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:
Lukas May
2026-03-06 20:18:17 +01:00
parent 9bd56ce4a1
commit 7c48c70d47
9 changed files with 485 additions and 28 deletions

View File

@@ -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>
)

View File

@@ -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>
)

View File

@@ -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>
)

View File

@@ -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>
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,4 +3,5 @@ export interface DrilldownDialogProps {
onOpenChange: (open: boolean) => void
agentId: string
agentName: string
isAgentRunning?: boolean
}