From 5598e1c10f85d7b26539c3cf05f62da6679938f7 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 16:40:18 +0100 Subject: [PATCH 1/6] feat: implement Radar backend tRPC procedures with repository extensions Add five new tRPC query procedures powering the Radar page's per-agent behavioral metrics (questions asked, subagent spawns, compaction events, inter-agent messages) plus the batch repository methods they require. Repository changes: - LogChunkRepository: add findByAgentIds() for batch fetching without N+1 - ConversationRepository: add countByFromAgentIds() and findByFromAgentId() - Drizzle adapters: implement all three new methods using inArray() - InMemoryConversationRepository (integration test): implement new methods tRPC procedures added: - agent.listForRadar: filtered agent list with per-agent metrics computed from log chunks (questionsCount, subagentsCount, compactionsCount) and conversation counts (messagesCount); supports timeRange/status/mode/initiative filters - agent.getCompactionEvents: compact system init chunks for one agent (cap 200) - agent.getSubagentSpawns: Agent tool_use entries with prompt preview (cap 200) - agent.getQuestionsAsked: AskUserQuestion tool calls with questions array (cap 200) - conversation.getByFromAgent: conversations by fromAgentId with toAgentName resolved All 13 new unit tests pass; existing test suite unaffected. Co-Authored-By: Claude Sonnet 4.6 --- .../repositories/conversation-repository.ts | 14 + .../db/repositories/drizzle/conversation.ts | 24 +- .../db/repositories/drizzle/log-chunk.ts | 16 +- .../db/repositories/log-chunk-repository.ts | 7 + .../real-providers/conversation.test.ts | 18 + .../server/test/unit/radar-procedures.test.ts | 476 ++++++++++++++++++ apps/server/trpc/routers/agent.ts | 190 ++++++- apps/server/trpc/routers/conversation.ts | 26 + docs/database.md | 4 +- docs/server-api.md | 5 + 10 files changed, 775 insertions(+), 5 deletions(-) create mode 100644 apps/server/test/unit/radar-procedures.test.ts diff --git a/apps/server/db/repositories/conversation-repository.ts b/apps/server/db/repositories/conversation-repository.ts index 0545ed1..66fe045 100644 --- a/apps/server/db/repositories/conversation-repository.ts +++ b/apps/server/db/repositories/conversation-repository.ts @@ -20,4 +20,18 @@ export interface ConversationRepository { findById(id: string): Promise; findPendingForAgent(toAgentId: string): Promise; answer(id: string, answer: string): Promise; + + /** + * Count conversations grouped by fromAgentId for a batch of agent IDs. + * Returns only agents that have at least one conversation (count > 0). + * Used by listForRadar to compute messagesCount without N+1 queries. + */ + countByFromAgentIds(agentIds: string[]): Promise<{ agentId: string; count: number }[]>; + + /** + * Find all conversations initiated by a given agent, ordered by createdAt ascending. + * Used by conversation.getByFromAgent drilldown procedure. + * Cap at 200 results. + */ + findByFromAgentId(agentId: string): Promise; } diff --git a/apps/server/db/repositories/drizzle/conversation.ts b/apps/server/db/repositories/drizzle/conversation.ts index 6ab11dd..1e359d3 100644 --- a/apps/server/db/repositories/drizzle/conversation.ts +++ b/apps/server/db/repositories/drizzle/conversation.ts @@ -4,7 +4,7 @@ * Implements ConversationRepository interface using Drizzle ORM. */ -import { eq, and, asc } from 'drizzle-orm'; +import { eq, and, asc, count, inArray } from 'drizzle-orm'; import { nanoid } from 'nanoid'; import type { DrizzleDatabase } from '../../index.js'; import { conversations, type Conversation } from '../../schema.js'; @@ -64,4 +64,26 @@ export class DrizzleConversationRepository implements ConversationRepository { .where(eq(conversations.id, id)); return this.findById(id); } + + async countByFromAgentIds(agentIds: string[]): Promise<{ agentId: string; count: number }[]> { + if (agentIds.length === 0) return []; + const rows = await this.db + .select({ + agentId: conversations.fromAgentId, + count: count(), + }) + .from(conversations) + .where(inArray(conversations.fromAgentId, agentIds)) + .groupBy(conversations.fromAgentId); + return rows.map(r => ({ agentId: r.agentId, count: Number(r.count) })); + } + + async findByFromAgentId(agentId: string): Promise { + return this.db + .select() + .from(conversations) + .where(eq(conversations.fromAgentId, agentId)) + .orderBy(asc(conversations.createdAt)) + .limit(200); + } } diff --git a/apps/server/db/repositories/drizzle/log-chunk.ts b/apps/server/db/repositories/drizzle/log-chunk.ts index 244b09b..9d4632b 100644 --- a/apps/server/db/repositories/drizzle/log-chunk.ts +++ b/apps/server/db/repositories/drizzle/log-chunk.ts @@ -4,7 +4,7 @@ * Implements LogChunkRepository interface using Drizzle ORM. */ -import { eq, asc, max } from 'drizzle-orm'; +import { eq, asc, max, inArray } from 'drizzle-orm'; import { nanoid } from 'nanoid'; import type { DrizzleDatabase } from '../../index.js'; import { agentLogChunks } from '../../schema.js'; @@ -41,6 +41,20 @@ export class DrizzleLogChunkRepository implements LogChunkRepository { .orderBy(asc(agentLogChunks.createdAt)); } + async findByAgentIds(agentIds: string[]): Promise<{ agentId: string; content: string; sessionNumber: number; createdAt: Date }[]> { + if (agentIds.length === 0) return []; + return this.db + .select({ + agentId: agentLogChunks.agentId, + content: agentLogChunks.content, + sessionNumber: agentLogChunks.sessionNumber, + createdAt: agentLogChunks.createdAt, + }) + .from(agentLogChunks) + .where(inArray(agentLogChunks.agentId, agentIds)) + .orderBy(asc(agentLogChunks.createdAt)); + } + async deleteByAgentId(agentId: string): Promise { await this.db .delete(agentLogChunks) diff --git a/apps/server/db/repositories/log-chunk-repository.ts b/apps/server/db/repositories/log-chunk-repository.ts index 9f83231..0283a0b 100644 --- a/apps/server/db/repositories/log-chunk-repository.ts +++ b/apps/server/db/repositories/log-chunk-repository.ts @@ -17,6 +17,13 @@ export interface LogChunkRepository { findByAgentId(agentId: string): Promise[]>; + /** + * Batch-fetch chunks for multiple agent IDs in a single query. + * Returns chunks ordered by createdAt ascending. + * agentId field is included so results can be grouped by agent. + */ + findByAgentIds(agentIds: string[]): Promise<{ agentId: string; content: string; sessionNumber: number; createdAt: Date }[]>; + deleteByAgentId(agentId: string): Promise; getSessionCount(agentId: string): Promise; diff --git a/apps/server/test/integration/real-providers/conversation.test.ts b/apps/server/test/integration/real-providers/conversation.test.ts index a3d168e..1d10ffc 100644 --- a/apps/server/test/integration/real-providers/conversation.test.ts +++ b/apps/server/test/integration/real-providers/conversation.test.ts @@ -88,6 +88,24 @@ class InMemoryConversationRepository implements ConversationRepository { return updated; } + async countByFromAgentIds(agentIds: string[]): Promise<{ agentId: string; count: number }[]> { + if (agentIds.length === 0) return []; + const counts = new Map(); + for (const conv of this.store.values()) { + if (agentIds.includes(conv.fromAgentId)) { + counts.set(conv.fromAgentId, (counts.get(conv.fromAgentId) ?? 0) + 1); + } + } + return [...counts.entries()].map(([agentId, count]) => ({ agentId, count })); + } + + async findByFromAgentId(agentId: string): Promise { + return [...this.store.values()] + .filter((c) => c.fromAgentId === agentId) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .slice(0, 200); + } + /** Test helper — return all conversations */ getAll(): Conversation[] { return [...this.store.values()]; diff --git a/apps/server/test/unit/radar-procedures.test.ts b/apps/server/test/unit/radar-procedures.test.ts new file mode 100644 index 0000000..d7acba6 --- /dev/null +++ b/apps/server/test/unit/radar-procedures.test.ts @@ -0,0 +1,476 @@ +/** + * Unit tests for Radar tRPC procedures. + * + * Tests listForRadar, getCompactionEvents, getSubagentSpawns, + * getQuestionsAsked, and conversation.getByFromAgent. + * + * Uses in-memory Drizzle DB + inline MockAgentManager for isolation. + */ + +import { describe, it, expect } from 'vitest'; +import { router, publicProcedure, createCallerFactory } from '../../trpc/trpc.js'; +import { agentProcedures } from '../../trpc/routers/agent.js'; +import { conversationProcedures } from '../../trpc/routers/conversation.js'; +import type { TRPCContext } from '../../trpc/context.js'; +import type { AgentManager, AgentInfo, PendingQuestions } from '../../agent/types.js'; +import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.js'; +import { + DrizzleAgentRepository, + DrizzleLogChunkRepository, + DrizzleConversationRepository, + DrizzleInitiativeRepository, + DrizzleTaskRepository, +} from '../../db/repositories/drizzle/index.js'; + +// ============================================================================= +// MockAgentManager +// ============================================================================= + +class MockAgentManager implements AgentManager { + private agents: AgentInfo[] = []; + private questions: Map = new Map(); + + addAgent(info: Partial & Pick): void { + this.agents.push({ + taskId: null, + initiativeId: null, + sessionId: null, + worktreeId: info.id, + mode: 'execute', + provider: 'claude', + accountId: null, + createdAt: new Date(), + updatedAt: new Date(), + userDismissedAt: null, + exitCode: null, + prompt: null, + ...info, + }); + } + + setQuestions(agentId: string, questions: PendingQuestions): void { + this.questions.set(agentId, questions); + } + + async list(): Promise { + return [...this.agents]; + } + + async getPendingQuestions(agentId: string): Promise { + return this.questions.get(agentId) ?? null; + } + + async spawn(): Promise { throw new Error('Not implemented'); } + async stop(): Promise { throw new Error('Not implemented'); } + async get(): Promise { return null; } + async getByName(): Promise { return null; } + async resume(): Promise { throw new Error('Not implemented'); } + async getResult() { return null; } + async delete(): Promise { throw new Error('Not implemented'); } + async dismiss(): Promise { throw new Error('Not implemented'); } + async resumeForConversation(): Promise { return false; } +} + +// ============================================================================= +// Test routers +// ============================================================================= + +const agentRouter = router({ ...agentProcedures(publicProcedure) }); +const conversationRouter = router({ ...conversationProcedures(publicProcedure) }); + +const createAgentCaller = createCallerFactory(agentRouter); +const createConversationCaller = createCallerFactory(conversationRouter); + +// ============================================================================= +// Helpers +// ============================================================================= + +function makeCtx(agentManager: MockAgentManager): TRPCContext { + const db = createTestDatabase(); + return { + eventBus: { emit: () => {}, on: () => {}, off: () => {} } as unknown as TRPCContext['eventBus'], + serverStartedAt: null, + processCount: 0, + agentManager, + logChunkRepository: new DrizzleLogChunkRepository(db), + conversationRepository: new DrizzleConversationRepository(db), + initiativeRepository: new DrizzleInitiativeRepository(db), + taskRepository: new DrizzleTaskRepository(db), + // Expose DB-backed agent repo seeder via a non-context helper + _agentRepository: new DrizzleAgentRepository(db), + } as unknown as TRPCContext & { _agentRepository: DrizzleAgentRepository }; +} + +// Typed helper to access seeder repos +function getRepos(ctx: ReturnType) { + const c = ctx as unknown as { + _agentRepository: DrizzleAgentRepository; + logChunkRepository: DrizzleLogChunkRepository; + conversationRepository: DrizzleConversationRepository; + initiativeRepository: DrizzleInitiativeRepository; + taskRepository: DrizzleTaskRepository; + }; + return { + agentRepo: c._agentRepository, + logChunkRepo: c.logChunkRepository, + convRepo: c.conversationRepository, + initiativeRepo: c.initiativeRepository, + taskRepo: c.taskRepository, + }; +} + +// ============================================================================= +// Tests: agent.listForRadar +// ============================================================================= + +describe('agent.listForRadar', () => { + it('timeRange=24h — excludes agents older than 24h', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + + const oldDate = new Date(Date.now() - 48 * 3600_000); + const recentDate = new Date(Date.now() - 12 * 3600_000); + + agents.addAgent({ id: 'agent-old', name: 'old-agent', status: 'stopped', createdAt: oldDate }); + agents.addAgent({ id: 'agent-recent', name: 'recent-agent', status: 'running', createdAt: recentDate }); + + const caller = createAgentCaller(ctx); + const result = await caller.listForRadar({ timeRange: '24h' }); + + expect(result.map(r => r.id)).not.toContain('agent-old'); + expect(result.map(r => r.id)).toContain('agent-recent'); + }); + + it('status=running filter — only running agents returned', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + + const now = new Date(); + agents.addAgent({ id: 'agent-running', name: 'running-agent', status: 'running', createdAt: now }); + agents.addAgent({ id: 'agent-stopped', name: 'stopped-agent', status: 'stopped', createdAt: now }); + + const caller = createAgentCaller(ctx); + const result = await caller.listForRadar({ timeRange: 'all', status: 'running' }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('agent-running'); + expect(result[0].status).toBe('running'); + }); + + it('status=completed filter — maps to stopped in DB', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + + const now = new Date(); + agents.addAgent({ id: 'agent-stopped', name: 'stopped-agent', status: 'stopped', createdAt: now }); + agents.addAgent({ id: 'agent-running', name: 'running-agent', status: 'running', createdAt: now }); + + const caller = createAgentCaller(ctx); + const result = await caller.listForRadar({ timeRange: 'all', status: 'completed' }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('agent-stopped'); + }); + + it('mode=execute filter — only execute agents returned', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + + const now = new Date(); + agents.addAgent({ id: 'agent-exec', name: 'exec-agent', status: 'running', mode: 'execute', createdAt: now }); + agents.addAgent({ id: 'agent-plan', name: 'plan-agent', status: 'running', mode: 'plan', createdAt: now }); + + const caller = createAgentCaller(ctx); + const result = await caller.listForRadar({ timeRange: 'all', mode: 'execute' }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('agent-exec'); + }); + + it('computes messagesCount from conversations table', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const { agentRepo, convRepo } = getRepos(ctx); + + const now = new Date(); + // Seed in DB (needed for FK on conversations) + const fromAgent = await agentRepo.create({ name: 'from-agent', worktreeId: 'wt-from', status: 'running' }); + const toAgent = await agentRepo.create({ name: 'to-agent', worktreeId: 'wt-to', status: 'running' }); + + // Seed in MockAgentManager for agentManager.list() + agents.addAgent({ id: fromAgent.id, name: fromAgent.name, status: 'running', createdAt: now }); + agents.addAgent({ id: toAgent.id, name: toAgent.name, status: 'running', createdAt: now }); + + // Create 3 conversations from fromAgent to toAgent + await convRepo.create({ fromAgentId: fromAgent.id, toAgentId: toAgent.id, question: 'Q1' }); + await convRepo.create({ fromAgentId: fromAgent.id, toAgentId: toAgent.id, question: 'Q2' }); + await convRepo.create({ fromAgentId: fromAgent.id, toAgentId: toAgent.id, question: 'Q3' }); + + const caller = createAgentCaller(ctx); + const result = await caller.listForRadar({ timeRange: 'all' }); + + const fromRow = result.find(r => r.id === fromAgent.id); + expect(fromRow).toBeDefined(); + expect(fromRow!.messagesCount).toBe(3); + + const toRow = result.find(r => r.id === toAgent.id); + expect(toRow!.messagesCount).toBe(0); + }); + + it('computes subagentsCount from log chunks', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const { logChunkRepo } = getRepos(ctx); + + const now = new Date(); + agents.addAgent({ id: 'agent-1', name: 'agent-one', status: 'running', createdAt: now }); + + await logChunkRepo.insertChunk({ + agentId: 'agent-1', + agentName: 'agent-one', + sessionNumber: 1, + content: JSON.stringify({ type: 'tool_use', name: 'Agent', input: { description: 'do stuff', prompt: 'some prompt' } }), + }); + + const caller = createAgentCaller(ctx); + const result = await caller.listForRadar({ timeRange: 'all' }); + + const row = result.find(r => r.id === 'agent-1'); + expect(row).toBeDefined(); + expect(row!.subagentsCount).toBe(1); + expect(row!.questionsCount).toBe(0); + expect(row!.compactionsCount).toBe(0); + }); + + it('computes compactionsCount from log chunks', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const { logChunkRepo } = getRepos(ctx); + + const now = new Date(); + agents.addAgent({ id: 'agent-1', name: 'agent-one', status: 'running', createdAt: now }); + + await logChunkRepo.insertChunk({ + agentId: 'agent-1', + agentName: 'agent-one', + sessionNumber: 1, + content: JSON.stringify({ type: 'system', subtype: 'init', source: 'compact' }), + }); + + const caller = createAgentCaller(ctx); + const result = await caller.listForRadar({ timeRange: 'all' }); + + const row = result.find(r => r.id === 'agent-1'); + expect(row).toBeDefined(); + expect(row!.compactionsCount).toBe(1); + expect(row!.subagentsCount).toBe(0); + expect(row!.questionsCount).toBe(0); + }); + + it('computes questionsCount from log chunks — sums questions array length', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const { logChunkRepo } = getRepos(ctx); + + const now = new Date(); + agents.addAgent({ id: 'agent-1', name: 'agent-one', status: 'running', createdAt: now }); + + await logChunkRepo.insertChunk({ + agentId: 'agent-1', + agentName: 'agent-one', + sessionNumber: 1, + content: JSON.stringify({ + type: 'tool_use', + name: 'AskUserQuestion', + input: { + questions: [ + { question: 'First?', header: 'H1', options: [] }, + { question: 'Second?', header: 'H2', options: [] }, + ], + }, + }), + }); + + const caller = createAgentCaller(ctx); + const result = await caller.listForRadar({ timeRange: 'all' }); + + const row = result.find(r => r.id === 'agent-1'); + expect(row).toBeDefined(); + expect(row!.questionsCount).toBe(2); + expect(row!.subagentsCount).toBe(0); + expect(row!.compactionsCount).toBe(0); + }); + + it('handles malformed JSON in log chunks without throwing', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const { logChunkRepo } = getRepos(ctx); + + const now = new Date(); + agents.addAgent({ id: 'agent-1', name: 'agent-one', status: 'running', createdAt: now }); + + await logChunkRepo.insertChunk({ + agentId: 'agent-1', + agentName: 'agent-one', + sessionNumber: 1, + content: 'not valid json {{{', + }); + + const caller = createAgentCaller(ctx); + // Should not throw + const result = await caller.listForRadar({ timeRange: 'all' }); + const row = result.find(r => r.id === 'agent-1'); + expect(row).toBeDefined(); + expect(row!.questionsCount).toBe(0); + expect(row!.subagentsCount).toBe(0); + expect(row!.compactionsCount).toBe(0); + }); +}); + +// ============================================================================= +// Tests: agent.getCompactionEvents +// ============================================================================= + +describe('agent.getCompactionEvents', () => { + it('returns compaction events sorted ascending, capped at 200', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const { logChunkRepo } = getRepos(ctx); + + // Seed 201 compaction chunks + for (let i = 0; i < 201; i++) { + await logChunkRepo.insertChunk({ + agentId: 'agent-compact', + agentName: 'compact-agent', + sessionNumber: i + 1, + content: JSON.stringify({ type: 'system', subtype: 'init', source: 'compact' }), + }); + } + + const caller = createAgentCaller(ctx); + const result = await caller.getCompactionEvents({ agentId: 'agent-compact' }); + + expect(result).toHaveLength(200); + // Each result has correct shape + expect(result[0]).toHaveProperty('timestamp'); + expect(result[0]).toHaveProperty('sessionNumber'); + expect(typeof result[0].timestamp).toBe('string'); + expect(typeof result[0].sessionNumber).toBe('number'); + // Sorted ascending — sessionNumber of first should be lower than last + expect(result[0].sessionNumber).toBeLessThan(result[199].sessionNumber); + }); +}); + +// ============================================================================= +// Tests: agent.getSubagentSpawns +// ============================================================================= + +describe('agent.getSubagentSpawns', () => { + it('returns spawns with correct promptPreview (first 200 chars)', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const { logChunkRepo } = getRepos(ctx); + + const fullPrompt = 'x'.repeat(300); + + await logChunkRepo.insertChunk({ + agentId: 'agent-spawn', + agentName: 'spawn-agent', + sessionNumber: 1, + content: JSON.stringify({ + type: 'tool_use', + name: 'Agent', + input: { description: 'my subagent', prompt: fullPrompt }, + }), + }); + + const caller = createAgentCaller(ctx); + const result = await caller.getSubagentSpawns({ agentId: 'agent-spawn' }); + + expect(result).toHaveLength(1); + expect(result[0].promptPreview).toHaveLength(200); + expect(result[0].fullPrompt).toHaveLength(300); + expect(result[0].description).toBe('my subagent'); + expect(typeof result[0].timestamp).toBe('string'); + }); +}); + +// ============================================================================= +// Tests: agent.getQuestionsAsked +// ============================================================================= + +describe('agent.getQuestionsAsked', () => { + it('returns questions arrays correctly', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const { logChunkRepo } = getRepos(ctx); + + await logChunkRepo.insertChunk({ + agentId: 'agent-q', + agentName: 'question-agent', + sessionNumber: 1, + content: JSON.stringify({ + type: 'tool_use', + name: 'AskUserQuestion', + input: { + questions: [ + { question: 'Which way?', header: 'Direction', options: [{ label: 'Left', description: 'Go left' }] }, + { question: 'How fast?', header: 'Speed', options: [{ label: 'Fast', description: 'Go fast' }] }, + ], + }, + }), + }); + + const caller = createAgentCaller(ctx); + const result = await caller.getQuestionsAsked({ agentId: 'agent-q' }); + + expect(result).toHaveLength(1); + expect(result[0].questions).toHaveLength(2); + expect(result[0].questions[0].question).toBe('Which way?'); + expect(result[0].questions[0].header).toBe('Direction'); + expect(result[0].questions[0].options).toHaveLength(1); + expect(result[0].questions[0].options[0].label).toBe('Left'); + expect(result[0].questions[1].question).toBe('How fast?'); + expect(typeof result[0].timestamp).toBe('string'); + }); +}); + +// ============================================================================= +// Tests: conversation.getByFromAgent +// ============================================================================= + +describe('conversation.getByFromAgent', () => { + it('returns conversations with toAgentName resolved', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const { agentRepo, convRepo } = getRepos(ctx); + + // Seed agents in DB (FK requirement) + const fromAgent = await agentRepo.create({ name: 'from-agent', worktreeId: 'wt-from', status: 'running' }); + const toAgent = await agentRepo.create({ name: 'to-agent', worktreeId: 'wt-to', status: 'running' }); + + // Seed in MockAgentManager for name resolution + agents.addAgent({ id: fromAgent.id, name: 'from-agent', status: 'running' }); + agents.addAgent({ id: toAgent.id, name: 'to-agent', status: 'running' }); + + // Create conversation + await convRepo.create({ + fromAgentId: fromAgent.id, + toAgentId: toAgent.id, + question: 'What is 2+2?', + }); + + const caller = createConversationCaller(ctx); + const result = await caller.getByFromAgent({ agentId: fromAgent.id }); + + expect(result).toHaveLength(1); + expect(result[0].toAgentName).toBe('to-agent'); + expect(result[0].toAgentId).toBe(toAgent.id); + expect(result[0].question).toBe('What is 2+2?'); + expect(result[0].status).toBe('pending'); + expect(result[0].answer).toBeNull(); + expect(typeof result[0].timestamp).toBe('string'); + expect(result[0].taskId).toBeNull(); + expect(result[0].phaseId).toBeNull(); + }); +}); diff --git a/apps/server/trpc/routers/agent.ts b/apps/server/trpc/routers/agent.ts index bdf6395..644b814 100644 --- a/apps/server/trpc/routers/agent.ts +++ b/apps/server/trpc/routers/agent.ts @@ -11,7 +11,23 @@ import type { ProcedureBuilder } from '../trpc.js'; import type { TRPCContext } from '../context.js'; import type { AgentInfo, AgentResult, PendingQuestions } from '../../agent/types.js'; import type { AgentOutputEvent } from '../../events/types.js'; -import { requireAgentManager, requireLogChunkRepository, requireTaskRepository, requireInitiativeRepository } from './_helpers.js'; +import { requireAgentManager, requireLogChunkRepository, requireTaskRepository, requireInitiativeRepository, requireConversationRepository } from './_helpers.js'; + +export 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; +}; export const spawnAgentInputSchema = z.object({ name: z.string().min(1).optional(), @@ -410,5 +426,177 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) { return { content: truncateIfNeeded(raw) }; }), + + listForRadar: publicProcedure + .input(z.object({ + timeRange: z.enum(['1h', '6h', '24h', '7d', 'all']).default('24h'), + status: z.enum(['running', 'completed', 'crashed']).optional(), + initiativeId: z.string().optional(), + mode: z.enum(['execute', 'discuss', 'plan', 'detail', 'refine', 'chat', 'errand']).optional(), + })) + .query(async ({ ctx, input }): Promise => { + const agentManager = requireAgentManager(ctx); + const allAgents = await agentManager.list(); + + // Compute cutoff + const cutoffMap: Record = { + '1h': Date.now() - 3_600_000, + '6h': Date.now() - 21_600_000, + '24h': Date.now() - 86_400_000, + '7d': Date.now() - 604_800_000, + }; + const cutoff = input.timeRange !== 'all' ? cutoffMap[input.timeRange] : null; + + // Filter agents + let filteredAgents = allAgents; + if (cutoff !== null) { + filteredAgents = filteredAgents.filter(a => a.createdAt.getTime() >= cutoff!); + } + if (input.status !== undefined) { + const dbStatus = input.status === 'completed' ? 'stopped' : input.status; + filteredAgents = filteredAgents.filter(a => a.status === dbStatus); + } + if (input.initiativeId !== undefined) { + filteredAgents = filteredAgents.filter(a => a.initiativeId === input.initiativeId); + } + if (input.mode !== undefined) { + filteredAgents = filteredAgents.filter(a => a.mode === input.mode); + } + + const matchingIds = filteredAgents.map(a => a.id); + + // Batch fetch in parallel + const logChunkRepo = requireLogChunkRepository(ctx); + const conversationRepo = requireConversationRepository(ctx); + const initiativeRepo = requireInitiativeRepository(ctx); + const taskRepo = requireTaskRepository(ctx); + + // Collect unique taskIds and initiativeIds for batch lookup + const uniqueTaskIds = [...new Set(filteredAgents.map(a => a.taskId).filter(Boolean) as string[])]; + const uniqueInitiativeIds = [...new Set(filteredAgents.map(a => a.initiativeId).filter(Boolean) as string[])]; + + const [chunks, messageCounts, taskResults, initiativeResults] = await Promise.all([ + logChunkRepo.findByAgentIds(matchingIds), + conversationRepo.countByFromAgentIds(matchingIds), + Promise.all(uniqueTaskIds.map(id => taskRepo.findById(id))), + Promise.all(uniqueInitiativeIds.map(id => initiativeRepo.findById(id))), + ]); + + // Build lookup maps + const taskMap = new Map(taskResults.filter(Boolean).map(t => [t!.id, t!.name])); + const initiativeMap = new Map(initiativeResults.filter(Boolean).map(i => [i!.id, i!.name])); + const messagesMap = new Map(messageCounts.map(m => [m.agentId, m.count])); + + // Group chunks by agentId + const chunksByAgent = new Map(); + for (const chunk of chunks) { + const existing = chunksByAgent.get(chunk.agentId); + if (existing) { + existing.push(chunk); + } else { + chunksByAgent.set(chunk.agentId, [chunk]); + } + } + + // Build result rows + return filteredAgents.map(agent => { + const agentChunks = chunksByAgent.get(agent.id) ?? []; + let questionsCount = 0; + let subagentsCount = 0; + let compactionsCount = 0; + + for (const chunk of agentChunks) { + try { + const parsed = JSON.parse(chunk.content); + if (parsed.type === 'tool_use' && parsed.name === 'AskUserQuestion') { + questionsCount += parsed.input?.questions?.length ?? 0; + } else if (parsed.type === 'tool_use' && parsed.name === 'Agent') { + subagentsCount++; + } else if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.source === 'compact') { + compactionsCount++; + } + } catch { /* skip malformed */ } + } + + return { + id: agent.id, + name: agent.name, + mode: agent.mode, + status: agent.status, + initiativeId: agent.initiativeId, + initiativeName: agent.initiativeId ? (initiativeMap.get(agent.initiativeId) ?? null) : null, + taskId: agent.taskId, + taskName: agent.taskId ? (taskMap.get(agent.taskId) ?? null) : null, + createdAt: agent.createdAt.toISOString(), + questionsCount, + messagesCount: messagesMap.get(agent.id) ?? 0, + subagentsCount, + compactionsCount, + }; + }); + }), + + getCompactionEvents: publicProcedure + .input(z.object({ agentId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const logChunkRepo = requireLogChunkRepository(ctx); + const chunks = await logChunkRepo.findByAgentId(input.agentId); + const results: { timestamp: string; sessionNumber: number }[] = []; + for (const chunk of chunks) { + try { + const parsed = JSON.parse(chunk.content); + if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.source === 'compact') { + results.push({ timestamp: chunk.createdAt.toISOString(), sessionNumber: chunk.sessionNumber }); + } + } catch { /* skip malformed */ } + if (results.length >= 200) break; + } + return results; + }), + + getSubagentSpawns: publicProcedure + .input(z.object({ agentId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const logChunkRepo = requireLogChunkRepository(ctx); + const chunks = await logChunkRepo.findByAgentId(input.agentId); + const results: { timestamp: string; description: string; promptPreview: string; fullPrompt: string }[] = []; + for (const chunk of chunks) { + try { + const parsed = JSON.parse(chunk.content); + if (parsed.type === 'tool_use' && parsed.name === 'Agent') { + const fullPrompt: string = parsed.input?.prompt ?? ''; + const description: string = parsed.input?.description ?? ''; + results.push({ + timestamp: chunk.createdAt.toISOString(), + description, + promptPreview: fullPrompt.slice(0, 200), + fullPrompt, + }); + } + } catch { /* skip malformed */ } + if (results.length >= 200) break; + } + return results; + }), + + getQuestionsAsked: publicProcedure + .input(z.object({ agentId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const logChunkRepo = requireLogChunkRepository(ctx); + const chunks = await logChunkRepo.findByAgentId(input.agentId); + type QuestionItem = { question: string; header: string; options: { label: string; description: string }[] }; + const results: { timestamp: string; questions: QuestionItem[] }[] = []; + for (const chunk of chunks) { + try { + const parsed = JSON.parse(chunk.content); + if (parsed.type === 'tool_use' && parsed.name === 'AskUserQuestion') { + const questions: QuestionItem[] = parsed.input?.questions ?? []; + results.push({ timestamp: chunk.createdAt.toISOString(), questions }); + } + } catch { /* skip malformed */ } + if (results.length >= 200) break; + } + return results; + }), }; } diff --git a/apps/server/trpc/routers/conversation.ts b/apps/server/trpc/routers/conversation.ts index c5486fd..96f4907 100644 --- a/apps/server/trpc/routers/conversation.ts +++ b/apps/server/trpc/routers/conversation.ts @@ -302,5 +302,31 @@ export function conversationProcedures(publicProcedure: ProcedureBuilder) { cleanup(); } }), + + getByFromAgent: publicProcedure + .input(z.object({ agentId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const repo = requireConversationRepository(ctx); + const agentManager = requireAgentManager(ctx); + + const convs = await repo.findByFromAgentId(input.agentId); + + // Build toAgent name map without N+1 + const toAgentIds = [...new Set(convs.map(c => c.toAgentId))]; + const allAgents = toAgentIds.length > 0 ? await agentManager.list() : []; + const agentNameMap = new Map(allAgents.map(a => [a.id, a.name])); + + return convs.map(c => ({ + id: c.id, + timestamp: c.createdAt.toISOString(), + toAgentName: agentNameMap.get(c.toAgentId) ?? c.toAgentId, + toAgentId: c.toAgentId, + question: c.question, + answer: c.answer ?? null, + status: c.status as 'pending' | 'answered', + taskId: c.taskId ?? null, + phaseId: c.phaseId ?? null, + })); + }), }; } diff --git a/docs/database.md b/docs/database.md index 2a6e994..3d7ccd5 100644 --- a/docs/database.md +++ b/docs/database.md @@ -245,8 +245,8 @@ Index: `(phaseId)`. | ProjectRepository | + junction ops: setInitiativeProjects (diff-based), findProjectsByInitiativeId | | AccountRepository | + findNextAvailable (round-robin), markExhausted, clearExpiredExhaustion | | ProposalRepository | + findByAgentIdAndStatus, updateManyByAgentId, countByAgentIdAndStatus | -| LogChunkRepository | insertChunk, findByAgentId, deleteByAgentId, getSessionCount | -| ConversationRepository | create, findById, findPendingForAgent, answer | +| LogChunkRepository | insertChunk, findByAgentId, findByAgentIds (batch), deleteByAgentId, getSessionCount | +| ConversationRepository | create, findById, findPendingForAgent, answer, countByFromAgentIds (batch), findByFromAgentId | | ChatSessionRepository | createSession, findActiveSession, findActiveSessionByAgentId, updateSession, createMessage, findMessagesBySessionId | | ReviewCommentRepository | create, findByPhaseId, resolve, unresolve, delete | | ErrandRepository | create, findById, findAll (filter by projectId/status), update, delete | diff --git a/docs/server-api.md b/docs/server-api.md index b064576..f74dbb2 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -69,6 +69,10 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | getActiveRefineAgent | query | Active refine agent for initiative | | getActiveConflictAgent | query | Active conflict resolution agent for initiative (name starts with `conflict-`) | | listWaitingAgents | query | Agents waiting for input | +| listForRadar | query | Radar page: per-agent metrics (questionsCount, messagesCount, subagentsCount, compactionsCount) with time/status/mode/initiative filters | +| getCompactionEvents | query | Compaction events for one agent: `{agentId}` → `{timestamp, sessionNumber}[]` (cap 200) | +| getSubagentSpawns | query | Subagent spawn events for one agent: `{agentId}` → `{timestamp, description, promptPreview, fullPrompt}[]` (cap 200) | +| getQuestionsAsked | query | AskUserQuestion tool calls for one agent: `{agentId}` → `{timestamp, questions[]}[]` (cap 200) | | onAgentOutput | subscription | Live raw JSONL output stream via EventBus | ### Tasks @@ -254,6 +258,7 @@ Inter-agent communication for parallel agents. | `getPendingConversations` | query | Poll for incoming questions: `{agentId}` → Conversation[] | | `getConversation` | query | Get conversation by ID: `{id}` → Conversation | | `answerConversation` | mutation | Answer a conversation: `{id, answer}` → Conversation | +| `getByFromAgent` | query | Radar drilldown: all conversations sent by agent: `{agentId}` → `{id, timestamp, toAgentName, toAgentId, question, answer, status, taskId, phaseId}[]` (cap 200) | Target resolution: `toAgentId` → direct; `taskId` → find running agent by task; `phaseId` → find running agent by any task in phase. From cb4519439dcbe902f1192039aa7ec76d9f53d807 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 20:06:32 +0100 Subject: [PATCH 2/6] 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 3/6] 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() + }) +}) From b860bc100df53a3ec85d6b71904b63c0c9f56032 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 20:10:39 +0100 Subject: [PATCH 4/6] feat: Add Radar page with nav item, filters, table, and tests - Adds "Radar" nav item to AppLayout between Agents and Inbox - Creates /radar route with validateSearch for timeRange/status/initiativeId/mode filters - Summary stat cards for questions, messages, subagents, compactions aggregates - Agent activity table with client-side sorting on all 10 columns (default: started desc) - Real-time SSE updates via useLiveUpdates invalidating agent namespace - Loading skeleton (5 rows) and empty state messaging - Non-zero metric cells show cursor-pointer for future drilldown dialogs - 12-test suite covering rendering, sorting, filtering, nav, and states Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/layouts/AppLayout.tsx | 1 + apps/web/src/routes/__tests__/radar.test.tsx | 299 +++++++++++++++++ apps/web/src/routes/radar.tsx | 327 +++++++++++++++++++ 3 files changed, 627 insertions(+) create mode 100644 apps/web/src/routes/__tests__/radar.test.tsx create mode 100644 apps/web/src/routes/radar.tsx diff --git a/apps/web/src/layouts/AppLayout.tsx b/apps/web/src/layouts/AppLayout.tsx index 4a2f9b3..c65167b 100644 --- a/apps/web/src/layouts/AppLayout.tsx +++ b/apps/web/src/layouts/AppLayout.tsx @@ -10,6 +10,7 @@ const navItems = [ { label: 'HQ', to: '/hq', badgeKey: null }, { label: 'Initiatives', to: '/initiatives', badgeKey: null }, { label: 'Agents', to: '/agents', badgeKey: 'running' as const }, + { label: 'Radar', to: '/radar', badgeKey: null }, { label: 'Inbox', to: '/inbox', badgeKey: 'questions' as const }, { label: 'Settings', to: '/settings', badgeKey: null }, ] as const diff --git a/apps/web/src/routes/__tests__/radar.test.tsx b/apps/web/src/routes/__tests__/radar.test.tsx new file mode 100644 index 0000000..5472ff7 --- /dev/null +++ b/apps/web/src/routes/__tests__/radar.test.tsx @@ -0,0 +1,299 @@ +// @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' + +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('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/src/routes/radar.tsx b/apps/web/src/routes/radar.tsx new file mode 100644 index 0000000..8547b6c --- /dev/null +++ b/apps/web/src/routes/radar.tsx @@ -0,0 +1,327 @@ +import { useState, useMemo } from 'react' +import { createFileRoute, useNavigate, useSearch, Link } from '@tanstack/react-router' +import { trpc } from '@/lib/trpc' +import { useLiveUpdates } from '@/hooks' +import type { LiveUpdateRule } from '@/hooks' +import { Card, CardContent } from '@/components/ui/card' + +type TimeRange = '1h' | '6h' | '24h' | '7d' | 'all' +type StatusFilter = 'all' | 'running' | 'completed' | 'crashed' +type ModeFilter = 'all' | 'execute' | 'discuss' | 'plan' | 'detail' | 'refine' | 'chat' | 'errand' +type SortColumn = + | 'name' + | 'mode' + | 'status' + | 'initiative' + | 'task' + | 'started' + | 'questions' + | 'messages' + | 'subagents' + | 'compactions' + +const VALID_TIME_RANGES: TimeRange[] = ['1h', '6h', '24h', '7d', 'all'] +const VALID_STATUSES: StatusFilter[] = ['all', 'running', 'completed', 'crashed'] +const VALID_MODES: ModeFilter[] = [ + 'all', + 'execute', + 'discuss', + 'plan', + 'detail', + 'refine', + 'chat', + 'errand', +] + +export const Route = createFileRoute('/radar')({ + component: RadarPage, + validateSearch: (search: Record) => ({ + timeRange: VALID_TIME_RANGES.includes(search.timeRange as TimeRange) + ? (search.timeRange as TimeRange) + : '24h', + status: VALID_STATUSES.includes(search.status as StatusFilter) + ? (search.status as StatusFilter) + : 'all', + initiativeId: typeof search.initiativeId === 'string' ? search.initiativeId : undefined, + mode: VALID_MODES.includes(search.mode as ModeFilter) ? (search.mode as ModeFilter) : 'all', + }), +}) + +const RADAR_LIVE_UPDATE_RULES: LiveUpdateRule[] = [ + { prefix: 'agent:waiting', invalidate: ['agent'] }, + { prefix: 'conversation:created', invalidate: ['agent'] }, + { prefix: 'agent:stopped', invalidate: ['agent'] }, + { prefix: 'agent:crashed', invalidate: ['agent'] }, +] + +export function RadarPage() { + const { timeRange, status, initiativeId, mode } = useSearch({ from: '/radar' }) as { + timeRange: TimeRange + status: StatusFilter + initiativeId: string | undefined + mode: ModeFilter + } + const navigate = useNavigate() + + useLiveUpdates(RADAR_LIVE_UPDATE_RULES) + + const { data: agents = [], isLoading } = trpc.agent.listForRadar.useQuery({ + timeRange, + status: status === 'all' ? undefined : status, + initiativeId: initiativeId ?? undefined, + mode: mode === 'all' ? undefined : mode, + }) + + const { data: initiatives = [] } = trpc.listInitiatives.useQuery() + + const [sortState, setSortState] = useState<{ column: SortColumn; direction: 'asc' | 'desc' }>({ + column: 'started', + direction: 'desc', + }) + + function handleSort(column: SortColumn) { + setSortState((prev) => + prev.column === column + ? { column, direction: prev.direction === 'asc' ? 'desc' : 'asc' } + : { column, direction: 'asc' }, + ) + } + + const sortedAgents = useMemo(() => { + return [...agents].sort((a, b) => { + let cmp = 0 + switch (sortState.column) { + case 'name': + cmp = a.name.localeCompare(b.name) + break + case 'mode': + cmp = a.mode.localeCompare(b.mode) + break + case 'status': + cmp = a.status.localeCompare(b.status) + break + case 'initiative': + cmp = (a.initiativeName ?? '').localeCompare(b.initiativeName ?? '') + break + case 'task': + cmp = (a.taskName ?? '').localeCompare(b.taskName ?? '') + break + case 'started': + cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + break + case 'questions': + cmp = a.questionsCount - b.questionsCount + break + case 'messages': + cmp = a.messagesCount - b.messagesCount + break + case 'subagents': + cmp = a.subagentsCount - b.subagentsCount + break + case 'compactions': + cmp = a.compactionsCount - b.compactionsCount + break + } + return sortState.direction === 'asc' ? cmp : -cmp + }) + }, [agents, sortState]) + + const totalQuestions = agents.reduce((sum, a) => sum + a.questionsCount, 0) + const totalMessages = agents.reduce((sum, a) => sum + a.messagesCount, 0) + const totalSubagents = agents.reduce((sum, a) => sum + a.subagentsCount, 0) + const totalCompactions = agents.reduce((sum, a) => sum + a.compactionsCount, 0) + + function sortIndicator(column: SortColumn) { + if (sortState.column !== column) return null + return sortState.direction === 'asc' ? ' ▲' : ' ▼' + } + + function SortableTh({ + column, + label, + className, + }: { + column: SortColumn + label: string + className?: string + }) { + return ( + handleSort(column)} + > + {label} + {sortIndicator(column)} + + ) + } + + function MetricCell({ value }: { value: number }) { + if (value > 0) { + return ( + { + // TODO: open drilldown dialog — wired in Phase 4 (Dialog Integration) + }} + > + {value} + + ) + } + return 0 + } + + return ( +
+

Radar

+ + {/* Summary stat cards */} +
+ + +

{totalQuestions}

+

Total Questions Asked

+
+
+ + +

{totalMessages}

+

Total Inter-Agent Messages

+
+
+ + +

{totalSubagents}

+

Total Subagent Spawns

+
+
+ + +

{totalCompactions}

+

Total Compaction Events

+
+
+
+ + {/* Filter bar */} +
+ + + + + + + +
+ + {/* Empty state */} + {!isLoading && agents.length === 0 && ( +

No agent activity in this time period

+ )} + + {/* Agent activity table */} + {(isLoading || agents.length > 0) && ( + + + + + + + + + + + + + + + + + {isLoading + ? Array.from({ length: 5 }).map((_, i) => ( + + + + )) + : sortedAgents.map((agent) => ( + + + + + + + + + + + + + ))} + +
+
+
+ + {agent.name} + + {agent.mode}{agent.status}{agent.initiativeName ?? '—'}{agent.taskName ?? '—'} + {new Date(agent.createdAt).toLocaleString()} +
+ )} +
+ ) +} From 92a95ffa023eb44ff149cdf2d7deff6a57e93929 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 20:14:06 +0100 Subject: [PATCH 5/6] feat: wire drilldown dialogs into RadarPage with isAgentRunning prop - Add isAgentRunning? to DrilldownDialogProps interface - Import and render all four dialog components in RadarPage - Replace MetricCell helper with per-type td elements using data-testid attributes for reliable test targeting - Add drilldown state (type/agentId/agentName) and isAgentRunning derivation - Wire non-zero metric cell onClick handlers to open the correct dialog - Zero cells retain no onClick handler - Extend radar.test.tsx with 8 new dialog integration test cases covering open/close behavior and isAgentRunning prop passing Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/components/radar/types.ts | 1 + apps/web/src/routes/__tests__/radar.test.tsx | 152 +++++++++++++++++++ apps/web/src/routes/radar.tsx | 99 +++++++++--- 3 files changed, 233 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/radar/types.ts b/apps/web/src/components/radar/types.ts index 67ea1ac..e7dd307 100644 --- a/apps/web/src/components/radar/types.ts +++ b/apps/web/src/components/radar/types.ts @@ -3,4 +3,5 @@ export interface DrilldownDialogProps { onOpenChange: (open: boolean) => void agentId: string agentName: string + isAgentRunning?: boolean } diff --git a/apps/web/src/routes/__tests__/radar.test.tsx b/apps/web/src/routes/__tests__/radar.test.tsx index 5472ff7..01f110e 100644 --- a/apps/web/src/routes/__tests__/radar.test.tsx +++ b/apps/web/src/routes/__tests__/radar.test.tsx @@ -3,6 +3,39 @@ 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 @@ -277,6 +310,125 @@ describe('RadarPage', () => { 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', () => { diff --git a/apps/web/src/routes/radar.tsx b/apps/web/src/routes/radar.tsx index 8547b6c..7e68b3b 100644 --- a/apps/web/src/routes/radar.tsx +++ b/apps/web/src/routes/radar.tsx @@ -4,6 +4,10 @@ import { trpc } from '@/lib/trpc' import { useLiveUpdates } from '@/hooks' import type { LiveUpdateRule } from '@/hooks' import { Card, CardContent } from '@/components/ui/card' +import { CompactionEventsDialog } from '@/components/radar/CompactionEventsDialog' +import { SubagentSpawnsDialog } from '@/components/radar/SubagentSpawnsDialog' +import { QuestionsAskedDialog } from '@/components/radar/QuestionsAskedDialog' +import { InterAgentMessagesDialog } from '@/components/radar/InterAgentMessagesDialog' type TimeRange = '1h' | '6h' | '24h' | '7d' | 'all' type StatusFilter = 'all' | 'running' | 'completed' | 'crashed' @@ -74,6 +78,14 @@ export function RadarPage() { const { data: initiatives = [] } = trpc.listInitiatives.useQuery() + type DrilldownType = 'questions' | 'messages' | 'subagents' | 'compactions' + + const [drilldown, setDrilldown] = useState<{ + type: DrilldownType + agentId: string + agentName: string + } | null>(null) + const [sortState, setSortState] = useState<{ column: SortColumn; direction: 'asc' | 'desc' }>({ column: 'started', direction: 'desc', @@ -156,21 +168,9 @@ export function RadarPage() { ) } - function MetricCell({ value }: { value: number }) { - if (value > 0) { - return ( - { - // TODO: open drilldown dialog — wired in Phase 4 (Dialog Integration) - }} - > - {value} - - ) - } - return 0 - } + const isAgentRunning = drilldown + ? agents.find((a) => a.id === drilldown.agentId)?.status === 'running' + : false return (
@@ -313,15 +313,76 @@ export function RadarPage() { {new Date(agent.createdAt).toLocaleString()} - - - - + 0 ? 'cursor-pointer hover:bg-muted/50 text-right px-3 py-2' : 'text-muted-foreground text-right px-3 py-2'} + onClick={agent.questionsCount > 0 + ? () => setDrilldown({ type: 'questions', agentId: agent.id, agentName: agent.name }) + : undefined} + > + {agent.questionsCount} + + 0 ? 'cursor-pointer hover:bg-muted/50 text-right px-3 py-2' : 'text-muted-foreground text-right px-3 py-2'} + onClick={agent.messagesCount > 0 + ? () => setDrilldown({ type: 'messages', agentId: agent.id, agentName: agent.name }) + : undefined} + > + {agent.messagesCount} + + 0 ? 'cursor-pointer hover:bg-muted/50 text-right px-3 py-2' : 'text-muted-foreground text-right px-3 py-2'} + onClick={agent.subagentsCount > 0 + ? () => setDrilldown({ type: 'subagents', agentId: agent.id, agentName: agent.name }) + : undefined} + > + {agent.subagentsCount} + + 0 ? 'cursor-pointer hover:bg-muted/50 text-right px-3 py-2' : 'text-muted-foreground text-right px-3 py-2'} + onClick={agent.compactionsCount > 0 + ? () => setDrilldown({ type: 'compactions', agentId: agent.id, agentName: agent.name }) + : undefined} + > + {agent.compactionsCount} + ))} )} + + { if (!open) setDrilldown(null) }} + agentId={drilldown?.agentId ?? ''} + agentName={drilldown?.agentName ?? ''} + isAgentRunning={isAgentRunning} + /> + { if (!open) setDrilldown(null) }} + agentId={drilldown?.agentId ?? ''} + agentName={drilldown?.agentName ?? ''} + isAgentRunning={isAgentRunning} + /> + { if (!open) setDrilldown(null) }} + agentId={drilldown?.agentId ?? ''} + agentName={drilldown?.agentName ?? ''} + isAgentRunning={isAgentRunning} + /> + { if (!open) setDrilldown(null) }} + agentId={drilldown?.agentId ?? ''} + agentName={drilldown?.agentName ?? ''} + isAgentRunning={isAgentRunning} + />
) } From 7c48c70d47da61e755288c09c2c810b0b03163c5 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 20:18:17 +0100 Subject: [PATCH 6/6] feat: Add SSE-driven real-time refresh and last-refreshed timestamp to drilldown dialogs Add isAgentRunning prop to all four radar drilldown dialog components. When true, subscribe to relevant SSE events and trigger refetch on matching events for the current agentId. Show a "Last refreshed: just now" timestamp that ticks to "Xs ago" in the dialog footer. Reset on close. - CompactionEventsDialog, SubagentSpawnsDialog, QuestionsAskedDialog: subscribe to agent:waiting events - InterAgentMessagesDialog: subscribe to conversation:created and conversation:answered events (matches on fromAgentId) - Update DrilldownDialogProps type with isAgentRunning?: boolean - Add test coverage for all new behavior across all four dialogs Co-Authored-By: Claude Sonnet 4.6 --- .../radar/CompactionEventsDialog.tsx | 48 ++++++++++- .../radar/InterAgentMessagesDialog.tsx | 46 ++++++++++- .../components/radar/QuestionsAskedDialog.tsx | 46 ++++++++++- .../components/radar/SubagentSpawnsDialog.tsx | 46 ++++++++++- .../__tests__/CompactionEventsDialog.test.tsx | 81 ++++++++++++++++-- .../InterAgentMessagesDialog.test.tsx | 82 +++++++++++++++++-- .../__tests__/QuestionsAskedDialog.test.tsx | 82 +++++++++++++++++-- .../__tests__/SubagentSpawnsDialog.test.tsx | 81 ++++++++++++++++-- apps/web/src/components/radar/types.ts | 1 + 9 files changed, 485 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/radar/CompactionEventsDialog.tsx b/apps/web/src/components/radar/CompactionEventsDialog.tsx index e188376..c4ed929 100644 --- a/apps/web/src/components/radar/CompactionEventsDialog.tsx +++ b/apps/web/src/components/radar/CompactionEventsDialog.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from 'react' import { trpc } from '@/lib/trpc' import { Dialog, @@ -7,8 +8,11 @@ import { DialogTitle, } from '@/components/ui/dialog' import { Skeleton } from '@/components/ui/skeleton' +import { useSubscriptionWithErrorHandling } from '@/hooks' import type { DrilldownDialogProps } from './types' +const RELEVANT_EVENTS = ['agent:waiting'] + function formatTimestamp(ts: string): string { const date = new Date(ts) const now = new Date() @@ -47,12 +51,48 @@ export function CompactionEventsDialog({ onOpenChange, agentId, agentName, + isAgentRunning, }: DrilldownDialogProps) { - const { data, isLoading } = trpc.agent.getCompactionEvents.useQuery( + const { data, isLoading, refetch } = trpc.agent.getCompactionEvents.useQuery( { agentId }, { enabled: open } ) + const [lastRefreshedAt, setLastRefreshedAt] = useState(null) + const [secondsAgo, setSecondsAgo] = useState(0) + + useSubscriptionWithErrorHandling( + () => trpc.onEvent.useSubscription(undefined), + { + enabled: open && !!isAgentRunning, + onData: (event: any) => { + const eventType: string = event?.data?.type ?? event?.type ?? '' + const eventAgentId: string = event?.data?.agentId ?? event?.agentId ?? '' + if (RELEVANT_EVENTS.some(e => eventType.startsWith(e)) && eventAgentId === agentId) { + void refetch().then(() => { + setLastRefreshedAt(new Date()) + setSecondsAgo(0) + }) + } + }, + } + ) + + useEffect(() => { + if (!open || !isAgentRunning || !lastRefreshedAt) return + const interval = setInterval(() => { + setSecondsAgo(Math.floor((Date.now() - lastRefreshedAt.getTime()) / 1000)) + }, 1000) + return () => clearInterval(interval) + }, [open, isAgentRunning, lastRefreshedAt]) + + useEffect(() => { + if (!open) { + setLastRefreshedAt(null) + setSecondsAgo(0) + } + }, [open]) + return ( @@ -105,6 +145,12 @@ export function CompactionEventsDialog({ )} + + {isAgentRunning && lastRefreshedAt && ( +

+ Last refreshed: {secondsAgo === 0 ? 'just now' : `${secondsAgo}s ago`} +

+ )}
) diff --git a/apps/web/src/components/radar/InterAgentMessagesDialog.tsx b/apps/web/src/components/radar/InterAgentMessagesDialog.tsx index 57461f8..a302b72 100644 --- a/apps/web/src/components/radar/InterAgentMessagesDialog.tsx +++ b/apps/web/src/components/radar/InterAgentMessagesDialog.tsx @@ -9,8 +9,11 @@ import { } from '@/components/ui/dialog' import { Skeleton } from '@/components/ui/skeleton' import { Badge } from '@/components/ui/badge' +import { useSubscriptionWithErrorHandling } from '@/hooks' import type { DrilldownDialogProps } from './types' +const RELEVANT_EVENTS = ['conversation:created', 'conversation:answered'] + function formatTimestamp(ts: string): string { const date = new Date(ts) const now = new Date() @@ -49,16 +52,49 @@ export function InterAgentMessagesDialog({ onOpenChange, agentId, agentName, + isAgentRunning, }: DrilldownDialogProps) { const [expandedIndex, setExpandedIndex] = useState(null) - const { data, isLoading } = trpc.conversation.getByFromAgent.useQuery( + const { data, isLoading, refetch } = trpc.conversation.getByFromAgent.useQuery( { agentId }, { enabled: open } ) + const [lastRefreshedAt, setLastRefreshedAt] = useState(null) + const [secondsAgo, setSecondsAgo] = useState(0) + + useSubscriptionWithErrorHandling( + () => trpc.onEvent.useSubscription(undefined), + { + enabled: open && !!isAgentRunning, + onData: (event: any) => { + const eventType: string = event?.data?.type ?? event?.type ?? '' + const eventAgentId: string = event?.data?.fromAgentId ?? event?.data?.agentId ?? event?.agentId ?? '' + if (RELEVANT_EVENTS.some(e => eventType.startsWith(e)) && eventAgentId === agentId) { + void refetch().then(() => { + setLastRefreshedAt(new Date()) + setSecondsAgo(0) + }) + } + }, + } + ) + useEffect(() => { - if (!open) setExpandedIndex(null) + if (!open || !isAgentRunning || !lastRefreshedAt) return + const interval = setInterval(() => { + setSecondsAgo(Math.floor((Date.now() - lastRefreshedAt.getTime()) / 1000)) + }, 1000) + return () => clearInterval(interval) + }, [open, isAgentRunning, lastRefreshedAt]) + + useEffect(() => { + if (!open) { + setExpandedIndex(null) + setLastRefreshedAt(null) + setSecondsAgo(0) + } }, [open]) return ( @@ -161,6 +197,12 @@ export function InterAgentMessagesDialog({ )} + + {isAgentRunning && lastRefreshedAt && ( +

+ Last refreshed: {secondsAgo === 0 ? 'just now' : `${secondsAgo}s ago`} +

+ )} ) diff --git a/apps/web/src/components/radar/QuestionsAskedDialog.tsx b/apps/web/src/components/radar/QuestionsAskedDialog.tsx index 49da631..97773ac 100644 --- a/apps/web/src/components/radar/QuestionsAskedDialog.tsx +++ b/apps/web/src/components/radar/QuestionsAskedDialog.tsx @@ -8,8 +8,11 @@ import { DialogTitle, } from '@/components/ui/dialog' import { Skeleton } from '@/components/ui/skeleton' +import { useSubscriptionWithErrorHandling } from '@/hooks' import type { DrilldownDialogProps } from './types' +const RELEVANT_EVENTS = ['agent:waiting'] + function formatTimestamp(ts: string): string { const date = new Date(ts) const now = new Date() @@ -52,16 +55,49 @@ export function QuestionsAskedDialog({ onOpenChange, agentId, agentName, + isAgentRunning, }: DrilldownDialogProps) { const [expandedIndex, setExpandedIndex] = useState(null) - const { data, isLoading } = trpc.agent.getQuestionsAsked.useQuery( + const { data, isLoading, refetch } = trpc.agent.getQuestionsAsked.useQuery( { agentId }, { enabled: open } ) + const [lastRefreshedAt, setLastRefreshedAt] = useState(null) + const [secondsAgo, setSecondsAgo] = useState(0) + + useSubscriptionWithErrorHandling( + () => trpc.onEvent.useSubscription(undefined), + { + enabled: open && !!isAgentRunning, + onData: (event: any) => { + const eventType: string = event?.data?.type ?? event?.type ?? '' + const eventAgentId: string = event?.data?.agentId ?? event?.agentId ?? '' + if (RELEVANT_EVENTS.some(e => eventType.startsWith(e)) && eventAgentId === agentId) { + void refetch().then(() => { + setLastRefreshedAt(new Date()) + setSecondsAgo(0) + }) + } + }, + } + ) + useEffect(() => { - if (!open) setExpandedIndex(null) + if (!open || !isAgentRunning || !lastRefreshedAt) return + const interval = setInterval(() => { + setSecondsAgo(Math.floor((Date.now() - lastRefreshedAt.getTime()) / 1000)) + }, 1000) + return () => clearInterval(interval) + }, [open, isAgentRunning, lastRefreshedAt]) + + useEffect(() => { + if (!open) { + setExpandedIndex(null) + setLastRefreshedAt(null) + setSecondsAgo(0) + } }, [open]) return ( @@ -153,6 +189,12 @@ export function QuestionsAskedDialog({ )} + + {isAgentRunning && lastRefreshedAt && ( +

+ Last refreshed: {secondsAgo === 0 ? 'just now' : `${secondsAgo}s ago`} +

+ )} ) diff --git a/apps/web/src/components/radar/SubagentSpawnsDialog.tsx b/apps/web/src/components/radar/SubagentSpawnsDialog.tsx index 2fe27e9..ca56aa3 100644 --- a/apps/web/src/components/radar/SubagentSpawnsDialog.tsx +++ b/apps/web/src/components/radar/SubagentSpawnsDialog.tsx @@ -8,8 +8,11 @@ import { DialogTitle, } from '@/components/ui/dialog' import { Skeleton } from '@/components/ui/skeleton' +import { useSubscriptionWithErrorHandling } from '@/hooks' import type { DrilldownDialogProps } from './types' +const RELEVANT_EVENTS = ['agent:waiting'] + function formatTimestamp(ts: string): string { const date = new Date(ts) const now = new Date() @@ -48,16 +51,49 @@ export function SubagentSpawnsDialog({ onOpenChange, agentId, agentName, + isAgentRunning, }: DrilldownDialogProps) { const [expandedIndex, setExpandedIndex] = useState(null) - const { data, isLoading } = trpc.agent.getSubagentSpawns.useQuery( + const { data, isLoading, refetch } = trpc.agent.getSubagentSpawns.useQuery( { agentId }, { enabled: open } ) + const [lastRefreshedAt, setLastRefreshedAt] = useState(null) + const [secondsAgo, setSecondsAgo] = useState(0) + + useSubscriptionWithErrorHandling( + () => trpc.onEvent.useSubscription(undefined), + { + enabled: open && !!isAgentRunning, + onData: (event: any) => { + const eventType: string = event?.data?.type ?? event?.type ?? '' + const eventAgentId: string = event?.data?.agentId ?? event?.agentId ?? '' + if (RELEVANT_EVENTS.some(e => eventType.startsWith(e)) && eventAgentId === agentId) { + void refetch().then(() => { + setLastRefreshedAt(new Date()) + setSecondsAgo(0) + }) + } + }, + } + ) + useEffect(() => { - if (!open) setExpandedIndex(null) + if (!open || !isAgentRunning || !lastRefreshedAt) return + const interval = setInterval(() => { + setSecondsAgo(Math.floor((Date.now() - lastRefreshedAt.getTime()) / 1000)) + }, 1000) + return () => clearInterval(interval) + }, [open, isAgentRunning, lastRefreshedAt]) + + useEffect(() => { + if (!open) { + setExpandedIndex(null) + setLastRefreshedAt(null) + setSecondsAgo(0) + } }, [open]) return ( @@ -137,6 +173,12 @@ export function SubagentSpawnsDialog({ )} + + {isAgentRunning && lastRefreshedAt && ( +

+ Last refreshed: {secondsAgo === 0 ? 'just now' : `${secondsAgo}s ago`} +

+ )} ) diff --git a/apps/web/src/components/radar/__tests__/CompactionEventsDialog.test.tsx b/apps/web/src/components/radar/__tests__/CompactionEventsDialog.test.tsx index 94a2e8a..8352c1e 100644 --- a/apps/web/src/components/radar/__tests__/CompactionEventsDialog.test.tsx +++ b/apps/web/src/components/radar/__tests__/CompactionEventsDialog.test.tsx @@ -1,11 +1,17 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' -import { render, screen } from '@testing-library/react' +import { render, screen, act, waitFor } from '@testing-library/react' import { vi, describe, it, expect, beforeEach } from 'vitest' -let mockUseQueryReturn: { data: unknown; isLoading: boolean } = { +const mockUseSubscriptionWithErrorHandling = vi.hoisted(() => vi.fn()) +vi.mock('@/hooks', () => ({ + useSubscriptionWithErrorHandling: mockUseSubscriptionWithErrorHandling, +})) + +let mockUseQueryReturn: { data: unknown; isLoading: boolean; refetch?: () => Promise } = { data: undefined, isLoading: false, + refetch: vi.fn().mockResolvedValue({}), } vi.mock('@/lib/trpc', () => ({ @@ -15,6 +21,9 @@ vi.mock('@/lib/trpc', () => ({ useQuery: vi.fn((_args: unknown, _opts: unknown) => mockUseQueryReturn), }, }, + onEvent: { + useSubscription: vi.fn(), + }, }, })) @@ -30,7 +39,20 @@ const defaultProps = { describe('CompactionEventsDialog', () => { beforeEach(() => { vi.clearAllMocks() - mockUseQueryReturn = { data: undefined, isLoading: false } + mockUseQueryReturn = { + data: undefined, + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + mockUseSubscriptionWithErrorHandling.mockReturnValue({ + isConnected: false, + isConnecting: false, + error: null, + reconnectAttempts: 0, + lastEventId: null, + reconnect: vi.fn(), + reset: vi.fn(), + }) }) it('does not render dialog content when open=false', () => { @@ -39,7 +61,7 @@ describe('CompactionEventsDialog', () => { }) it('shows skeleton rows when loading', () => { - mockUseQueryReturn = { data: undefined, isLoading: true } + mockUseQueryReturn = { data: undefined, isLoading: true, refetch: vi.fn().mockResolvedValue({}) } render() const skeletons = document.querySelectorAll('.animate-pulse') expect(skeletons.length).toBeGreaterThanOrEqual(3) @@ -47,7 +69,7 @@ describe('CompactionEventsDialog', () => { }) it('shows "No data found" when data is empty', () => { - mockUseQueryReturn = { data: [], isLoading: false } + mockUseQueryReturn = { data: [], isLoading: false, refetch: vi.fn().mockResolvedValue({}) } render() expect(screen.getByText('No data found')).toBeInTheDocument() }) @@ -56,6 +78,7 @@ describe('CompactionEventsDialog', () => { mockUseQueryReturn = { data: [{ timestamp: '2026-03-06T10:00:00.000Z', sessionNumber: 3 }], isLoading: false, + refetch: vi.fn().mockResolvedValue({}), } render() expect(screen.getByText('3')).toBeInTheDocument() @@ -68,15 +91,61 @@ describe('CompactionEventsDialog', () => { mockUseQueryReturn = { data: Array(200).fill({ timestamp: '2026-03-06T10:00:00.000Z', sessionNumber: 1 }), isLoading: false, + refetch: vi.fn().mockResolvedValue({}), } render() expect(screen.getByText('Showing first 200 instances.')).toBeInTheDocument() }) it('renders dialog title and subtitle', () => { - mockUseQueryReturn = { data: [], isLoading: false } + mockUseQueryReturn = { data: [], isLoading: false, refetch: vi.fn().mockResolvedValue({}) } render() expect(screen.getByText(/Compaction Events — test-agent/)).toBeInTheDocument() expect(screen.getByText(/context-window compaction/)).toBeInTheDocument() }) + + describe('isAgentRunning behavior', () => { + it('shows "Last refreshed: just now" after SSE-triggered refetch when isAgentRunning=true', async () => { + let capturedOnData: ((event: any) => void) | undefined + mockUseSubscriptionWithErrorHandling.mockImplementation((_getter: any, opts: any) => { + capturedOnData = opts.onData + return { isConnected: true, isConnecting: false, error: null, reconnectAttempts: 0, lastEventId: null, reconnect: vi.fn(), reset: vi.fn() } + }) + + const mockRefetch = vi.fn().mockResolvedValue({ data: [{ timestamp: '2026-03-06T10:00:00.000Z', sessionNumber: 1 }] }) + mockUseQueryReturn = { + data: [{ timestamp: '2026-03-06T10:00:00.000Z', sessionNumber: 1 }], + isLoading: false, + refetch: mockRefetch, + } + + render() + + await act(async () => { + capturedOnData?.({ data: { type: 'agent:waiting', agentId: 'agent-1' } }) + }) + + await waitFor(() => { + expect(screen.getByText(/Last refreshed: just now/)).toBeInTheDocument() + }) + }) + + it('does not show "Last refreshed" when isAgentRunning=false', () => { + render() + expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument() + }) + + it('does not show "Last refreshed" when isAgentRunning is not provided', () => { + render() + expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument() + }) + + it('subscription is enabled only when open=true and isAgentRunning=true', () => { + render() + expect(mockUseSubscriptionWithErrorHandling).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ enabled: false }) + ) + }) + }) }) diff --git a/apps/web/src/components/radar/__tests__/InterAgentMessagesDialog.test.tsx b/apps/web/src/components/radar/__tests__/InterAgentMessagesDialog.test.tsx index 017a729..4ffb10a 100644 --- a/apps/web/src/components/radar/__tests__/InterAgentMessagesDialog.test.tsx +++ b/apps/web/src/components/radar/__tests__/InterAgentMessagesDialog.test.tsx @@ -1,11 +1,17 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' import { vi, describe, it, expect, beforeEach } from 'vitest' -let mockUseQueryReturn: { data: unknown; isLoading: boolean } = { +const mockUseSubscriptionWithErrorHandling = vi.hoisted(() => vi.fn()) +vi.mock('@/hooks', () => ({ + useSubscriptionWithErrorHandling: mockUseSubscriptionWithErrorHandling, +})) + +let mockUseQueryReturn: { data: unknown; isLoading: boolean; refetch?: () => Promise } = { data: undefined, isLoading: false, + refetch: vi.fn().mockResolvedValue({}), } vi.mock('@/lib/trpc', () => ({ @@ -15,6 +21,9 @@ vi.mock('@/lib/trpc', () => ({ useQuery: vi.fn((_args: unknown, _opts: unknown) => mockUseQueryReturn), }, }, + onEvent: { + useSubscription: vi.fn(), + }, }, })) @@ -30,7 +39,20 @@ const defaultProps = { describe('InterAgentMessagesDialog', () => { beforeEach(() => { vi.clearAllMocks() - mockUseQueryReturn = { data: undefined, isLoading: false } + mockUseQueryReturn = { + data: undefined, + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + mockUseSubscriptionWithErrorHandling.mockReturnValue({ + isConnected: false, + isConnecting: false, + error: null, + reconnectAttempts: 0, + lastEventId: null, + reconnect: vi.fn(), + reset: vi.fn(), + }) }) it('does not render dialog content when open=false', () => { @@ -39,7 +61,7 @@ describe('InterAgentMessagesDialog', () => { }) it('shows skeleton rows when loading', () => { - mockUseQueryReturn = { data: undefined, isLoading: true } + mockUseQueryReturn = { data: undefined, isLoading: true, refetch: vi.fn().mockResolvedValue({}) } render() const skeletons = document.querySelectorAll('.animate-pulse') expect(skeletons.length).toBeGreaterThanOrEqual(3) @@ -47,7 +69,7 @@ describe('InterAgentMessagesDialog', () => { }) it('shows "No data found" when data is empty', () => { - mockUseQueryReturn = { data: [], isLoading: false } + mockUseQueryReturn = { data: [], isLoading: false, refetch: vi.fn().mockResolvedValue({}) } render() expect(screen.getByText('No data found')).toBeInTheDocument() }) @@ -68,6 +90,7 @@ describe('InterAgentMessagesDialog', () => { }, ], isLoading: false, + refetch: vi.fn().mockResolvedValue({}), } render() expect(screen.getByText('target-agent')).toBeInTheDocument() @@ -91,6 +114,7 @@ describe('InterAgentMessagesDialog', () => { }, ], isLoading: false, + refetch: vi.fn().mockResolvedValue({}), } render() @@ -116,6 +140,7 @@ describe('InterAgentMessagesDialog', () => { }, ], isLoading: false, + refetch: vi.fn().mockResolvedValue({}), } render() @@ -141,6 +166,7 @@ describe('InterAgentMessagesDialog', () => { }, ], isLoading: false, + refetch: vi.fn().mockResolvedValue({}), } render() @@ -165,8 +191,54 @@ describe('InterAgentMessagesDialog', () => { phaseId: null, }), isLoading: false, + refetch: vi.fn().mockResolvedValue({}), } render() expect(screen.getByText('Showing first 200 instances.')).toBeInTheDocument() }) + + describe('isAgentRunning behavior', () => { + it('shows "Last refreshed: just now" after SSE-triggered refetch when isAgentRunning=true', async () => { + let capturedOnData: ((event: any) => void) | undefined + mockUseSubscriptionWithErrorHandling.mockImplementation((_getter: any, opts: any) => { + capturedOnData = opts.onData + return { isConnected: true, isConnecting: false, error: null, reconnectAttempts: 0, lastEventId: null, reconnect: vi.fn(), reset: vi.fn() } + }) + + const mockRefetch = vi.fn().mockResolvedValue({ data: [] }) + mockUseQueryReturn = { + data: [], + isLoading: false, + refetch: mockRefetch, + } + + render() + + await act(async () => { + capturedOnData?.({ data: { type: 'conversation:created', fromAgentId: 'agent-1' } }) + }) + + await waitFor(() => { + expect(screen.getByText(/Last refreshed: just now/)).toBeInTheDocument() + }) + }) + + it('does not show "Last refreshed" when isAgentRunning=false', () => { + render() + expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument() + }) + + it('does not show "Last refreshed" when isAgentRunning is not provided', () => { + render() + expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument() + }) + + it('subscription is enabled only when open=true and isAgentRunning=true', () => { + render() + expect(mockUseSubscriptionWithErrorHandling).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ enabled: false }) + ) + }) + }) }) diff --git a/apps/web/src/components/radar/__tests__/QuestionsAskedDialog.test.tsx b/apps/web/src/components/radar/__tests__/QuestionsAskedDialog.test.tsx index d15e2f6..4a7e89d 100644 --- a/apps/web/src/components/radar/__tests__/QuestionsAskedDialog.test.tsx +++ b/apps/web/src/components/radar/__tests__/QuestionsAskedDialog.test.tsx @@ -1,11 +1,17 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' import { vi, describe, it, expect, beforeEach } from 'vitest' -let mockUseQueryReturn: { data: unknown; isLoading: boolean } = { +const mockUseSubscriptionWithErrorHandling = vi.hoisted(() => vi.fn()) +vi.mock('@/hooks', () => ({ + useSubscriptionWithErrorHandling: mockUseSubscriptionWithErrorHandling, +})) + +let mockUseQueryReturn: { data: unknown; isLoading: boolean; refetch?: () => Promise } = { data: undefined, isLoading: false, + refetch: vi.fn().mockResolvedValue({}), } vi.mock('@/lib/trpc', () => ({ @@ -15,6 +21,9 @@ vi.mock('@/lib/trpc', () => ({ useQuery: vi.fn((_args: unknown, _opts: unknown) => mockUseQueryReturn), }, }, + onEvent: { + useSubscription: vi.fn(), + }, }, })) @@ -30,7 +39,20 @@ const defaultProps = { describe('QuestionsAskedDialog', () => { beforeEach(() => { vi.clearAllMocks() - mockUseQueryReturn = { data: undefined, isLoading: false } + mockUseQueryReturn = { + data: undefined, + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + mockUseSubscriptionWithErrorHandling.mockReturnValue({ + isConnected: false, + isConnecting: false, + error: null, + reconnectAttempts: 0, + lastEventId: null, + reconnect: vi.fn(), + reset: vi.fn(), + }) }) it('does not render dialog content when open=false', () => { @@ -39,7 +61,7 @@ describe('QuestionsAskedDialog', () => { }) it('shows skeleton rows when loading', () => { - mockUseQueryReturn = { data: undefined, isLoading: true } + mockUseQueryReturn = { data: undefined, isLoading: true, refetch: vi.fn().mockResolvedValue({}) } render() const skeletons = document.querySelectorAll('.animate-pulse') expect(skeletons.length).toBeGreaterThanOrEqual(3) @@ -47,7 +69,7 @@ describe('QuestionsAskedDialog', () => { }) it('shows "No data found" when data is empty', () => { - mockUseQueryReturn = { data: [], isLoading: false } + mockUseQueryReturn = { data: [], isLoading: false, refetch: vi.fn().mockResolvedValue({}) } render() expect(screen.getByText('No data found')).toBeInTheDocument() }) @@ -64,6 +86,7 @@ describe('QuestionsAskedDialog', () => { }, ], isLoading: false, + refetch: vi.fn().mockResolvedValue({}), } render() expect(screen.getByText('2 questions')).toBeInTheDocument() @@ -83,6 +106,7 @@ describe('QuestionsAskedDialog', () => { }, ], isLoading: false, + refetch: vi.fn().mockResolvedValue({}), } render() @@ -104,6 +128,7 @@ describe('QuestionsAskedDialog', () => { }, ], isLoading: false, + refetch: vi.fn().mockResolvedValue({}), } render() @@ -123,6 +148,7 @@ describe('QuestionsAskedDialog', () => { ], }), isLoading: false, + refetch: vi.fn().mockResolvedValue({}), } render() expect(screen.getByText('Showing first 200 instances.')).toBeInTheDocument() @@ -139,9 +165,55 @@ describe('QuestionsAskedDialog', () => { }, ], isLoading: false, + refetch: vi.fn().mockResolvedValue({}), } render() expect(screen.getByText('1 question')).toBeInTheDocument() expect(screen.queryByText('1 questions')).toBeNull() }) + + describe('isAgentRunning behavior', () => { + it('shows "Last refreshed: just now" after SSE-triggered refetch when isAgentRunning=true', async () => { + let capturedOnData: ((event: any) => void) | undefined + mockUseSubscriptionWithErrorHandling.mockImplementation((_getter: any, opts: any) => { + capturedOnData = opts.onData + return { isConnected: true, isConnecting: false, error: null, reconnectAttempts: 0, lastEventId: null, reconnect: vi.fn(), reset: vi.fn() } + }) + + const mockRefetch = vi.fn().mockResolvedValue({ data: [] }) + mockUseQueryReturn = { + data: [], + isLoading: false, + refetch: mockRefetch, + } + + render() + + await act(async () => { + capturedOnData?.({ data: { type: 'agent:waiting', agentId: 'agent-1' } }) + }) + + await waitFor(() => { + expect(screen.getByText(/Last refreshed: just now/)).toBeInTheDocument() + }) + }) + + it('does not show "Last refreshed" when isAgentRunning=false', () => { + render() + expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument() + }) + + it('does not show "Last refreshed" when isAgentRunning is not provided', () => { + render() + expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument() + }) + + it('subscription is enabled only when open=true and isAgentRunning=true', () => { + render() + expect(mockUseSubscriptionWithErrorHandling).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ enabled: false }) + ) + }) + }) }) diff --git a/apps/web/src/components/radar/__tests__/SubagentSpawnsDialog.test.tsx b/apps/web/src/components/radar/__tests__/SubagentSpawnsDialog.test.tsx index de2cb7e..916f840 100644 --- a/apps/web/src/components/radar/__tests__/SubagentSpawnsDialog.test.tsx +++ b/apps/web/src/components/radar/__tests__/SubagentSpawnsDialog.test.tsx @@ -1,11 +1,17 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' import { vi, describe, it, expect, beforeEach } from 'vitest' -let mockUseQueryReturn: { data: unknown; isLoading: boolean } = { +const mockUseSubscriptionWithErrorHandling = vi.hoisted(() => vi.fn()) +vi.mock('@/hooks', () => ({ + useSubscriptionWithErrorHandling: mockUseSubscriptionWithErrorHandling, +})) + +let mockUseQueryReturn: { data: unknown; isLoading: boolean; refetch?: () => Promise } = { data: undefined, isLoading: false, + refetch: vi.fn().mockResolvedValue({}), } vi.mock('@/lib/trpc', () => ({ @@ -15,6 +21,9 @@ vi.mock('@/lib/trpc', () => ({ useQuery: vi.fn((_args: unknown, _opts: unknown) => mockUseQueryReturn), }, }, + onEvent: { + useSubscription: vi.fn(), + }, }, })) @@ -30,7 +39,20 @@ const defaultProps = { describe('SubagentSpawnsDialog', () => { beforeEach(() => { vi.clearAllMocks() - mockUseQueryReturn = { data: undefined, isLoading: false } + mockUseQueryReturn = { + data: undefined, + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + mockUseSubscriptionWithErrorHandling.mockReturnValue({ + isConnected: false, + isConnecting: false, + error: null, + reconnectAttempts: 0, + lastEventId: null, + reconnect: vi.fn(), + reset: vi.fn(), + }) }) it('does not render dialog content when open=false', () => { @@ -39,7 +61,7 @@ describe('SubagentSpawnsDialog', () => { }) it('shows skeleton rows when loading', () => { - mockUseQueryReturn = { data: undefined, isLoading: true } + mockUseQueryReturn = { data: undefined, isLoading: true, refetch: vi.fn().mockResolvedValue({}) } render() const skeletons = document.querySelectorAll('.animate-pulse') expect(skeletons.length).toBeGreaterThanOrEqual(3) @@ -47,7 +69,7 @@ describe('SubagentSpawnsDialog', () => { }) it('shows "No data found" when data is empty', () => { - mockUseQueryReturn = { data: [], isLoading: false } + mockUseQueryReturn = { data: [], isLoading: false, refetch: vi.fn().mockResolvedValue({}) } render() expect(screen.getByText('No data found')).toBeInTheDocument() }) @@ -63,6 +85,7 @@ describe('SubagentSpawnsDialog', () => { }, ], isLoading: false, + refetch: vi.fn().mockResolvedValue({}), } render() expect(screen.getByText('my task')).toBeInTheDocument() @@ -81,6 +104,7 @@ describe('SubagentSpawnsDialog', () => { }, ], isLoading: false, + refetch: vi.fn().mockResolvedValue({}), } render() @@ -106,6 +130,7 @@ describe('SubagentSpawnsDialog', () => { }, ], isLoading: false, + refetch: vi.fn().mockResolvedValue({}), } render() expect(screen.getByText('…')).toBeInTheDocument() @@ -120,8 +145,54 @@ describe('SubagentSpawnsDialog', () => { fullPrompt: 'full prompt', }), isLoading: false, + refetch: vi.fn().mockResolvedValue({}), } render() expect(screen.getByText('Showing first 200 instances.')).toBeInTheDocument() }) + + describe('isAgentRunning behavior', () => { + it('shows "Last refreshed: just now" after SSE-triggered refetch when isAgentRunning=true', async () => { + let capturedOnData: ((event: any) => void) | undefined + mockUseSubscriptionWithErrorHandling.mockImplementation((_getter: any, opts: any) => { + capturedOnData = opts.onData + return { isConnected: true, isConnecting: false, error: null, reconnectAttempts: 0, lastEventId: null, reconnect: vi.fn(), reset: vi.fn() } + }) + + const mockRefetch = vi.fn().mockResolvedValue({ data: [] }) + mockUseQueryReturn = { + data: [], + isLoading: false, + refetch: mockRefetch, + } + + render() + + await act(async () => { + capturedOnData?.({ data: { type: 'agent:waiting', agentId: 'agent-1' } }) + }) + + await waitFor(() => { + expect(screen.getByText(/Last refreshed: just now/)).toBeInTheDocument() + }) + }) + + it('does not show "Last refreshed" when isAgentRunning=false', () => { + render() + expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument() + }) + + it('does not show "Last refreshed" when isAgentRunning is not provided', () => { + render() + expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument() + }) + + it('subscription is enabled only when open=true and isAgentRunning=true', () => { + render() + expect(mockUseSubscriptionWithErrorHandling).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ enabled: false }) + ) + }) + }) }) diff --git a/apps/web/src/components/radar/types.ts b/apps/web/src/components/radar/types.ts index 67ea1ac..e7dd307 100644 --- a/apps/web/src/components/radar/types.ts +++ b/apps/web/src/components/radar/types.ts @@ -3,4 +3,5 @@ export interface DrilldownDialogProps { onOpenChange: (open: boolean) => void agentId: string agentName: string + isAgentRunning?: boolean }