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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Date | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
@@ -105,6 +145,12 @@ export function CompactionEventsDialog({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAgentRunning && lastRefreshedAt && (
|
||||
<p className="mt-2 text-xs text-muted-foreground text-right">
|
||||
Last refreshed: {secondsAgo === 0 ? 'just now' : `${secondsAgo}s ago`}
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -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<number | null>(null)
|
||||
|
||||
const { data, isLoading } = trpc.conversation.getByFromAgent.useQuery(
|
||||
const { data, isLoading, refetch } = trpc.conversation.getByFromAgent.useQuery(
|
||||
{ agentId },
|
||||
{ enabled: open }
|
||||
)
|
||||
|
||||
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(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({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAgentRunning && lastRefreshedAt && (
|
||||
<p className="mt-2 text-xs text-muted-foreground text-right">
|
||||
Last refreshed: {secondsAgo === 0 ? 'just now' : `${secondsAgo}s ago`}
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -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<number | null>(null)
|
||||
|
||||
const { data, isLoading } = trpc.agent.getQuestionsAsked.useQuery(
|
||||
const { data, isLoading, refetch } = trpc.agent.getQuestionsAsked.useQuery(
|
||||
{ agentId },
|
||||
{ enabled: open }
|
||||
)
|
||||
|
||||
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(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({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAgentRunning && lastRefreshedAt && (
|
||||
<p className="mt-2 text-xs text-muted-foreground text-right">
|
||||
Last refreshed: {secondsAgo === 0 ? 'just now' : `${secondsAgo}s ago`}
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -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<number | null>(null)
|
||||
|
||||
const { data, isLoading } = trpc.agent.getSubagentSpawns.useQuery(
|
||||
const { data, isLoading, refetch } = trpc.agent.getSubagentSpawns.useQuery(
|
||||
{ agentId },
|
||||
{ enabled: open }
|
||||
)
|
||||
|
||||
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(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({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAgentRunning && lastRefreshedAt && (
|
||||
<p className="mt-2 text-xs text-muted-foreground text-right">
|
||||
Last refreshed: {secondsAgo === 0 ? 'just now' : `${secondsAgo}s ago`}
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -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<unknown> } = {
|
||||
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(<CompactionEventsDialog {...defaultProps} />)
|
||||
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(<CompactionEventsDialog {...defaultProps} />)
|
||||
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(<CompactionEventsDialog {...defaultProps} />)
|
||||
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(<CompactionEventsDialog {...defaultProps} />)
|
||||
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(<CompactionEventsDialog {...defaultProps} />)
|
||||
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(<CompactionEventsDialog {...defaultProps} agentId="agent-1" isAgentRunning={true} />)
|
||||
|
||||
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(<CompactionEventsDialog {...defaultProps} isAgentRunning={false} />)
|
||||
expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show "Last refreshed" when isAgentRunning is not provided', () => {
|
||||
render(<CompactionEventsDialog {...defaultProps} />)
|
||||
expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('subscription is enabled only when open=true and isAgentRunning=true', () => {
|
||||
render(<CompactionEventsDialog open={false} onOpenChange={vi.fn()} agentId="agent-1" agentName="test-agent" isAgentRunning={true} />)
|
||||
expect(mockUseSubscriptionWithErrorHandling).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ enabled: false })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<unknown> } = {
|
||||
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(<InterAgentMessagesDialog {...defaultProps} />)
|
||||
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(<InterAgentMessagesDialog {...defaultProps} />)
|
||||
expect(screen.getByText('No data found')).toBeInTheDocument()
|
||||
})
|
||||
@@ -68,6 +90,7 @@ describe('InterAgentMessagesDialog', () => {
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<InterAgentMessagesDialog {...defaultProps} />)
|
||||
expect(screen.getByText('target-agent')).toBeInTheDocument()
|
||||
@@ -91,6 +114,7 @@ describe('InterAgentMessagesDialog', () => {
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<InterAgentMessagesDialog {...defaultProps} />)
|
||||
|
||||
@@ -116,6 +140,7 @@ describe('InterAgentMessagesDialog', () => {
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<InterAgentMessagesDialog {...defaultProps} />)
|
||||
|
||||
@@ -141,6 +166,7 @@ describe('InterAgentMessagesDialog', () => {
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<InterAgentMessagesDialog {...defaultProps} />)
|
||||
|
||||
@@ -165,8 +191,54 @@ describe('InterAgentMessagesDialog', () => {
|
||||
phaseId: null,
|
||||
}),
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<InterAgentMessagesDialog {...defaultProps} />)
|
||||
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(<InterAgentMessagesDialog {...defaultProps} agentId="agent-1" isAgentRunning={true} />)
|
||||
|
||||
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(<InterAgentMessagesDialog {...defaultProps} isAgentRunning={false} />)
|
||||
expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show "Last refreshed" when isAgentRunning is not provided', () => {
|
||||
render(<InterAgentMessagesDialog {...defaultProps} />)
|
||||
expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('subscription is enabled only when open=true and isAgentRunning=true', () => {
|
||||
render(<InterAgentMessagesDialog open={false} onOpenChange={vi.fn()} agentId="agent-1" agentName="test-agent" isAgentRunning={true} />)
|
||||
expect(mockUseSubscriptionWithErrorHandling).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ enabled: false })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<unknown> } = {
|
||||
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(<QuestionsAskedDialog {...defaultProps} />)
|
||||
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(<QuestionsAskedDialog {...defaultProps} />)
|
||||
expect(screen.getByText('No data found')).toBeInTheDocument()
|
||||
})
|
||||
@@ -64,6 +86,7 @@ describe('QuestionsAskedDialog', () => {
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<QuestionsAskedDialog {...defaultProps} />)
|
||||
expect(screen.getByText('2 questions')).toBeInTheDocument()
|
||||
@@ -83,6 +106,7 @@ describe('QuestionsAskedDialog', () => {
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<QuestionsAskedDialog {...defaultProps} />)
|
||||
|
||||
@@ -104,6 +128,7 @@ describe('QuestionsAskedDialog', () => {
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<QuestionsAskedDialog {...defaultProps} />)
|
||||
|
||||
@@ -123,6 +148,7 @@ describe('QuestionsAskedDialog', () => {
|
||||
],
|
||||
}),
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<QuestionsAskedDialog {...defaultProps} />)
|
||||
expect(screen.getByText('Showing first 200 instances.')).toBeInTheDocument()
|
||||
@@ -139,9 +165,55 @@ describe('QuestionsAskedDialog', () => {
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<QuestionsAskedDialog {...defaultProps} />)
|
||||
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(<QuestionsAskedDialog {...defaultProps} agentId="agent-1" isAgentRunning={true} />)
|
||||
|
||||
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(<QuestionsAskedDialog {...defaultProps} isAgentRunning={false} />)
|
||||
expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show "Last refreshed" when isAgentRunning is not provided', () => {
|
||||
render(<QuestionsAskedDialog {...defaultProps} />)
|
||||
expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('subscription is enabled only when open=true and isAgentRunning=true', () => {
|
||||
render(<QuestionsAskedDialog open={false} onOpenChange={vi.fn()} agentId="agent-1" agentName="test-agent" isAgentRunning={true} />)
|
||||
expect(mockUseSubscriptionWithErrorHandling).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ enabled: false })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<unknown> } = {
|
||||
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(<SubagentSpawnsDialog {...defaultProps} />)
|
||||
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(<SubagentSpawnsDialog {...defaultProps} />)
|
||||
expect(screen.getByText('No data found')).toBeInTheDocument()
|
||||
})
|
||||
@@ -63,6 +85,7 @@ describe('SubagentSpawnsDialog', () => {
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<SubagentSpawnsDialog {...defaultProps} />)
|
||||
expect(screen.getByText('my task')).toBeInTheDocument()
|
||||
@@ -81,6 +104,7 @@ describe('SubagentSpawnsDialog', () => {
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<SubagentSpawnsDialog {...defaultProps} />)
|
||||
|
||||
@@ -106,6 +130,7 @@ describe('SubagentSpawnsDialog', () => {
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<SubagentSpawnsDialog {...defaultProps} />)
|
||||
expect(screen.getByText('…')).toBeInTheDocument()
|
||||
@@ -120,8 +145,54 @@ describe('SubagentSpawnsDialog', () => {
|
||||
fullPrompt: 'full prompt',
|
||||
}),
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<SubagentSpawnsDialog {...defaultProps} />)
|
||||
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(<SubagentSpawnsDialog {...defaultProps} agentId="agent-1" isAgentRunning={true} />)
|
||||
|
||||
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(<SubagentSpawnsDialog {...defaultProps} isAgentRunning={false} />)
|
||||
expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show "Last refreshed" when isAgentRunning is not provided', () => {
|
||||
render(<SubagentSpawnsDialog {...defaultProps} />)
|
||||
expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('subscription is enabled only when open=true and isAgentRunning=true', () => {
|
||||
render(<SubagentSpawnsDialog open={false} onOpenChange={vi.fn()} agentId="agent-1" agentName="test-agent" isAgentRunning={true} />)
|
||||
expect(mockUseSubscriptionWithErrorHandling).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ enabled: false })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,4 +3,5 @@ export interface DrilldownDialogProps {
|
||||
onOpenChange: (open: boolean) => void
|
||||
agentId: string
|
||||
agentName: string
|
||||
isAgentRunning?: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user