Merge branch 'cw/radar-task-JJ3yFeDd1HIKtqYWuF36n' into cw-merge-1772824727227

This commit is contained in:
Lukas May
2026-03-06 20:18:47 +01:00
8 changed files with 484 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 })
)
})
})
})