From cb4519439dcbe902f1192039aa7ec76d9f53d807 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 20:06:32 +0100 Subject: [PATCH 1/2] feat: add CompactionEventsDialog and SubagentSpawnsDialog with tests Implements two Radar drilldown dialog components following the AddAccountDialog pattern (Radix UI Dialog + tRPC lazy query + skeleton loading). CompactionEventsDialog shows a simple table of compaction events; SubagentSpawnsDialog adds expandable rows that reveal the full Agent tool prompt. Shared DrilldownDialogProps type in types.ts. Co-Authored-By: Claude Sonnet 4.6 --- .../radar/CompactionEventsDialog.tsx | 111 ++++++++++++++ .../components/radar/SubagentSpawnsDialog.tsx | 143 ++++++++++++++++++ .../__tests__/CompactionEventsDialog.test.tsx | 82 ++++++++++ .../__tests__/SubagentSpawnsDialog.test.tsx | 127 ++++++++++++++++ apps/web/src/components/radar/types.ts | 6 + 5 files changed, 469 insertions(+) create mode 100644 apps/web/src/components/radar/CompactionEventsDialog.tsx create mode 100644 apps/web/src/components/radar/SubagentSpawnsDialog.tsx create mode 100644 apps/web/src/components/radar/__tests__/CompactionEventsDialog.test.tsx create mode 100644 apps/web/src/components/radar/__tests__/SubagentSpawnsDialog.test.tsx create mode 100644 apps/web/src/components/radar/types.ts 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/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__/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 +} From 20d591c51fb299a76d690748b0809d3c3dcfaa0d Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 20:10:38 +0100 Subject: [PATCH 2/2] feat: add QuestionsAskedDialog and InterAgentMessagesDialog with tests Implements the remaining two Radar drilldown dialogs following the established AddAccountDialog pattern. Both use tRPC lazy queries, skeleton loading, and expandable rows via useState. Co-Authored-By: Claude Sonnet 4.6 --- .../radar/InterAgentMessagesDialog.tsx | 167 +++++++++++++++++ .../components/radar/QuestionsAskedDialog.tsx | 159 ++++++++++++++++ .../InterAgentMessagesDialog.test.tsx | 172 ++++++++++++++++++ .../__tests__/QuestionsAskedDialog.test.tsx | 147 +++++++++++++++ 4 files changed, 645 insertions(+) create mode 100644 apps/web/src/components/radar/InterAgentMessagesDialog.tsx create mode 100644 apps/web/src/components/radar/QuestionsAskedDialog.tsx create mode 100644 apps/web/src/components/radar/__tests__/InterAgentMessagesDialog.test.tsx create mode 100644 apps/web/src/components/radar/__tests__/QuestionsAskedDialog.test.tsx 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/__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() + }) +})