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