diff --git a/apps/web/src/components/radar/CompactionEventsDialog.tsx b/apps/web/src/components/radar/CompactionEventsDialog.tsx new file mode 100644 index 0000000..e188376 --- /dev/null +++ b/apps/web/src/components/radar/CompactionEventsDialog.tsx @@ -0,0 +1,111 @@ +import { trpc } from '@/lib/trpc' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Skeleton } from '@/components/ui/skeleton' +import type { DrilldownDialogProps } from './types' + +function formatTimestamp(ts: string): string { + const date = new Date(ts) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSecs = Math.floor(diffMs / 1000) + const diffMins = Math.floor(diffSecs / 60) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + let relative: string + if (diffSecs < 60) { + relative = `${diffSecs}s ago` + } else if (diffMins < 60) { + relative = `${diffMins}m ago` + } else if (diffHours < 24) { + relative = `${diffHours}h ago` + } else { + relative = `${diffDays}d ago` + } + + const absolute = date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + + return `${relative} · ${absolute}` +} + +export function CompactionEventsDialog({ + open, + onOpenChange, + agentId, + agentName, +}: DrilldownDialogProps) { + const { data, isLoading } = trpc.agent.getCompactionEvents.useQuery( + { agentId }, + { enabled: open } + ) + + return ( + + + + {`Compaction Events — ${agentName}`} + + Each row is a context-window compaction — the model's history was summarized to + free up space. Frequent compactions indicate a long-running agent with large context. + + + +
+ {isLoading ? ( +
+ {[0, 1, 2].map((i) => ( +
+
+ + +
+ {i < 2 &&
} +
+ ))} +
+ ) : !data || data.length === 0 ? ( +

No data found

+ ) : ( + <> + {data.length >= 200 && ( +

Showing first 200 instances.

+ )} + + + + + + + + + {data.map((row, i) => ( + + + + + ))} + +
TimestampSession #
+ {formatTimestamp(row.timestamp)} + {row.sessionNumber}
+ + )} +
+ +
+ ) +} diff --git a/apps/web/src/components/radar/InterAgentMessagesDialog.tsx b/apps/web/src/components/radar/InterAgentMessagesDialog.tsx new file mode 100644 index 0000000..57461f8 --- /dev/null +++ b/apps/web/src/components/radar/InterAgentMessagesDialog.tsx @@ -0,0 +1,167 @@ +import { useState, useEffect, Fragment } from 'react' +import { trpc } from '@/lib/trpc' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Skeleton } from '@/components/ui/skeleton' +import { Badge } from '@/components/ui/badge' +import type { DrilldownDialogProps } from './types' + +function formatTimestamp(ts: string): string { + const date = new Date(ts) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSecs = Math.floor(diffMs / 1000) + const diffMins = Math.floor(diffSecs / 60) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + let relative: string + if (diffSecs < 60) { + relative = `${diffSecs}s ago` + } else if (diffMins < 60) { + relative = `${diffMins}m ago` + } else if (diffHours < 24) { + relative = `${diffHours}h ago` + } else { + relative = `${diffDays}d ago` + } + + const absolute = date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + + return `${relative} · ${absolute}` +} + +export function InterAgentMessagesDialog({ + open, + onOpenChange, + agentId, + agentName, +}: DrilldownDialogProps) { + const [expandedIndex, setExpandedIndex] = useState(null) + + const { data, isLoading } = trpc.conversation.getByFromAgent.useQuery( + { agentId }, + { enabled: open } + ) + + useEffect(() => { + if (!open) setExpandedIndex(null) + }, [open]) + + return ( + + + + {`Inter-Agent Messages — ${agentName}`} + + Each row is a conversation this agent initiated with another agent. Click a row to see + the full question and answer. + + + +
+ {isLoading ? ( +
+ {[0, 1, 2].map((i) => ( +
+
+ + + +
+ {i < 2 &&
} +
+ ))} +
+ ) : !data || data.length === 0 ? ( +

No data found

+ ) : ( + <> + {data.length >= 200 && ( +

Showing first 200 instances.

+ )} + + + + + + + + + + {data.map((row, i) => ( + + setExpandedIndex(i === expandedIndex ? null : i)} + > + + + + + {expandedIndex === i && ( + + + + )} + + ))} + +
TimestampTarget AgentStatus
+ {formatTimestamp(row.timestamp)} + {row.toAgentName} + {row.status === 'answered' ? ( + answered + ) : ( + + pending + + )} +
+
+

+ Question +

+
+ {row.question} +
+ {row.status === 'answered' ? ( + <> +

+ Answer +

+
+ {row.answer} +
+ + ) : ( +

No answer yet

+ )} +
+
+ + )} +
+ +
+ ) +} diff --git a/apps/web/src/components/radar/QuestionsAskedDialog.tsx b/apps/web/src/components/radar/QuestionsAskedDialog.tsx new file mode 100644 index 0000000..49da631 --- /dev/null +++ b/apps/web/src/components/radar/QuestionsAskedDialog.tsx @@ -0,0 +1,159 @@ +import { useState, useEffect, Fragment } from 'react' +import { trpc } from '@/lib/trpc' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Skeleton } from '@/components/ui/skeleton' +import type { DrilldownDialogProps } from './types' + +function formatTimestamp(ts: string): string { + const date = new Date(ts) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSecs = Math.floor(diffMs / 1000) + const diffMins = Math.floor(diffSecs / 60) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + let relative: string + if (diffSecs < 60) { + relative = `${diffSecs}s ago` + } else if (diffMins < 60) { + relative = `${diffMins}m ago` + } else if (diffHours < 24) { + relative = `${diffHours}h ago` + } else { + relative = `${diffDays}d ago` + } + + const absolute = date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + + return `${relative} · ${absolute}` +} + +function truncate(text: string, max: number): string { + return text.length > max ? text.slice(0, max) + '…' : text +} + +export function QuestionsAskedDialog({ + open, + onOpenChange, + agentId, + agentName, +}: DrilldownDialogProps) { + const [expandedIndex, setExpandedIndex] = useState(null) + + const { data, isLoading } = trpc.agent.getQuestionsAsked.useQuery( + { agentId }, + { enabled: open } + ) + + useEffect(() => { + if (!open) setExpandedIndex(null) + }, [open]) + + return ( + + + + {`Questions Asked — ${agentName}`} + + Each row is a question this agent sent to the user via the AskUserQuestion tool. + + + +
+ {isLoading ? ( +
+ {[0, 1, 2].map((i) => ( +
+
+ + + +
+ {i < 2 &&
} +
+ ))} +
+ ) : !data || data.length === 0 ? ( +

No data found

+ ) : ( + <> + {data.length >= 200 && ( +

Showing first 200 instances.

+ )} + + + + + + + + + + {data.map((row, i) => { + const n = row.questions.length + const countLabel = `${n} question${n !== 1 ? 's' : ''}` + const firstHeader = truncate(row.questions[0]?.header ?? '', 40) + return ( + + setExpandedIndex(i === expandedIndex ? null : i)} + > + + + + + {expandedIndex === i && ( + + + + )} + + ) + })} + +
Timestamp# QuestionsFirst Question Header
+ {formatTimestamp(row.timestamp)} + {countLabel}{firstHeader}
+
+
    + {row.questions.map((q, qi) => ( +
  1. + + {q.header} + + {q.question} +
      + {q.options.map((opt, oi) => ( +
    • + {`• ${opt.label} — ${opt.description}`} +
    • + ))} +
    +
  2. + ))} +
+
+
+ + )} +
+ +
+ ) +} diff --git a/apps/web/src/components/radar/SubagentSpawnsDialog.tsx b/apps/web/src/components/radar/SubagentSpawnsDialog.tsx new file mode 100644 index 0000000..2fe27e9 --- /dev/null +++ b/apps/web/src/components/radar/SubagentSpawnsDialog.tsx @@ -0,0 +1,143 @@ +import { useState, useEffect, Fragment } from 'react' +import { trpc } from '@/lib/trpc' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Skeleton } from '@/components/ui/skeleton' +import type { DrilldownDialogProps } from './types' + +function formatTimestamp(ts: string): string { + const date = new Date(ts) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSecs = Math.floor(diffMs / 1000) + const diffMins = Math.floor(diffSecs / 60) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + let relative: string + if (diffSecs < 60) { + relative = `${diffSecs}s ago` + } else if (diffMins < 60) { + relative = `${diffMins}m ago` + } else if (diffHours < 24) { + relative = `${diffHours}h ago` + } else { + relative = `${diffDays}d ago` + } + + const absolute = date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + + return `${relative} · ${absolute}` +} + +export function SubagentSpawnsDialog({ + open, + onOpenChange, + agentId, + agentName, +}: DrilldownDialogProps) { + const [expandedIndex, setExpandedIndex] = useState(null) + + const { data, isLoading } = trpc.agent.getSubagentSpawns.useQuery( + { agentId }, + { enabled: open } + ) + + useEffect(() => { + if (!open) setExpandedIndex(null) + }, [open]) + + return ( + + + + {`Subagent Spawns — ${agentName}`} + + Each row is an Agent tool call — a subagent spawned by this agent. The description and + first 200 characters of the prompt are shown. + + + +
+ {isLoading ? ( +
+ {[0, 1, 2].map((i) => ( +
+
+ + + +
+ {i < 2 &&
} +
+ ))} +
+ ) : !data || data.length === 0 ? ( +

No data found

+ ) : ( + <> + {data.length >= 200 && ( +

Showing first 200 instances.

+ )} + + + + + + + + + + {data.map((row, i) => ( + + setExpandedIndex(i === expandedIndex ? null : i)} + > + + + + + {expandedIndex === i && ( + + + + )} + + ))} + +
TimestampDescriptionPrompt Preview
+ {formatTimestamp(row.timestamp)} + {row.description} + {row.promptPreview} + {row.fullPrompt.length > row.promptPreview.length && ( + + )} +
+
+
{row.fullPrompt}
+
+
+ + )} +
+ +
+ ) +} diff --git a/apps/web/src/components/radar/__tests__/CompactionEventsDialog.test.tsx b/apps/web/src/components/radar/__tests__/CompactionEventsDialog.test.tsx new file mode 100644 index 0000000..94a2e8a --- /dev/null +++ b/apps/web/src/components/radar/__tests__/CompactionEventsDialog.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { render, screen } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach } from 'vitest' + +let mockUseQueryReturn: { data: unknown; isLoading: boolean } = { + data: undefined, + isLoading: false, +} + +vi.mock('@/lib/trpc', () => ({ + trpc: { + agent: { + getCompactionEvents: { + useQuery: vi.fn((_args: unknown, _opts: unknown) => mockUseQueryReturn), + }, + }, + }, +})) + +import { CompactionEventsDialog } from '../CompactionEventsDialog' + +const defaultProps = { + open: true, + onOpenChange: vi.fn(), + agentId: 'agent-123', + agentName: 'test-agent', +} + +describe('CompactionEventsDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseQueryReturn = { data: undefined, isLoading: false } + }) + + it('does not render dialog content when open=false', () => { + render() + expect(screen.queryByText(/Compaction Events/)).toBeNull() + }) + + it('shows skeleton rows when loading', () => { + mockUseQueryReturn = { data: undefined, isLoading: true } + render() + const skeletons = document.querySelectorAll('.animate-pulse') + expect(skeletons.length).toBeGreaterThanOrEqual(3) + expect(screen.queryByRole('table')).toBeNull() + }) + + it('shows "No data found" when data is empty', () => { + mockUseQueryReturn = { data: [], isLoading: false } + render() + expect(screen.getByText('No data found')).toBeInTheDocument() + }) + + it('renders data rows correctly', () => { + mockUseQueryReturn = { + data: [{ timestamp: '2026-03-06T10:00:00.000Z', sessionNumber: 3 }], + isLoading: false, + } + render() + expect(screen.getByText('3')).toBeInTheDocument() + // Timestamp includes year 2026 + expect(screen.getByText(/2026/)).toBeInTheDocument() + expect(screen.queryByText('Showing first 200 instances.')).toBeNull() + }) + + it('shows 200-instance note when data length is 200', () => { + mockUseQueryReturn = { + data: Array(200).fill({ timestamp: '2026-03-06T10:00:00.000Z', sessionNumber: 1 }), + isLoading: false, + } + render() + expect(screen.getByText('Showing first 200 instances.')).toBeInTheDocument() + }) + + it('renders dialog title and subtitle', () => { + mockUseQueryReturn = { data: [], isLoading: false } + render() + expect(screen.getByText(/Compaction Events — test-agent/)).toBeInTheDocument() + expect(screen.getByText(/context-window compaction/)).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/radar/__tests__/InterAgentMessagesDialog.test.tsx b/apps/web/src/components/radar/__tests__/InterAgentMessagesDialog.test.tsx new file mode 100644 index 0000000..017a729 --- /dev/null +++ b/apps/web/src/components/radar/__tests__/InterAgentMessagesDialog.test.tsx @@ -0,0 +1,172 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach } from 'vitest' + +let mockUseQueryReturn: { data: unknown; isLoading: boolean } = { + data: undefined, + isLoading: false, +} + +vi.mock('@/lib/trpc', () => ({ + trpc: { + conversation: { + getByFromAgent: { + useQuery: vi.fn((_args: unknown, _opts: unknown) => mockUseQueryReturn), + }, + }, + }, +})) + +import { InterAgentMessagesDialog } from '../InterAgentMessagesDialog' + +const defaultProps = { + open: true, + onOpenChange: vi.fn(), + agentId: 'agent-123', + agentName: 'test-agent', +} + +describe('InterAgentMessagesDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseQueryReturn = { data: undefined, isLoading: false } + }) + + it('does not render dialog content when open=false', () => { + render() + expect(screen.queryByText(/Inter-Agent Messages/)).toBeNull() + }) + + it('shows skeleton rows when loading', () => { + mockUseQueryReturn = { data: undefined, isLoading: true } + render() + const skeletons = document.querySelectorAll('.animate-pulse') + expect(skeletons.length).toBeGreaterThanOrEqual(3) + expect(screen.queryByRole('table')).toBeNull() + }) + + it('shows "No data found" when data is empty', () => { + mockUseQueryReturn = { data: [], isLoading: false } + render() + expect(screen.getByText('No data found')).toBeInTheDocument() + }) + + it('renders data rows for answered conversation', () => { + mockUseQueryReturn = { + data: [ + { + id: 'c1', + timestamp: '2026-03-06T10:00:00.000Z', + toAgentName: 'target-agent', + toAgentId: 'agent-2', + question: 'What is the export path?', + answer: 'It is src/api/index.ts', + status: 'answered', + taskId: null, + phaseId: null, + }, + ], + isLoading: false, + } + render() + expect(screen.getByText('target-agent')).toBeInTheDocument() + expect(screen.getByText('answered')).toBeInTheDocument() + expect(screen.queryByText('What is the export path?')).toBeNull() + }) + + it('expands answered row to show question and answer', () => { + mockUseQueryReturn = { + data: [ + { + id: 'c1', + timestamp: '2026-03-06T10:00:00.000Z', + toAgentName: 'target-agent', + toAgentId: 'agent-2', + question: 'What is the export path?', + answer: 'It is src/api/index.ts', + status: 'answered', + taskId: null, + phaseId: null, + }, + ], + isLoading: false, + } + render() + + fireEvent.click(screen.getByText('target-agent').closest('tr')!) + expect(screen.getByText('What is the export path?')).toBeInTheDocument() + expect(screen.getByText('It is src/api/index.ts')).toBeInTheDocument() + expect(screen.queryByText('No answer yet')).toBeNull() + }) + + it('expands pending row to show question and "No answer yet"', () => { + mockUseQueryReturn = { + data: [ + { + id: 'c2', + timestamp: '2026-03-06T10:00:00.000Z', + toAgentName: 'target-agent', + toAgentId: 'agent-2', + question: 'What is the export path?', + answer: null, + status: 'pending', + taskId: null, + phaseId: null, + }, + ], + isLoading: false, + } + render() + + fireEvent.click(screen.getByText('target-agent').closest('tr')!) + expect(screen.getByText('What is the export path?')).toBeInTheDocument() + expect(screen.getByText('No answer yet')).toBeInTheDocument() + expect(screen.queryByText('It is src/api/index.ts')).toBeNull() + }) + + it('collapses row when clicked again', () => { + mockUseQueryReturn = { + data: [ + { + id: 'c1', + timestamp: '2026-03-06T10:00:00.000Z', + toAgentName: 'target-agent', + toAgentId: 'agent-2', + question: 'What is the export path?', + answer: 'It is src/api/index.ts', + status: 'answered', + taskId: null, + phaseId: null, + }, + ], + isLoading: false, + } + render() + + const row = screen.getByText('target-agent').closest('tr')! + fireEvent.click(row) + expect(screen.getByText('What is the export path?')).toBeInTheDocument() + fireEvent.click(row) + expect(screen.queryByText('What is the export path?')).toBeNull() + }) + + it('shows 200-instance note when data length is 200', () => { + mockUseQueryReturn = { + data: Array(200).fill({ + id: 'c1', + timestamp: '2026-03-06T10:00:00.000Z', + toAgentName: 'target-agent', + toAgentId: 'agent-2', + question: 'What is the export path?', + answer: null, + status: 'pending', + taskId: null, + phaseId: null, + }), + isLoading: false, + } + render() + expect(screen.getByText('Showing first 200 instances.')).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/radar/__tests__/QuestionsAskedDialog.test.tsx b/apps/web/src/components/radar/__tests__/QuestionsAskedDialog.test.tsx new file mode 100644 index 0000000..d15e2f6 --- /dev/null +++ b/apps/web/src/components/radar/__tests__/QuestionsAskedDialog.test.tsx @@ -0,0 +1,147 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach } from 'vitest' + +let mockUseQueryReturn: { data: unknown; isLoading: boolean } = { + data: undefined, + isLoading: false, +} + +vi.mock('@/lib/trpc', () => ({ + trpc: { + agent: { + getQuestionsAsked: { + useQuery: vi.fn((_args: unknown, _opts: unknown) => mockUseQueryReturn), + }, + }, + }, +})) + +import { QuestionsAskedDialog } from '../QuestionsAskedDialog' + +const defaultProps = { + open: true, + onOpenChange: vi.fn(), + agentId: 'agent-123', + agentName: 'test-agent', +} + +describe('QuestionsAskedDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseQueryReturn = { data: undefined, isLoading: false } + }) + + it('does not render dialog content when open=false', () => { + render() + expect(screen.queryByText(/Questions Asked/)).toBeNull() + }) + + it('shows skeleton rows when loading', () => { + mockUseQueryReturn = { data: undefined, isLoading: true } + render() + const skeletons = document.querySelectorAll('.animate-pulse') + expect(skeletons.length).toBeGreaterThanOrEqual(3) + expect(screen.queryByRole('table')).toBeNull() + }) + + it('shows "No data found" when data is empty', () => { + mockUseQueryReturn = { data: [], isLoading: false } + render() + expect(screen.getByText('No data found')).toBeInTheDocument() + }) + + it('renders data rows correctly', () => { + mockUseQueryReturn = { + data: [ + { + timestamp: '2026-03-06T10:00:00.000Z', + questions: [ + { question: 'Pick a method', header: 'Method', options: [{ label: 'A', description: 'Option A' }] }, + { question: 'Pick a strategy', header: 'Strategy', options: [{ label: 'B', description: 'Option B' }] }, + ], + }, + ], + isLoading: false, + } + render() + expect(screen.getByText('2 questions')).toBeInTheDocument() + expect(screen.getByText('Method')).toBeInTheDocument() + expect(screen.queryByText('Pick a method')).toBeNull() + }) + + it('expands row to show all sub-questions on click', () => { + mockUseQueryReturn = { + data: [ + { + timestamp: '2026-03-06T10:00:00.000Z', + questions: [ + { question: 'Pick a method', header: 'Method', options: [{ label: 'A', description: 'Option A' }] }, + { question: 'Pick a strategy', header: 'Strategy', options: [{ label: 'B', description: 'Option B' }] }, + ], + }, + ], + isLoading: false, + } + render() + + fireEvent.click(screen.getByText('2 questions').closest('tr')!) + expect(screen.getByText('Pick a method')).toBeInTheDocument() + expect(screen.getByText('Pick a strategy')).toBeInTheDocument() + expect(screen.getByText('• A — Option A')).toBeInTheDocument() + }) + + it('collapses row when clicked again', () => { + mockUseQueryReturn = { + data: [ + { + timestamp: '2026-03-06T10:00:00.000Z', + questions: [ + { question: 'Pick a method', header: 'Method', options: [{ label: 'A', description: 'Option A' }] }, + { question: 'Pick a strategy', header: 'Strategy', options: [{ label: 'B', description: 'Option B' }] }, + ], + }, + ], + isLoading: false, + } + render() + + const row = screen.getByText('2 questions').closest('tr')! + fireEvent.click(row) + expect(screen.getByText('Pick a method')).toBeInTheDocument() + fireEvent.click(row) + expect(screen.queryByText('Pick a method')).toBeNull() + }) + + it('shows 200-instance note when data length is 200', () => { + mockUseQueryReturn = { + data: Array(200).fill({ + timestamp: '2026-03-06T10:00:00.000Z', + questions: [ + { question: 'Pick a method', header: 'Method', options: [] }, + ], + }), + isLoading: false, + } + render() + expect(screen.getByText('Showing first 200 instances.')).toBeInTheDocument() + }) + + it('shows singular "1 question" for single-question rows', () => { + mockUseQueryReturn = { + data: [ + { + timestamp: '2026-03-06T10:00:00.000Z', + questions: [ + { question: 'Only one', header: 'Single', options: [] }, + ], + }, + ], + isLoading: false, + } + render() + expect(screen.getByText('1 question')).toBeInTheDocument() + expect(screen.queryByText('1 questions')).toBeNull() + }) +}) diff --git a/apps/web/src/components/radar/__tests__/SubagentSpawnsDialog.test.tsx b/apps/web/src/components/radar/__tests__/SubagentSpawnsDialog.test.tsx new file mode 100644 index 0000000..de2cb7e --- /dev/null +++ b/apps/web/src/components/radar/__tests__/SubagentSpawnsDialog.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach } from 'vitest' + +let mockUseQueryReturn: { data: unknown; isLoading: boolean } = { + data: undefined, + isLoading: false, +} + +vi.mock('@/lib/trpc', () => ({ + trpc: { + agent: { + getSubagentSpawns: { + useQuery: vi.fn((_args: unknown, _opts: unknown) => mockUseQueryReturn), + }, + }, + }, +})) + +import { SubagentSpawnsDialog } from '../SubagentSpawnsDialog' + +const defaultProps = { + open: true, + onOpenChange: vi.fn(), + agentId: 'agent-123', + agentName: 'test-agent', +} + +describe('SubagentSpawnsDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseQueryReturn = { data: undefined, isLoading: false } + }) + + it('does not render dialog content when open=false', () => { + render() + expect(screen.queryByText(/Subagent Spawns/)).toBeNull() + }) + + it('shows skeleton rows when loading', () => { + mockUseQueryReturn = { data: undefined, isLoading: true } + render() + const skeletons = document.querySelectorAll('.animate-pulse') + expect(skeletons.length).toBeGreaterThanOrEqual(3) + expect(screen.queryByRole('table')).toBeNull() + }) + + it('shows "No data found" when data is empty', () => { + mockUseQueryReturn = { data: [], isLoading: false } + render() + expect(screen.getByText('No data found')).toBeInTheDocument() + }) + + it('renders data rows correctly', () => { + mockUseQueryReturn = { + data: [ + { + timestamp: '2026-03-06T10:00:00.000Z', + description: 'my task', + promptPreview: 'hello', + fullPrompt: 'hello world full', + }, + ], + isLoading: false, + } + render() + expect(screen.getByText('my task')).toBeInTheDocument() + expect(screen.getByText('hello')).toBeInTheDocument() + expect(screen.queryByText('hello world full')).toBeNull() + }) + + it('expands and collapses row on click', () => { + mockUseQueryReturn = { + data: [ + { + timestamp: '2026-03-06T10:00:00.000Z', + description: 'my task', + promptPreview: 'hello', + fullPrompt: 'hello world full', + }, + ], + isLoading: false, + } + render() + + // Click the row — should expand + fireEvent.click(screen.getByText('my task').closest('tr')!) + expect(screen.getByText('hello world full')).toBeInTheDocument() + + // Click again — should collapse + fireEvent.click(screen.getByText('my task').closest('tr')!) + expect(screen.queryByText('hello world full')).toBeNull() + }) + + it('shows ellipsis suffix when fullPrompt is longer than promptPreview', () => { + const fullPrompt = 'A'.repeat(201) + const promptPreview = fullPrompt.slice(0, 200) + mockUseQueryReturn = { + data: [ + { + timestamp: '2026-03-06T10:00:00.000Z', + description: 'truncated task', + promptPreview, + fullPrompt, + }, + ], + isLoading: false, + } + render() + expect(screen.getByText('…')).toBeInTheDocument() + }) + + it('shows 200-instance note when data length is 200', () => { + mockUseQueryReturn = { + data: Array(200).fill({ + timestamp: '2026-03-06T10:00:00.000Z', + description: 'task', + promptPreview: 'prompt', + fullPrompt: 'full prompt', + }), + isLoading: false, + } + render() + expect(screen.getByText('Showing first 200 instances.')).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/radar/types.ts b/apps/web/src/components/radar/types.ts new file mode 100644 index 0000000..67ea1ac --- /dev/null +++ b/apps/web/src/components/radar/types.ts @@ -0,0 +1,6 @@ +export interface DrilldownDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + agentId: string + agentName: string +}