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