From 20d591c51fb299a76d690748b0809d3c3dcfaa0d Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 20:10:38 +0100 Subject: [PATCH] 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() + }) +})