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 +}