Merge branch 'main' into cw/small-change-flow-conflict-1772826399181
# Conflicts: # README.md # apps/server/execution/orchestrator.ts # apps/server/test/unit/headquarters.test.ts # apps/server/trpc/router.ts # apps/server/trpc/routers/agent.ts # apps/server/trpc/routers/headquarters.ts # apps/web/src/components/hq/HQSections.test.tsx # apps/web/src/components/hq/types.ts # apps/web/src/layouts/AppLayout.tsx # apps/web/src/routes/hq.tsx # apps/web/tsconfig.app.tsbuildinfo # docs/dispatch-events.md # docs/server-api.md # vitest.config.ts
This commit is contained in:
@@ -27,12 +27,14 @@
|
||||
"@tiptap/suggestion": "^3.19.0",
|
||||
"@trpc/client": "^11.9.0",
|
||||
"@trpc/react-query": "^11.9.0",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"geist": "^1.7.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-window": "^2.2.7",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tippy.js": "^6.3.7"
|
||||
|
||||
52
apps/web/src/components/hq/HQResolvingConflictsSection.tsx
Normal file
52
apps/web/src/components/hq/HQResolvingConflictsSection.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { StatusDot } from '@/components/StatusDot'
|
||||
import { formatRelativeTime } from '@/lib/utils'
|
||||
import type { ResolvingConflictsItem } from './types'
|
||||
|
||||
interface Props {
|
||||
items: ResolvingConflictsItem[]
|
||||
}
|
||||
|
||||
export function HQResolvingConflictsSection({ items }: Props) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Resolving Conflicts
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<Card key={item.agentId} className="p-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<StatusDot status="resolving_conflict" variant="urgent" size="sm" pulse />
|
||||
<span className="font-semibold">{item.initiativeName}</span>
|
||||
<Badge variant="urgent" size="xs">{item.agentStatus === 'waiting_for_input' ? 'Needs Input' : 'Running'}</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{item.agentName} · started {formatRelativeTime(item.since)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: '/initiatives/$id',
|
||||
params: { id: item.initiativeId },
|
||||
search: { tab: 'execution' },
|
||||
})
|
||||
}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ vi.mock('@/lib/utils', () => ({
|
||||
import { HQWaitingForInputSection } from './HQWaitingForInputSection'
|
||||
import { HQNeedsReviewSection } from './HQNeedsReviewSection'
|
||||
import { HQNeedsApprovalSection } from './HQNeedsApprovalSection'
|
||||
import { HQResolvingConflictsSection } from './HQResolvingConflictsSection'
|
||||
import { HQBlockedSection } from './HQBlockedSection'
|
||||
import { HQEmptyState } from './HQEmptyState'
|
||||
|
||||
@@ -268,6 +269,77 @@ describe('HQNeedsApprovalSection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ─── HQResolvingConflictsSection ──────────────────────────────────────────────
|
||||
|
||||
describe('HQResolvingConflictsSection', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('renders "Resolving Conflicts" heading', () => {
|
||||
render(<HQResolvingConflictsSection items={[]} />)
|
||||
expect(screen.getByText('Resolving Conflicts')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows initiative name and "Running" badge for running agent', () => {
|
||||
render(
|
||||
<HQResolvingConflictsSection
|
||||
items={[
|
||||
{
|
||||
initiativeId: 'init-1',
|
||||
initiativeName: 'My Initiative',
|
||||
agentId: 'a1',
|
||||
agentName: 'conflict-1234567890',
|
||||
agentStatus: 'running',
|
||||
since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('My Initiative')).toBeInTheDocument()
|
||||
expect(screen.getByText('Running')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Needs Input" badge for waiting_for_input agent', () => {
|
||||
render(
|
||||
<HQResolvingConflictsSection
|
||||
items={[
|
||||
{
|
||||
initiativeId: 'init-1',
|
||||
initiativeName: 'My Initiative',
|
||||
agentId: 'a1',
|
||||
agentName: 'conflict-1234567890',
|
||||
agentStatus: 'waiting_for_input',
|
||||
since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Needs Input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('"View" CTA navigates to /initiatives/$id?tab=execution', () => {
|
||||
render(
|
||||
<HQResolvingConflictsSection
|
||||
items={[
|
||||
{
|
||||
initiativeId: 'init-1',
|
||||
initiativeName: 'My Initiative',
|
||||
agentId: 'a1',
|
||||
agentName: 'conflict-1234567890',
|
||||
agentStatus: 'running',
|
||||
since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /view/i }))
|
||||
expect(mockNavigate).toHaveBeenCalledWith({
|
||||
to: '/initiatives/$id',
|
||||
params: { id: 'init-1' },
|
||||
search: { tab: 'execution' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── HQBlockedSection ────────────────────────────────────────────────────────
|
||||
|
||||
describe('HQBlockedSection', () => {
|
||||
|
||||
@@ -5,4 +5,5 @@ export type WaitingForInputItem = HQDashboard['waitingForInput'][number]
|
||||
export type PendingReviewInitiativeItem = HQDashboard['pendingReviewInitiatives'][number]
|
||||
export type PendingReviewPhaseItem = HQDashboard['pendingReviewPhases'][number]
|
||||
export type PlanningInitiativeItem = HQDashboard['planningInitiatives'][number]
|
||||
export type ResolvingConflictsItem = HQDashboard['resolvingConflicts'][number]
|
||||
export type BlockedPhaseItem = HQDashboard['blockedPhases'][number]
|
||||
|
||||
157
apps/web/src/components/radar/CompactionEventsDialog.tsx
Normal file
157
apps/web/src/components/radar/CompactionEventsDialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
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()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffSecs = Math.floor(diffMs / 1000)
|
||||
const diffMins = Math.floor(diffSecs / 60)
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
let relative: string
|
||||
if (diffSecs < 60) {
|
||||
relative = `${diffSecs}s ago`
|
||||
} else if (diffMins < 60) {
|
||||
relative = `${diffMins}m ago`
|
||||
} else if (diffHours < 24) {
|
||||
relative = `${diffHours}h ago`
|
||||
} else {
|
||||
relative = `${diffDays}d ago`
|
||||
}
|
||||
|
||||
const absolute = date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
|
||||
return `${relative} · ${absolute}`
|
||||
}
|
||||
|
||||
export function CompactionEventsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
agentId,
|
||||
agentName,
|
||||
isAgentRunning,
|
||||
}: DrilldownDialogProps) {
|
||||
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">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`Compaction Events — ${agentName}`}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Each row is a context-window compaction — the model's history was summarized to
|
||||
free up space. Frequent compactions indicate a long-running agent with large context.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[70vh] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i}>
|
||||
<div className="flex items-center gap-4 py-2">
|
||||
<Skeleton className="w-40 h-4" />
|
||||
<Skeleton className="w-12 h-4" />
|
||||
</div>
|
||||
{i < 2 && <div className="border-b" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !data || data.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">No data found</p>
|
||||
) : (
|
||||
<>
|
||||
{data.length >= 200 && (
|
||||
<p className="text-sm text-muted-foreground mb-2">Showing first 200 instances.</p>
|
||||
)}
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 pr-4 font-medium">Timestamp</th>
|
||||
<th className="text-center py-2 font-medium">Session #</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, i) => (
|
||||
<tr key={i} className="border-b last:border-0">
|
||||
<td className="py-2 pr-4 text-muted-foreground">
|
||||
{formatTimestamp(row.timestamp)}
|
||||
</td>
|
||||
<td className="py-2 text-center">{row.sessionNumber}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
209
apps/web/src/components/radar/InterAgentMessagesDialog.tsx
Normal file
209
apps/web/src/components/radar/InterAgentMessagesDialog.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useState, useEffect, Fragment } from 'react'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffSecs = Math.floor(diffMs / 1000)
|
||||
const diffMins = Math.floor(diffSecs / 60)
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
let relative: string
|
||||
if (diffSecs < 60) {
|
||||
relative = `${diffSecs}s ago`
|
||||
} else if (diffMins < 60) {
|
||||
relative = `${diffMins}m ago`
|
||||
} else if (diffHours < 24) {
|
||||
relative = `${diffHours}h ago`
|
||||
} else {
|
||||
relative = `${diffDays}d ago`
|
||||
}
|
||||
|
||||
const absolute = date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
|
||||
return `${relative} · ${absolute}`
|
||||
}
|
||||
|
||||
export function InterAgentMessagesDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
agentId,
|
||||
agentName,
|
||||
isAgentRunning,
|
||||
}: DrilldownDialogProps) {
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null)
|
||||
|
||||
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 || !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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`Inter-Agent Messages — ${agentName}`}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Each row is a conversation this agent initiated with another agent. Click a row to see
|
||||
the full question and answer.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[70vh] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i}>
|
||||
<div className="flex items-center gap-4 py-2">
|
||||
<Skeleton className="w-28 h-4" />
|
||||
<Skeleton className="w-32 h-4" />
|
||||
<Skeleton className="w-20 h-5" />
|
||||
</div>
|
||||
{i < 2 && <div className="border-b" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !data || data.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">No data found</p>
|
||||
) : (
|
||||
<>
|
||||
{data.length >= 200 && (
|
||||
<p className="text-sm text-muted-foreground mb-2">Showing first 200 instances.</p>
|
||||
)}
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 pr-4 font-medium">Timestamp</th>
|
||||
<th className="text-left py-2 pr-4 font-medium">Target Agent</th>
|
||||
<th className="text-left py-2 font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, i) => (
|
||||
<Fragment key={i}>
|
||||
<tr
|
||||
className="border-b last:border-0 cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => setExpandedIndex(i === expandedIndex ? null : i)}
|
||||
>
|
||||
<td className="py-2 pr-4 text-muted-foreground whitespace-nowrap">
|
||||
{formatTimestamp(row.timestamp)}
|
||||
</td>
|
||||
<td className="py-2 pr-4">{row.toAgentName}</td>
|
||||
<td className="py-2">
|
||||
{row.status === 'answered' ? (
|
||||
<Badge variant="secondary">answered</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
pending
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{expandedIndex === i && (
|
||||
<tr>
|
||||
<td colSpan={3} className="pb-2">
|
||||
<div className="bg-muted/10 p-3 rounded mt-1 mb-1 space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide mb-1">
|
||||
Question
|
||||
</p>
|
||||
<div
|
||||
style={{ maxHeight: '200px', overflowY: 'auto' }}
|
||||
className="bg-muted/50 p-2 rounded font-mono text-sm whitespace-pre-wrap"
|
||||
>
|
||||
{row.question}
|
||||
</div>
|
||||
{row.status === 'answered' ? (
|
||||
<>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide mb-1">
|
||||
Answer
|
||||
</p>
|
||||
<div
|
||||
style={{ maxHeight: '200px', overflowY: 'auto' }}
|
||||
className="bg-muted/50 p-2 rounded font-mono text-sm whitespace-pre-wrap"
|
||||
>
|
||||
{row.answer}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground italic text-sm">No answer yet</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
201
apps/web/src/components/radar/QuestionsAskedDialog.tsx
Normal file
201
apps/web/src/components/radar/QuestionsAskedDialog.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useState, useEffect, Fragment } from 'react'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
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()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffSecs = Math.floor(diffMs / 1000)
|
||||
const diffMins = Math.floor(diffSecs / 60)
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
let relative: string
|
||||
if (diffSecs < 60) {
|
||||
relative = `${diffSecs}s ago`
|
||||
} else if (diffMins < 60) {
|
||||
relative = `${diffMins}m ago`
|
||||
} else if (diffHours < 24) {
|
||||
relative = `${diffHours}h ago`
|
||||
} else {
|
||||
relative = `${diffDays}d ago`
|
||||
}
|
||||
|
||||
const absolute = date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
|
||||
return `${relative} · ${absolute}`
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
return text.length > max ? text.slice(0, max) + '…' : text
|
||||
}
|
||||
|
||||
export function QuestionsAskedDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
agentId,
|
||||
agentName,
|
||||
isAgentRunning,
|
||||
}: DrilldownDialogProps) {
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null)
|
||||
|
||||
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 || !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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`Questions Asked — ${agentName}`}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Each row is a question this agent sent to the user via the AskUserQuestion tool.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[70vh] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i}>
|
||||
<div className="flex items-center gap-4 py-2">
|
||||
<Skeleton className="w-28 h-4" />
|
||||
<Skeleton className="w-20 h-4" />
|
||||
<Skeleton className="w-36 h-4" />
|
||||
</div>
|
||||
{i < 2 && <div className="border-b" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !data || data.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">No data found</p>
|
||||
) : (
|
||||
<>
|
||||
{data.length >= 200 && (
|
||||
<p className="text-sm text-muted-foreground mb-2">Showing first 200 instances.</p>
|
||||
)}
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 pr-4 font-medium">Timestamp</th>
|
||||
<th className="text-left py-2 pr-4 font-medium"># Questions</th>
|
||||
<th className="text-left py-2 font-medium">First Question Header</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, i) => {
|
||||
const n = row.questions.length
|
||||
const countLabel = `${n} question${n !== 1 ? 's' : ''}`
|
||||
const firstHeader = truncate(row.questions[0]?.header ?? '', 40)
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
<tr
|
||||
className="border-b last:border-0 cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => setExpandedIndex(i === expandedIndex ? null : i)}
|
||||
>
|
||||
<td className="py-2 pr-4 text-muted-foreground whitespace-nowrap">
|
||||
{formatTimestamp(row.timestamp)}
|
||||
</td>
|
||||
<td className="py-2 pr-4">{countLabel}</td>
|
||||
<td className="py-2">{firstHeader}</td>
|
||||
</tr>
|
||||
{expandedIndex === i && (
|
||||
<tr>
|
||||
<td colSpan={3} className="pb-2">
|
||||
<div className="bg-muted/30 p-3 rounded mt-1 mb-1">
|
||||
<ol className="space-y-3 list-decimal list-inside">
|
||||
{row.questions.map((q, qi) => (
|
||||
<li key={qi}>
|
||||
<span className="font-bold bg-muted px-1 py-0.5 rounded text-sm mr-2">
|
||||
{q.header}
|
||||
</span>
|
||||
{q.question}
|
||||
<ul className="ml-4 mt-1 space-y-0.5">
|
||||
{q.options.map((opt, oi) => (
|
||||
<li key={oi} className="text-sm text-muted-foreground">
|
||||
{`• ${opt.label} — ${opt.description}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
185
apps/web/src/components/radar/SubagentSpawnsDialog.tsx
Normal file
185
apps/web/src/components/radar/SubagentSpawnsDialog.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useState, useEffect, Fragment } from 'react'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
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()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffSecs = Math.floor(diffMs / 1000)
|
||||
const diffMins = Math.floor(diffSecs / 60)
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
let relative: string
|
||||
if (diffSecs < 60) {
|
||||
relative = `${diffSecs}s ago`
|
||||
} else if (diffMins < 60) {
|
||||
relative = `${diffMins}m ago`
|
||||
} else if (diffHours < 24) {
|
||||
relative = `${diffHours}h ago`
|
||||
} else {
|
||||
relative = `${diffDays}d ago`
|
||||
}
|
||||
|
||||
const absolute = date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
|
||||
return `${relative} · ${absolute}`
|
||||
}
|
||||
|
||||
export function SubagentSpawnsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
agentId,
|
||||
agentName,
|
||||
isAgentRunning,
|
||||
}: DrilldownDialogProps) {
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null)
|
||||
|
||||
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 || !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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`Subagent Spawns — ${agentName}`}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Each row is an Agent tool call — a subagent spawned by this agent. The description and
|
||||
first 200 characters of the prompt are shown.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[70vh] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i}>
|
||||
<div className="flex items-center gap-4 py-2">
|
||||
<Skeleton className="w-32 h-4" />
|
||||
<Skeleton className="w-48 h-4" />
|
||||
<Skeleton className="w-64 h-4" />
|
||||
</div>
|
||||
{i < 2 && <div className="border-b" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !data || data.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">No data found</p>
|
||||
) : (
|
||||
<>
|
||||
{data.length >= 200 && (
|
||||
<p className="text-sm text-muted-foreground mb-2">Showing first 200 instances.</p>
|
||||
)}
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 pr-4 font-medium">Timestamp</th>
|
||||
<th className="text-left py-2 pr-4 font-medium">Description</th>
|
||||
<th className="text-left py-2 font-medium">Prompt Preview</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, i) => (
|
||||
<Fragment key={i}>
|
||||
<tr
|
||||
className="border-b last:border-0 cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => setExpandedIndex(i === expandedIndex ? null : i)}
|
||||
>
|
||||
<td className="py-2 pr-4 text-muted-foreground whitespace-nowrap">
|
||||
{formatTimestamp(row.timestamp)}
|
||||
</td>
|
||||
<td className="py-2 pr-4">{row.description}</td>
|
||||
<td className="py-2">
|
||||
{row.promptPreview}
|
||||
{row.fullPrompt.length > row.promptPreview.length && (
|
||||
<span>…</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{expandedIndex === i && (
|
||||
<tr>
|
||||
<td colSpan={3} className="pb-2">
|
||||
<div
|
||||
className="bg-muted/30 p-3 rounded"
|
||||
style={{ maxHeight: '300px', overflowY: 'auto', fontFamily: 'monospace' }}
|
||||
>
|
||||
<pre>{row.fullPrompt}</pre>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { render, screen, act, waitFor } from '@testing-library/react'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
|
||||
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', () => ({
|
||||
trpc: {
|
||||
agent: {
|
||||
getCompactionEvents: {
|
||||
useQuery: vi.fn((_args: unknown, _opts: unknown) => mockUseQueryReturn),
|
||||
},
|
||||
},
|
||||
onEvent: {
|
||||
useSubscription: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { CompactionEventsDialog } from '../CompactionEventsDialog'
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
agentId: 'agent-123',
|
||||
agentName: 'test-agent',
|
||||
}
|
||||
|
||||
describe('CompactionEventsDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
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', () => {
|
||||
render(<CompactionEventsDialog {...defaultProps} open={false} />)
|
||||
expect(screen.queryByText(/Compaction Events/)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows skeleton rows when loading', () => {
|
||||
mockUseQueryReturn = { data: undefined, isLoading: true, refetch: vi.fn().mockResolvedValue({}) }
|
||||
render(<CompactionEventsDialog {...defaultProps} />)
|
||||
const skeletons = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletons.length).toBeGreaterThanOrEqual(3)
|
||||
expect(screen.queryByRole('table')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows "No data found" when data is empty', () => {
|
||||
mockUseQueryReturn = { data: [], isLoading: false, refetch: vi.fn().mockResolvedValue({}) }
|
||||
render(<CompactionEventsDialog {...defaultProps} />)
|
||||
expect(screen.getByText('No data found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders data rows correctly', () => {
|
||||
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()
|
||||
// Timestamp includes year 2026
|
||||
expect(screen.getByText(/2026/)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Showing first 200 instances.')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows 200-instance note when data length is 200', () => {
|
||||
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, 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 })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,244 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
|
||||
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', () => ({
|
||||
trpc: {
|
||||
conversation: {
|
||||
getByFromAgent: {
|
||||
useQuery: vi.fn((_args: unknown, _opts: unknown) => mockUseQueryReturn),
|
||||
},
|
||||
},
|
||||
onEvent: {
|
||||
useSubscription: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { InterAgentMessagesDialog } from '../InterAgentMessagesDialog'
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
agentId: 'agent-123',
|
||||
agentName: 'test-agent',
|
||||
}
|
||||
|
||||
describe('InterAgentMessagesDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
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', () => {
|
||||
render(<InterAgentMessagesDialog {...defaultProps} open={false} />)
|
||||
expect(screen.queryByText(/Inter-Agent Messages/)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows skeleton rows when loading', () => {
|
||||
mockUseQueryReturn = { data: undefined, isLoading: true, refetch: vi.fn().mockResolvedValue({}) }
|
||||
render(<InterAgentMessagesDialog {...defaultProps} />)
|
||||
const skeletons = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletons.length).toBeGreaterThanOrEqual(3)
|
||||
expect(screen.queryByRole('table')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows "No data found" when data is empty', () => {
|
||||
mockUseQueryReturn = { data: [], isLoading: false, refetch: vi.fn().mockResolvedValue({}) }
|
||||
render(<InterAgentMessagesDialog {...defaultProps} />)
|
||||
expect(screen.getByText('No data found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders data rows for answered conversation', () => {
|
||||
mockUseQueryReturn = {
|
||||
data: [
|
||||
{
|
||||
id: 'c1',
|
||||
timestamp: '2026-03-06T10:00:00.000Z',
|
||||
toAgentName: 'target-agent',
|
||||
toAgentId: 'agent-2',
|
||||
question: 'What is the export path?',
|
||||
answer: 'It is src/api/index.ts',
|
||||
status: 'answered',
|
||||
taskId: null,
|
||||
phaseId: null,
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<InterAgentMessagesDialog {...defaultProps} />)
|
||||
expect(screen.getByText('target-agent')).toBeInTheDocument()
|
||||
expect(screen.getByText('answered')).toBeInTheDocument()
|
||||
expect(screen.queryByText('What is the export path?')).toBeNull()
|
||||
})
|
||||
|
||||
it('expands answered row to show question and answer', () => {
|
||||
mockUseQueryReturn = {
|
||||
data: [
|
||||
{
|
||||
id: 'c1',
|
||||
timestamp: '2026-03-06T10:00:00.000Z',
|
||||
toAgentName: 'target-agent',
|
||||
toAgentId: 'agent-2',
|
||||
question: 'What is the export path?',
|
||||
answer: 'It is src/api/index.ts',
|
||||
status: 'answered',
|
||||
taskId: null,
|
||||
phaseId: null,
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<InterAgentMessagesDialog {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText('target-agent').closest('tr')!)
|
||||
expect(screen.getByText('What is the export path?')).toBeInTheDocument()
|
||||
expect(screen.getByText('It is src/api/index.ts')).toBeInTheDocument()
|
||||
expect(screen.queryByText('No answer yet')).toBeNull()
|
||||
})
|
||||
|
||||
it('expands pending row to show question and "No answer yet"', () => {
|
||||
mockUseQueryReturn = {
|
||||
data: [
|
||||
{
|
||||
id: 'c2',
|
||||
timestamp: '2026-03-06T10:00:00.000Z',
|
||||
toAgentName: 'target-agent',
|
||||
toAgentId: 'agent-2',
|
||||
question: 'What is the export path?',
|
||||
answer: null,
|
||||
status: 'pending',
|
||||
taskId: null,
|
||||
phaseId: null,
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<InterAgentMessagesDialog {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText('target-agent').closest('tr')!)
|
||||
expect(screen.getByText('What is the export path?')).toBeInTheDocument()
|
||||
expect(screen.getByText('No answer yet')).toBeInTheDocument()
|
||||
expect(screen.queryByText('It is src/api/index.ts')).toBeNull()
|
||||
})
|
||||
|
||||
it('collapses row when clicked again', () => {
|
||||
mockUseQueryReturn = {
|
||||
data: [
|
||||
{
|
||||
id: 'c1',
|
||||
timestamp: '2026-03-06T10:00:00.000Z',
|
||||
toAgentName: 'target-agent',
|
||||
toAgentId: 'agent-2',
|
||||
question: 'What is the export path?',
|
||||
answer: 'It is src/api/index.ts',
|
||||
status: 'answered',
|
||||
taskId: null,
|
||||
phaseId: null,
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<InterAgentMessagesDialog {...defaultProps} />)
|
||||
|
||||
const row = screen.getByText('target-agent').closest('tr')!
|
||||
fireEvent.click(row)
|
||||
expect(screen.getByText('What is the export path?')).toBeInTheDocument()
|
||||
fireEvent.click(row)
|
||||
expect(screen.queryByText('What is the export path?')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows 200-instance note when data length is 200', () => {
|
||||
mockUseQueryReturn = {
|
||||
data: Array(200).fill({
|
||||
id: 'c1',
|
||||
timestamp: '2026-03-06T10:00:00.000Z',
|
||||
toAgentName: 'target-agent',
|
||||
toAgentId: 'agent-2',
|
||||
question: 'What is the export path?',
|
||||
answer: null,
|
||||
status: 'pending',
|
||||
taskId: null,
|
||||
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 })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
|
||||
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', () => ({
|
||||
trpc: {
|
||||
agent: {
|
||||
getQuestionsAsked: {
|
||||
useQuery: vi.fn((_args: unknown, _opts: unknown) => mockUseQueryReturn),
|
||||
},
|
||||
},
|
||||
onEvent: {
|
||||
useSubscription: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { QuestionsAskedDialog } from '../QuestionsAskedDialog'
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
agentId: 'agent-123',
|
||||
agentName: 'test-agent',
|
||||
}
|
||||
|
||||
describe('QuestionsAskedDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
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', () => {
|
||||
render(<QuestionsAskedDialog {...defaultProps} open={false} />)
|
||||
expect(screen.queryByText(/Questions Asked/)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows skeleton rows when loading', () => {
|
||||
mockUseQueryReturn = { data: undefined, isLoading: true, refetch: vi.fn().mockResolvedValue({}) }
|
||||
render(<QuestionsAskedDialog {...defaultProps} />)
|
||||
const skeletons = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletons.length).toBeGreaterThanOrEqual(3)
|
||||
expect(screen.queryByRole('table')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows "No data found" when data is empty', () => {
|
||||
mockUseQueryReturn = { data: [], isLoading: false, refetch: vi.fn().mockResolvedValue({}) }
|
||||
render(<QuestionsAskedDialog {...defaultProps} />)
|
||||
expect(screen.getByText('No data found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders data rows correctly', () => {
|
||||
mockUseQueryReturn = {
|
||||
data: [
|
||||
{
|
||||
timestamp: '2026-03-06T10:00:00.000Z',
|
||||
questions: [
|
||||
{ question: 'Pick a method', header: 'Method', options: [{ label: 'A', description: 'Option A' }] },
|
||||
{ question: 'Pick a strategy', header: 'Strategy', options: [{ label: 'B', description: 'Option B' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<QuestionsAskedDialog {...defaultProps} />)
|
||||
expect(screen.getByText('2 questions')).toBeInTheDocument()
|
||||
expect(screen.getByText('Method')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Pick a method')).toBeNull()
|
||||
})
|
||||
|
||||
it('expands row to show all sub-questions on click', () => {
|
||||
mockUseQueryReturn = {
|
||||
data: [
|
||||
{
|
||||
timestamp: '2026-03-06T10:00:00.000Z',
|
||||
questions: [
|
||||
{ question: 'Pick a method', header: 'Method', options: [{ label: 'A', description: 'Option A' }] },
|
||||
{ question: 'Pick a strategy', header: 'Strategy', options: [{ label: 'B', description: 'Option B' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<QuestionsAskedDialog {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText('2 questions').closest('tr')!)
|
||||
expect(screen.getByText('Pick a method')).toBeInTheDocument()
|
||||
expect(screen.getByText('Pick a strategy')).toBeInTheDocument()
|
||||
expect(screen.getByText('• A — Option A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses row when clicked again', () => {
|
||||
mockUseQueryReturn = {
|
||||
data: [
|
||||
{
|
||||
timestamp: '2026-03-06T10:00:00.000Z',
|
||||
questions: [
|
||||
{ question: 'Pick a method', header: 'Method', options: [{ label: 'A', description: 'Option A' }] },
|
||||
{ question: 'Pick a strategy', header: 'Strategy', options: [{ label: 'B', description: 'Option B' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<QuestionsAskedDialog {...defaultProps} />)
|
||||
|
||||
const row = screen.getByText('2 questions').closest('tr')!
|
||||
fireEvent.click(row)
|
||||
expect(screen.getByText('Pick a method')).toBeInTheDocument()
|
||||
fireEvent.click(row)
|
||||
expect(screen.queryByText('Pick a method')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows 200-instance note when data length is 200', () => {
|
||||
mockUseQueryReturn = {
|
||||
data: Array(200).fill({
|
||||
timestamp: '2026-03-06T10:00:00.000Z',
|
||||
questions: [
|
||||
{ question: 'Pick a method', header: 'Method', options: [] },
|
||||
],
|
||||
}),
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<QuestionsAskedDialog {...defaultProps} />)
|
||||
expect(screen.getByText('Showing first 200 instances.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows singular "1 question" for single-question rows', () => {
|
||||
mockUseQueryReturn = {
|
||||
data: [
|
||||
{
|
||||
timestamp: '2026-03-06T10:00:00.000Z',
|
||||
questions: [
|
||||
{ question: 'Only one', header: 'Single', options: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
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 })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
|
||||
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', () => ({
|
||||
trpc: {
|
||||
agent: {
|
||||
getSubagentSpawns: {
|
||||
useQuery: vi.fn((_args: unknown, _opts: unknown) => mockUseQueryReturn),
|
||||
},
|
||||
},
|
||||
onEvent: {
|
||||
useSubscription: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { SubagentSpawnsDialog } from '../SubagentSpawnsDialog'
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
agentId: 'agent-123',
|
||||
agentName: 'test-agent',
|
||||
}
|
||||
|
||||
describe('SubagentSpawnsDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
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', () => {
|
||||
render(<SubagentSpawnsDialog {...defaultProps} open={false} />)
|
||||
expect(screen.queryByText(/Subagent Spawns/)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows skeleton rows when loading', () => {
|
||||
mockUseQueryReturn = { data: undefined, isLoading: true, refetch: vi.fn().mockResolvedValue({}) }
|
||||
render(<SubagentSpawnsDialog {...defaultProps} />)
|
||||
const skeletons = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletons.length).toBeGreaterThanOrEqual(3)
|
||||
expect(screen.queryByRole('table')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows "No data found" when data is empty', () => {
|
||||
mockUseQueryReturn = { data: [], isLoading: false, refetch: vi.fn().mockResolvedValue({}) }
|
||||
render(<SubagentSpawnsDialog {...defaultProps} />)
|
||||
expect(screen.getByText('No data found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders data rows correctly', () => {
|
||||
mockUseQueryReturn = {
|
||||
data: [
|
||||
{
|
||||
timestamp: '2026-03-06T10:00:00.000Z',
|
||||
description: 'my task',
|
||||
promptPreview: 'hello',
|
||||
fullPrompt: 'hello world full',
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<SubagentSpawnsDialog {...defaultProps} />)
|
||||
expect(screen.getByText('my task')).toBeInTheDocument()
|
||||
expect(screen.getByText('hello')).toBeInTheDocument()
|
||||
expect(screen.queryByText('hello world full')).toBeNull()
|
||||
})
|
||||
|
||||
it('expands and collapses row on click', () => {
|
||||
mockUseQueryReturn = {
|
||||
data: [
|
||||
{
|
||||
timestamp: '2026-03-06T10:00:00.000Z',
|
||||
description: 'my task',
|
||||
promptPreview: 'hello',
|
||||
fullPrompt: 'hello world full',
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<SubagentSpawnsDialog {...defaultProps} />)
|
||||
|
||||
// Click the row — should expand
|
||||
fireEvent.click(screen.getByText('my task').closest('tr')!)
|
||||
expect(screen.getByText('hello world full')).toBeInTheDocument()
|
||||
|
||||
// Click again — should collapse
|
||||
fireEvent.click(screen.getByText('my task').closest('tr')!)
|
||||
expect(screen.queryByText('hello world full')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows ellipsis suffix when fullPrompt is longer than promptPreview', () => {
|
||||
const fullPrompt = 'A'.repeat(201)
|
||||
const promptPreview = fullPrompt.slice(0, 200)
|
||||
mockUseQueryReturn = {
|
||||
data: [
|
||||
{
|
||||
timestamp: '2026-03-06T10:00:00.000Z',
|
||||
description: 'truncated task',
|
||||
promptPreview,
|
||||
fullPrompt,
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
render(<SubagentSpawnsDialog {...defaultProps} />)
|
||||
expect(screen.getByText('…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows 200-instance note when data length is 200', () => {
|
||||
mockUseQueryReturn = {
|
||||
data: Array(200).fill({
|
||||
timestamp: '2026-03-06T10:00:00.000Z',
|
||||
description: 'task',
|
||||
promptPreview: 'prompt',
|
||||
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 })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
7
apps/web/src/components/radar/types.ts
Normal file
7
apps/web/src/components/radar/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface DrilldownDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
agentId: string
|
||||
agentName: string
|
||||
isAgentRunning?: boolean
|
||||
}
|
||||
@@ -11,7 +11,7 @@ interface ConflictResolutionPanelProps {
|
||||
}
|
||||
|
||||
export function ConflictResolutionPanel({ initiativeId, conflicts, onResolved }: ConflictResolutionPanelProps) {
|
||||
const { state, agent, questions, spawn, resume, stop, dismiss } = useConflictAgent(initiativeId);
|
||||
const { state, agent: _agent, questions, spawn, resume, stop, dismiss } = useConflictAgent(initiativeId);
|
||||
const [showManual, setShowManual] = useState(false);
|
||||
const prevStateRef = useRef<string | null>(null);
|
||||
|
||||
|
||||
192
apps/web/src/components/review/DiffViewer.test.tsx
Normal file
192
apps/web/src/components/review/DiffViewer.test.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
// @vitest-environment happy-dom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { DiffViewer } from "./DiffViewer";
|
||||
import type { FileDiff } from "./types";
|
||||
|
||||
// ── Module mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("./FileCard", () => ({
|
||||
FileCard: ({ file }: { file: FileDiff }) => (
|
||||
<div data-testid="file-card" data-path={file.newPath} />
|
||||
),
|
||||
}));
|
||||
|
||||
// Hoist the fetch mock so it can be referenced inside vi.mock factories
|
||||
const { mockGetFileDiffFetch } = vi.hoisted(() => ({
|
||||
mockGetFileDiffFetch: vi.fn().mockResolvedValue({ rawDiff: "" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/trpc", () => ({
|
||||
trpc: {
|
||||
useUtils: () => ({
|
||||
getFileDiff: { fetch: mockGetFileDiffFetch },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// DiffViewer calls useQueryClient() (even though the return value is unused).
|
||||
// Provide a minimal mock so the hook doesn't throw outside a QueryClientProvider.
|
||||
vi.mock("@tanstack/react-query", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@tanstack/react-query")>();
|
||||
return { ...actual, useQueryClient: () => ({}) };
|
||||
});
|
||||
|
||||
// ── IntersectionObserver mock ─────────────────────────────────────────────────
|
||||
|
||||
let observerCallback: IntersectionObserverCallback | null = null;
|
||||
const observedElements = new Set<Element>();
|
||||
|
||||
// Class (not arrow function) so it can be used with `new IntersectionObserver(...)`
|
||||
class MockIntersectionObserver {
|
||||
constructor(cb: IntersectionObserverCallback) {
|
||||
observerCallback = cb;
|
||||
}
|
||||
observe(el: Element) {
|
||||
observedElements.add(el);
|
||||
}
|
||||
unobserve(el: Element) {
|
||||
observedElements.delete(el);
|
||||
}
|
||||
disconnect() {
|
||||
observedElements.clear();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
|
||||
observedElements.clear();
|
||||
observerCallback = null;
|
||||
mockGetFileDiffFetch.mockClear();
|
||||
mockGetFileDiffFetch.mockResolvedValue({ rawDiff: "" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fire the IntersectionObserver callback with a set of intersecting and
|
||||
* non-intersecting file paths. The target element is simulated by an object
|
||||
* whose dataset.filePath matches the DiffViewer's data-file-path attribute.
|
||||
*/
|
||||
function fireIntersection(
|
||||
intersectingPaths: string[],
|
||||
nonIntersectingPaths: string[] = [],
|
||||
) {
|
||||
if (!observerCallback) return;
|
||||
const entries = [
|
||||
...intersectingPaths.map((p) => ({
|
||||
isIntersecting: true,
|
||||
target: { dataset: { filePath: p } } as unknown as Element,
|
||||
})),
|
||||
...nonIntersectingPaths.map((p) => ({
|
||||
isIntersecting: false,
|
||||
target: { dataset: { filePath: p } } as unknown as Element,
|
||||
})),
|
||||
] as IntersectionObserverEntry[];
|
||||
act(() => {
|
||||
observerCallback!(entries, {} as IntersectionObserver);
|
||||
});
|
||||
}
|
||||
|
||||
function makeFiles(count: number): FileDiff[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
oldPath: `file${i}.ts`,
|
||||
newPath: `file${i}.ts`,
|
||||
status: "modified" as const,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
}));
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
phaseId: "phase-1",
|
||||
commitMode: false,
|
||||
commentsByLine: new Map(),
|
||||
onAddComment: vi.fn(),
|
||||
onResolveComment: vi.fn(),
|
||||
onUnresolveComment: vi.fn(),
|
||||
};
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("DiffViewer", () => {
|
||||
it("renders all FileCards when 5 files are all in viewport", () => {
|
||||
const files = makeFiles(5);
|
||||
render(<DiffViewer files={files} {...defaultProps} />);
|
||||
|
||||
// Trigger all five as intersecting
|
||||
fireIntersection(files.map((f) => f.newPath));
|
||||
|
||||
expect(screen.getAllByTestId("file-card")).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("shows only intersecting FileCards for 300 files, placeholders for the rest", () => {
|
||||
const files = makeFiles(300);
|
||||
render(<DiffViewer files={files} {...defaultProps} />);
|
||||
|
||||
// Only first 5 files enter the viewport
|
||||
fireIntersection(files.slice(0, 5).map((f) => f.newPath));
|
||||
|
||||
expect(screen.getAllByTestId("file-card")).toHaveLength(5);
|
||||
|
||||
// The remaining 295 should be 48px placeholder divs marked aria-hidden
|
||||
const placeholders = document.querySelectorAll(
|
||||
'[aria-hidden][style*="height: 48px"]',
|
||||
);
|
||||
expect(placeholders.length).toBeGreaterThanOrEqual(295);
|
||||
});
|
||||
|
||||
it("skips IntersectionObserver for single-file diff and renders FileCard directly", () => {
|
||||
render(<DiffViewer files={makeFiles(1)} {...defaultProps} />);
|
||||
|
||||
// Single-file path: isVisible is always true, no intersection event needed
|
||||
expect(screen.getAllByTestId("file-card")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("calls scrollIntoView on the wrapper div when onRegisterRef is used for sidebar navigation", () => {
|
||||
const files = makeFiles(5);
|
||||
const registeredRefs = new Map<string, HTMLDivElement>();
|
||||
const onRegisterRef = (filePath: string, el: HTMLDivElement | null) => {
|
||||
if (el) registeredRefs.set(filePath, el);
|
||||
};
|
||||
|
||||
render(<DiffViewer files={files} {...defaultProps} onRegisterRef={onRegisterRef} />);
|
||||
|
||||
// All wrapper divs should have been registered (including the last one)
|
||||
const targetFile = files[4].newPath;
|
||||
expect(registeredRefs.has(targetFile)).toBe(true);
|
||||
|
||||
const wrapperEl = registeredRefs.get(targetFile)!;
|
||||
const scrollSpy = vi.fn();
|
||||
Object.defineProperty(wrapperEl, "scrollIntoView", { value: scrollSpy });
|
||||
|
||||
// Simulate a sidebar click that calls scrollIntoView on the wrapper
|
||||
act(() => {
|
||||
wrapperEl.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
expect(scrollSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("fires getFileDiff queries in batches of 10 when expandAll is toggled", async () => {
|
||||
const files = makeFiles(25); // 3 batches: 10, 10, 5
|
||||
const { rerender } = render(
|
||||
<DiffViewer files={files} {...defaultProps} expandAll={false} />,
|
||||
);
|
||||
|
||||
rerender(<DiffViewer files={files} {...defaultProps} expandAll={true} />);
|
||||
|
||||
// Wait for all async batch iterations to complete
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
});
|
||||
|
||||
// All 25 non-binary files should have been prefetched
|
||||
expect(mockGetFileDiffFetch).toHaveBeenCalledTimes(25);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,25 @@
|
||||
import type { FileDiff, DiffLine, ReviewComment } from "./types";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { FileDiff, FileDiffDetail, DiffLine, ReviewComment } from "./types";
|
||||
import { FileCard } from "./FileCard";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
function getFileCommentMap(
|
||||
commentsByLine: Map<string, ReviewComment[]>,
|
||||
filePath: string,
|
||||
): Map<string, ReviewComment[]> {
|
||||
const result = new Map<string, ReviewComment[]>();
|
||||
for (const [key, val] of commentsByLine) {
|
||||
if (key.startsWith(`${filePath}:`)) result.set(key, val);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
interface DiffViewerProps {
|
||||
files: FileDiff[];
|
||||
comments: ReviewComment[];
|
||||
files: (FileDiff | FileDiffDetail)[];
|
||||
phaseId: string;
|
||||
commitMode: boolean;
|
||||
commentsByLine: Map<string, ReviewComment[]>;
|
||||
onAddComment: (
|
||||
filePath: string,
|
||||
lineNumber: number,
|
||||
@@ -17,11 +33,14 @@ interface DiffViewerProps {
|
||||
viewedFiles?: Set<string>;
|
||||
onToggleViewed?: (filePath: string) => void;
|
||||
onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void;
|
||||
expandAll?: boolean;
|
||||
}
|
||||
|
||||
export function DiffViewer({
|
||||
files,
|
||||
comments,
|
||||
phaseId,
|
||||
commitMode,
|
||||
commentsByLine,
|
||||
onAddComment,
|
||||
onResolveComment,
|
||||
onUnresolveComment,
|
||||
@@ -30,24 +49,156 @@ export function DiffViewer({
|
||||
viewedFiles,
|
||||
onToggleViewed,
|
||||
onRegisterRef,
|
||||
expandAll,
|
||||
}: DiffViewerProps) {
|
||||
// Set of file paths currently intersecting (or near) the viewport
|
||||
const visibleFiles = useRef<Set<string>>(new Set());
|
||||
// Map from filePath → wrapper div ref
|
||||
const wrapperRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
// Increment to trigger re-render when visibility changes
|
||||
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
||||
|
||||
// Single IntersectionObserver for all wrappers
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (files.length === 1) return; // skip for single file
|
||||
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
let changed = false;
|
||||
for (const entry of entries) {
|
||||
const filePath = (entry.target as HTMLDivElement).dataset['filePath'];
|
||||
if (!filePath) continue;
|
||||
if (entry.isIntersecting) {
|
||||
if (!visibleFiles.current.has(filePath)) {
|
||||
visibleFiles.current.add(filePath);
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
if (visibleFiles.current.has(filePath)) {
|
||||
visibleFiles.current.delete(filePath);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) setVisibilityVersion((v) => v + 1);
|
||||
},
|
||||
{ rootMargin: '100% 0px 100% 0px' }, // 1× viewport above and below
|
||||
);
|
||||
|
||||
// Observe all current wrapper divs
|
||||
for (const el of wrapperRefs.current.values()) {
|
||||
observerRef.current.observe(el);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observerRef.current?.disconnect();
|
||||
};
|
||||
}, [files]); // re-create observer when file list changes
|
||||
|
||||
// Register wrapper ref — observes the div, registers with parent
|
||||
const registerWrapper = useCallback(
|
||||
(filePath: string, el: HTMLDivElement | null) => {
|
||||
if (el) {
|
||||
wrapperRefs.current.set(filePath, el);
|
||||
observerRef.current?.observe(el);
|
||||
} else {
|
||||
const prev = wrapperRefs.current.get(filePath);
|
||||
if (prev) observerRef.current?.unobserve(prev);
|
||||
wrapperRefs.current.delete(filePath);
|
||||
}
|
||||
onRegisterRef?.(filePath, el);
|
||||
},
|
||||
[onRegisterRef],
|
||||
);
|
||||
|
||||
// expandAll batch loading
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
||||
const queryClient = useQueryClient();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
useEffect(() => {
|
||||
if (!expandAll || files.length === 0) return;
|
||||
|
||||
const BATCH = 10;
|
||||
let cancelled = false;
|
||||
|
||||
async function batchExpand() {
|
||||
const chunks: (FileDiff | FileDiffDetail)[][] = [];
|
||||
for (let i = 0; i < files.length; i += BATCH) {
|
||||
chunks.push(files.slice(i, i + BATCH));
|
||||
}
|
||||
|
||||
for (const chunk of chunks) {
|
||||
if (cancelled) break;
|
||||
// Mark this batch as expanded (triggers FileCard renders + queries)
|
||||
setExpandedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const f of chunk) {
|
||||
if (f.status !== 'binary') next.add(f.newPath);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// Eagerly prefetch via React Query to saturate network
|
||||
await Promise.all(
|
||||
chunk
|
||||
.filter((f) => f.status !== 'binary' && !('hunks' in f))
|
||||
.map((f) =>
|
||||
utils.getFileDiff
|
||||
.fetch({ phaseId, filePath: encodeURIComponent(f.newPath) })
|
||||
.catch(() => null), // swallow per-file errors; FileCard shows its own error state
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
batchExpand();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [expandAll]); // only re-run when expandAll toggles
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only on expandAll
|
||||
|
||||
// Suppress unused variable warning — used only to force re-render on visibility change
|
||||
void visibilityVersion;
|
||||
void queryClient; // imported for type alignment; actual prefetch goes through trpc utils
|
||||
|
||||
const isSingleFile = files.length === 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{files.map((file) => (
|
||||
<div key={file.newPath} ref={(el) => onRegisterRef?.(file.newPath, el)}>
|
||||
<FileCard
|
||||
file={file}
|
||||
comments={comments.filter((c) => c.filePath === file.newPath)}
|
||||
onAddComment={onAddComment}
|
||||
onResolveComment={onResolveComment}
|
||||
onUnresolveComment={onUnresolveComment}
|
||||
onReplyComment={onReplyComment}
|
||||
onEditComment={onEditComment}
|
||||
isViewed={viewedFiles?.has(file.newPath) ?? false}
|
||||
onToggleViewed={() => onToggleViewed?.(file.newPath)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{files.map((file) => {
|
||||
const isVisible = isSingleFile || visibleFiles.current.has(file.newPath);
|
||||
const isExpandedOverride = expandedFiles.has(file.newPath) ? true : undefined;
|
||||
return (
|
||||
<div
|
||||
key={file.newPath}
|
||||
ref={(el) => registerWrapper(file.newPath, el)}
|
||||
data-file-path={file.newPath}
|
||||
>
|
||||
{isVisible ? (
|
||||
<FileCard
|
||||
file={file as FileDiff}
|
||||
detail={'hunks' in file ? (file as FileDiffDetail) : undefined}
|
||||
phaseId={phaseId}
|
||||
commitMode={commitMode}
|
||||
commentsByLine={getFileCommentMap(commentsByLine, file.newPath)}
|
||||
isExpandedOverride={isExpandedOverride}
|
||||
onAddComment={onAddComment}
|
||||
onResolveComment={onResolveComment}
|
||||
onUnresolveComment={onUnresolveComment}
|
||||
onReplyComment={onReplyComment}
|
||||
onEditComment={onEditComment}
|
||||
isViewed={viewedFiles?.has(file.newPath) ?? false}
|
||||
onToggleViewed={() => onToggleViewed?.(file.newPath)}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ height: '48px' }} aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
270
apps/web/src/components/review/FileCard.test.tsx
Normal file
270
apps/web/src/components/review/FileCard.test.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
// @vitest-environment happy-dom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
import { FileCard } from "./FileCard";
|
||||
import type { FileDiff, FileDiffDetail } from "./types";
|
||||
|
||||
// ── Module mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("./HunkRows", () => ({
|
||||
HunkRows: ({ hunk }: { hunk: { header: string } }) => (
|
||||
<tr data-testid="hunk-row">
|
||||
<td>{hunk.header}</td>
|
||||
</tr>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./use-syntax-highlight", () => ({
|
||||
useHighlightedFile: () => null,
|
||||
}));
|
||||
|
||||
// Hoist mocks so they can be referenced in vi.mock factories
|
||||
const { mockGetFileDiff, mockParseUnifiedDiff } = vi.hoisted(() => ({
|
||||
mockGetFileDiff: vi.fn(),
|
||||
mockParseUnifiedDiff: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/trpc", () => ({
|
||||
trpc: {
|
||||
getFileDiff: {
|
||||
useQuery: (
|
||||
input: unknown,
|
||||
opts: { enabled: boolean; staleTime?: number },
|
||||
) => mockGetFileDiff(input, opts),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./parse-diff", () => ({
|
||||
parseUnifiedDiff: (rawDiff: string) => mockParseUnifiedDiff(rawDiff),
|
||||
}));
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeFile(overrides: Partial<FileDiff> = {}): FileDiff {
|
||||
return {
|
||||
oldPath: "src/foo.ts",
|
||||
newPath: "src/foo.ts",
|
||||
status: "modified",
|
||||
additions: 10,
|
||||
deletions: 5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
phaseId: "phase-1",
|
||||
commitMode: false,
|
||||
commentsByLine: new Map(),
|
||||
onAddComment: vi.fn(),
|
||||
onResolveComment: vi.fn(),
|
||||
onUnresolveComment: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetFileDiff.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
// Default: return empty parse result
|
||||
mockParseUnifiedDiff.mockReturnValue([]);
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("FileCard", () => {
|
||||
it("starts collapsed and does not enable getFileDiff query", () => {
|
||||
render(<FileCard file={makeFile()} {...defaultProps} />);
|
||||
|
||||
// Query must be called with enabled: false while card is collapsed
|
||||
expect(mockGetFileDiff).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: encodeURIComponent("src/foo.ts"),
|
||||
}),
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
|
||||
// No hunk rows rendered in the collapsed state
|
||||
expect(screen.queryByTestId("hunk-row")).toBeNull();
|
||||
});
|
||||
|
||||
it("enables query and shows loading spinner when expanded", () => {
|
||||
mockGetFileDiff.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
render(<FileCard file={makeFile()} {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
// After expanding, query should be called with enabled: true
|
||||
expect(mockGetFileDiff).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ enabled: true }),
|
||||
);
|
||||
|
||||
// Loading spinner should be visible
|
||||
expect(screen.getByText(/Loading diff/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders HunkRows when query succeeds", async () => {
|
||||
mockGetFileDiff.mockReturnValue({
|
||||
data: {
|
||||
binary: false,
|
||||
rawDiff:
|
||||
"diff --git a/src/foo.ts b/src/foo.ts\n@@ -1,3 +1,3 @@\n context\n",
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
mockParseUnifiedDiff.mockReturnValue([
|
||||
{
|
||||
oldPath: "src/foo.ts",
|
||||
newPath: "src/foo.ts",
|
||||
status: "modified",
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
hunks: [
|
||||
{
|
||||
header: "@@ -1,3 +1,3 @@",
|
||||
oldStart: 1,
|
||||
oldCount: 3,
|
||||
newStart: 1,
|
||||
newCount: 3,
|
||||
lines: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
render(<FileCard file={makeFile()} {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("hunk-row")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error state with Retry button; clicking retry calls refetch", () => {
|
||||
const refetch = vi.fn();
|
||||
mockGetFileDiff.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
refetch,
|
||||
});
|
||||
|
||||
render(<FileCard file={makeFile()} {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
expect(screen.getByText(/Failed to load diff/i)).toBeInTheDocument();
|
||||
const retryBtn = screen.getByRole("button", { name: /retry/i });
|
||||
fireEvent.click(retryBtn);
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows binary message on expand and does not enable getFileDiff query", () => {
|
||||
render(<FileCard file={makeFile({ status: "binary" })} {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
expect(screen.getByText(/Binary file/i)).toBeInTheDocument();
|
||||
|
||||
// Query must never be enabled for binary files
|
||||
expect(mockGetFileDiff).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows No content changes when parsed hunks array is empty", async () => {
|
||||
mockGetFileDiff.mockReturnValue({
|
||||
data: {
|
||||
binary: false,
|
||||
rawDiff: "diff --git a/src/foo.ts b/src/foo.ts\nsome content\n",
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
mockParseUnifiedDiff.mockReturnValue([
|
||||
{
|
||||
oldPath: "src/foo.ts",
|
||||
newPath: "src/foo.ts",
|
||||
status: "modified",
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
hunks: [],
|
||||
},
|
||||
]);
|
||||
|
||||
render(<FileCard file={makeFile()} {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No content changes/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders pre-parsed hunks from detail prop without fetching", () => {
|
||||
const detail: FileDiffDetail = {
|
||||
oldPath: "src/foo.ts",
|
||||
newPath: "src/foo.ts",
|
||||
status: "modified",
|
||||
additions: 5,
|
||||
deletions: 2,
|
||||
hunks: [
|
||||
{
|
||||
header: "@@ -1 +1 @@",
|
||||
oldStart: 1,
|
||||
oldCount: 1,
|
||||
newStart: 1,
|
||||
newCount: 1,
|
||||
lines: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<FileCard file={makeFile()} detail={detail} {...defaultProps} />);
|
||||
|
||||
// Should start expanded because detail prop is provided
|
||||
expect(screen.getByTestId("hunk-row")).toBeInTheDocument();
|
||||
|
||||
// Query must not be enabled when detail prop is present
|
||||
expect(mockGetFileDiff).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not refetch when collapsing and re-expanding", () => {
|
||||
// Simulate data already available (as if previously fetched and cached)
|
||||
mockGetFileDiff.mockReturnValue({
|
||||
data: { binary: false, rawDiff: "" },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
render(<FileCard file={makeFile()} {...defaultProps} />);
|
||||
const headerBtn = screen.getByRole("button");
|
||||
|
||||
// Expand: query enabled, data shown immediately (no loading)
|
||||
fireEvent.click(headerBtn);
|
||||
expect(screen.queryByText(/Loading diff/i)).toBeNull();
|
||||
|
||||
// Collapse
|
||||
fireEvent.click(headerBtn);
|
||||
|
||||
// Re-expand: should not enter loading state (data still available)
|
||||
fireEvent.click(headerBtn);
|
||||
expect(screen.queryByText(/Loading diff/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,16 +6,16 @@ import {
|
||||
Minus,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { FileDiff, FileChangeType, DiffLine, ReviewComment } from "./types";
|
||||
import type { FileDiff, FileDiffDetail, DiffLine, ReviewComment } from "./types";
|
||||
import { HunkRows } from "./HunkRows";
|
||||
import { useHighlightedFile } from "./use-syntax-highlight";
|
||||
import { parseUnifiedDiff } from "./parse-diff";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
const changeTypeBadge: Record<
|
||||
FileChangeType,
|
||||
{ label: string; classes: string } | null
|
||||
> = {
|
||||
const statusBadge: Record<FileDiff['status'], { label: string; classes: string } | null> = {
|
||||
added: {
|
||||
label: "NEW",
|
||||
classes:
|
||||
@@ -32,18 +32,27 @@ const changeTypeBadge: Record<
|
||||
"bg-status-active-bg text-status-active-fg border-status-active-border",
|
||||
},
|
||||
modified: null,
|
||||
binary: {
|
||||
label: "BINARY",
|
||||
classes: "bg-muted text-muted-foreground border-border",
|
||||
},
|
||||
};
|
||||
|
||||
const leftBorderClass: Record<FileChangeType, string> = {
|
||||
const leftBorderClass: Record<FileDiff['status'], string> = {
|
||||
added: "border-l-2 border-l-status-success-fg",
|
||||
deleted: "border-l-2 border-l-status-error-fg",
|
||||
renamed: "border-l-2 border-l-status-active-fg",
|
||||
modified: "border-l-2 border-l-primary/40",
|
||||
binary: "border-l-2 border-l-primary/40",
|
||||
};
|
||||
|
||||
interface FileCardProps {
|
||||
file: FileDiff;
|
||||
comments: ReviewComment[];
|
||||
detail?: FileDiffDetail;
|
||||
phaseId: string;
|
||||
commitMode: boolean;
|
||||
commentsByLine: Map<string, ReviewComment[]>;
|
||||
isExpandedOverride?: boolean;
|
||||
onAddComment: (
|
||||
filePath: string,
|
||||
lineNumber: number,
|
||||
@@ -60,7 +69,11 @@ interface FileCardProps {
|
||||
|
||||
export function FileCard({
|
||||
file,
|
||||
comments,
|
||||
detail,
|
||||
phaseId,
|
||||
commitMode,
|
||||
commentsByLine,
|
||||
isExpandedOverride,
|
||||
onAddComment,
|
||||
onResolveComment,
|
||||
onUnresolveComment,
|
||||
@@ -69,26 +82,65 @@ export function FileCard({
|
||||
isViewed = false,
|
||||
onToggleViewed = () => {},
|
||||
}: FileCardProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const commentCount = comments.length;
|
||||
const badge = changeTypeBadge[file.changeType];
|
||||
// Uncontrolled expand for normal file clicks.
|
||||
// Start expanded if detail prop is provided (commit mode).
|
||||
const [isExpandedLocal, setIsExpandedLocal] = useState(() => !!detail);
|
||||
|
||||
// Flatten all hunk lines for syntax highlighting
|
||||
const allLines = useMemo(
|
||||
() => file.hunks.flatMap((h) => h.lines),
|
||||
[file.hunks],
|
||||
// Merge with override from DiffViewer expandAll
|
||||
const isExpanded = isExpandedOverride ?? isExpandedLocal;
|
||||
|
||||
const fileDiffQuery = trpc.getFileDiff.useQuery(
|
||||
{ phaseId, filePath: encodeURIComponent(file.newPath) },
|
||||
{
|
||||
enabled: isExpanded && !commitMode && file.status !== 'binary' && !detail,
|
||||
staleTime: Infinity,
|
||||
},
|
||||
);
|
||||
|
||||
// Compute hunks from query data (phase mode)
|
||||
const parsedHunks = useMemo(() => {
|
||||
if (!fileDiffQuery.data?.rawDiff) return null;
|
||||
const parsed = parseUnifiedDiff(fileDiffQuery.data.rawDiff);
|
||||
return parsed[0] ?? null;
|
||||
}, [fileDiffQuery.data]);
|
||||
|
||||
// Collect all lines for syntax highlighting
|
||||
const allLines = useMemo(() => {
|
||||
if (detail) return detail.hunks.flatMap((h) => h.lines);
|
||||
if (parsedHunks) return parsedHunks.hunks.flatMap((h) => h.lines);
|
||||
return [];
|
||||
}, [detail, parsedHunks]);
|
||||
|
||||
const tokenMap = useHighlightedFile(file.newPath, allLines);
|
||||
|
||||
const commentCount = useMemo(() => {
|
||||
let count = 0;
|
||||
for (const [key, arr] of commentsByLine) {
|
||||
if (key.startsWith(`${file.newPath}:`)) count += arr.length;
|
||||
}
|
||||
return count;
|
||||
}, [commentsByLine, file.newPath]);
|
||||
|
||||
const badge = statusBadge[file.status];
|
||||
|
||||
const handlers = {
|
||||
onAddComment,
|
||||
onResolveComment,
|
||||
onUnresolveComment,
|
||||
onReplyComment,
|
||||
onEditComment,
|
||||
tokenMap,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border overflow-clip">
|
||||
{/* File header — sticky so it stays visible when scrolling */}
|
||||
{/* File header */}
|
||||
<button
|
||||
className={`sticky z-10 flex w-full items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/90 text-left text-sm font-mono transition-colors ${leftBorderClass[file.changeType]}`}
|
||||
className={`sticky z-10 flex w-full items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/90 text-left text-sm font-mono transition-colors ${leftBorderClass[file.status]}`}
|
||||
style={{ top: 'var(--review-header-h, 0px)' }}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
onClick={() => setIsExpandedLocal(!isExpandedLocal)}
|
||||
>
|
||||
{expanded ? (
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
@@ -149,26 +201,63 @@ export function FileCard({
|
||||
</button>
|
||||
|
||||
{/* Diff content */}
|
||||
{expanded && (
|
||||
{isExpanded && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs font-mono border-collapse">
|
||||
<tbody>
|
||||
{file.hunks.map((hunk, hi) => (
|
||||
<HunkRows
|
||||
key={hi}
|
||||
hunk={hunk}
|
||||
filePath={file.newPath}
|
||||
comments={comments}
|
||||
onAddComment={onAddComment}
|
||||
onResolveComment={onResolveComment}
|
||||
onUnresolveComment={onUnresolveComment}
|
||||
onReplyComment={onReplyComment}
|
||||
onEditComment={onEditComment}
|
||||
tokenMap={tokenMap}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{detail ? (
|
||||
// Commit mode: pre-parsed hunks from detail prop
|
||||
detail.hunks.length === 0 ? (
|
||||
<div className="px-4 py-3 text-xs text-muted-foreground">No content changes</div>
|
||||
) : (
|
||||
<table className="w-full text-xs font-mono border-collapse">
|
||||
<tbody>
|
||||
{detail.hunks.map((hunk, hi) => (
|
||||
<HunkRows
|
||||
key={hi}
|
||||
hunk={hunk}
|
||||
filePath={file.newPath}
|
||||
commentsByLine={commentsByLine}
|
||||
{...handlers}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
) : file.status === 'binary' ? (
|
||||
<div className="px-4 py-3 text-xs text-muted-foreground">Binary file — diff not shown</div>
|
||||
) : fileDiffQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 px-4 py-3 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading diff…
|
||||
</div>
|
||||
) : fileDiffQuery.isError ? (
|
||||
<div className="flex items-center gap-2 px-4 py-3 text-xs text-destructive">
|
||||
Failed to load diff.
|
||||
<button
|
||||
className="underline hover:no-underline"
|
||||
onClick={() => fileDiffQuery.refetch()}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : fileDiffQuery.data ? (
|
||||
!parsedHunks || parsedHunks.hunks.length === 0 ? (
|
||||
<div className="px-4 py-3 text-xs text-muted-foreground">No content changes</div>
|
||||
) : (
|
||||
<table className="w-full text-xs font-mono border-collapse">
|
||||
<tbody>
|
||||
{parsedHunks.hunks.map((hunk, hi) => (
|
||||
<HunkRows
|
||||
key={hi}
|
||||
hunk={hunk}
|
||||
filePath={file.newPath}
|
||||
commentsByLine={commentsByLine}
|
||||
{...handlers}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { LineTokenMap } from "./use-syntax-highlight";
|
||||
interface HunkRowsProps {
|
||||
hunk: { header: string; lines: DiffLine[] };
|
||||
filePath: string;
|
||||
comments: ReviewComment[];
|
||||
commentsByLine: Map<string, ReviewComment[]>;
|
||||
onAddComment: (
|
||||
filePath: string,
|
||||
lineNumber: number,
|
||||
@@ -23,7 +23,7 @@ interface HunkRowsProps {
|
||||
export function HunkRows({
|
||||
hunk,
|
||||
filePath,
|
||||
comments,
|
||||
commentsByLine,
|
||||
onAddComment,
|
||||
onResolveComment,
|
||||
onUnresolveComment,
|
||||
@@ -81,9 +81,9 @@ export function HunkRows({
|
||||
|
||||
{hunk.lines.map((line, li) => {
|
||||
const lineKey = line.newLineNumber ?? line.oldLineNumber ?? li;
|
||||
const lineComments = comments.filter(
|
||||
(c) => c.lineNumber === lineKey && c.lineType === line.type,
|
||||
);
|
||||
// O(1) map lookup — replaces the previous O(n) filter
|
||||
const lineComments =
|
||||
commentsByLine.get(`${filePath}:${lineKey}:${line.type}`) ?? [];
|
||||
const isCommenting =
|
||||
commentingLine?.lineNumber === lineKey &&
|
||||
commentingLine?.lineType === line.type;
|
||||
|
||||
@@ -308,7 +308,7 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
||||
) : (
|
||||
<DiffViewer
|
||||
files={files}
|
||||
comments={[]}
|
||||
commentsByLine={new Map()}
|
||||
onAddComment={() => {}}
|
||||
onResolveComment={() => {}}
|
||||
onUnresolveComment={() => {}}
|
||||
|
||||
@@ -42,6 +42,9 @@ interface ReviewHeaderProps {
|
||||
preview: PreviewState | null;
|
||||
viewedCount?: number;
|
||||
totalCount?: number;
|
||||
totalAdditions?: number;
|
||||
totalDeletions?: number;
|
||||
onExpandAll?: () => void;
|
||||
}
|
||||
|
||||
export function ReviewHeader({
|
||||
@@ -62,9 +65,12 @@ export function ReviewHeader({
|
||||
preview,
|
||||
viewedCount,
|
||||
totalCount,
|
||||
totalAdditions: totalAdditionsProp,
|
||||
totalDeletions: totalDeletionsProp,
|
||||
onExpandAll,
|
||||
}: ReviewHeaderProps) {
|
||||
const totalAdditions = files.reduce((s, f) => s + f.additions, 0);
|
||||
const totalDeletions = files.reduce((s, f) => s + f.deletions, 0);
|
||||
const totalAdditions = totalAdditionsProp ?? files.reduce((s, f) => s + f.additions, 0);
|
||||
const totalDeletions = totalDeletionsProp ?? files.reduce((s, f) => s + f.deletions, 0);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [showRequestConfirm, setShowRequestConfirm] = useState(false);
|
||||
const confirmRef = useRef<HTMLDivElement>(null);
|
||||
@@ -186,6 +192,16 @@ export function ReviewHeader({
|
||||
|
||||
{/* Right: preview + actions */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{onExpandAll && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onExpandAll}
|
||||
className="h-7 text-xs px-2 text-muted-foreground"
|
||||
>
|
||||
Expand all
|
||||
</Button>
|
||||
)}
|
||||
{/* Preview controls */}
|
||||
{preview && <PreviewControls preview={preview} />}
|
||||
|
||||
|
||||
193
apps/web/src/components/review/ReviewSidebar.test.tsx
Normal file
193
apps/web/src/components/review/ReviewSidebar.test.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { ReviewSidebar } from './ReviewSidebar';
|
||||
import type { FileDiff, ReviewComment, CommitInfo } from './types';
|
||||
|
||||
// Mock ResizeObserver — not provided by happy-dom.
|
||||
// react-window 2.x uses `new ResizeObserver()` internally.
|
||||
class MockResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', MockResizeObserver);
|
||||
|
||||
// Mock react-window to avoid ESM/CJS duplicate-React-instance errors in Vitest.
|
||||
// The mock renders only the first 15 rows, simulating windowed rendering.
|
||||
// It also exposes a `listRef`-compatible imperative handle so scroll-save/restore logic runs.
|
||||
vi.mock('react-window', () => ({
|
||||
List: vi.fn(({ rowComponent: RowComponent, rowCount, rowProps, listRef }: any) => {
|
||||
// Expose the imperative API via the ref (synchronous assignment is safe in tests).
|
||||
if (listRef && typeof listRef === 'object' && 'current' in listRef) {
|
||||
listRef.current = { element: { scrollTop: 0 }, scrollToRow: vi.fn() };
|
||||
}
|
||||
const renderCount = Math.min(rowCount ?? 0, 15);
|
||||
return (
|
||||
<div data-testid="virtual-list">
|
||||
{Array.from({ length: renderCount }, (_, i) => (
|
||||
<RowComponent key={i} index={i} style={{}} ariaAttributes={{}} {...rowProps} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeFile(path: string): FileDiff {
|
||||
return {
|
||||
oldPath: path,
|
||||
newPath: path,
|
||||
hunks: [],
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
changeType: 'modified',
|
||||
};
|
||||
}
|
||||
|
||||
function makeFiles(count: number, prefix = 'src/components/'): FileDiff[] {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
makeFile(`${prefix}file${String(i).padStart(4, '0')}.ts`),
|
||||
);
|
||||
}
|
||||
|
||||
const NO_COMMENTS: ReviewComment[] = [];
|
||||
const NO_COMMITS: CommitInfo[] = [];
|
||||
|
||||
function renderSidebar(files: FileDiff[]) {
|
||||
return render(
|
||||
<ReviewSidebar
|
||||
files={files}
|
||||
comments={NO_COMMENTS}
|
||||
onFileClick={vi.fn()}
|
||||
selectedCommit={null}
|
||||
activeFiles={files}
|
||||
commits={NO_COMMITS}
|
||||
onSelectCommit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ReviewSidebar FilesView virtualization', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
// 1. Virtual list NOT used for ≤50 files (fallback path)
|
||||
it('does not use virtual list when files count is ≤50', () => {
|
||||
renderSidebar(makeFiles(10));
|
||||
|
||||
expect(screen.queryByTestId('virtual-list')).not.toBeInTheDocument();
|
||||
// All 10 file rows are in the DOM directly
|
||||
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBe(10);
|
||||
});
|
||||
|
||||
// 2. Virtual list IS used for >50 files (virtualized path)
|
||||
it('uses virtual list when files count is >50', () => {
|
||||
renderSidebar(makeFiles(1000));
|
||||
|
||||
expect(screen.getByTestId('virtual-list')).toBeInTheDocument();
|
||||
// Mock renders only 15 rows — far fewer than 1000
|
||||
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBeLessThan(50);
|
||||
});
|
||||
|
||||
// 3. Directory collapse removes file rows from the virtual list
|
||||
it('removes file rows from virtual list when directory is collapsed', async () => {
|
||||
// 100 files all in "src/" — produces 101 rows (1 dir-header + 100 files), which is >50
|
||||
const files = Array.from({ length: 100 }, (_, i) => makeFile(`src/file${i}.ts`));
|
||||
renderSidebar(files);
|
||||
|
||||
expect(screen.getByTestId('virtual-list')).toBeInTheDocument();
|
||||
|
||||
const dirHeader = screen.getByRole('button', { name: /src\// });
|
||||
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(dirHeader);
|
||||
});
|
||||
|
||||
// After collapse: only the dir-header row remains in the virtual list
|
||||
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBe(0);
|
||||
expect(document.querySelectorAll('[data-testid="dir-header"]').length).toBe(1);
|
||||
});
|
||||
|
||||
// 3a. Expanding a collapsed directory restores file rows
|
||||
it('restores file rows when a collapsed directory is expanded again', async () => {
|
||||
const files = makeFiles(60, 'src/components/');
|
||||
renderSidebar(files);
|
||||
|
||||
const dirHeader = screen.getByRole('button', { name: /src\/components\// });
|
||||
|
||||
// Collapse
|
||||
await act(async () => {
|
||||
fireEvent.click(dirHeader);
|
||||
});
|
||||
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBe(0);
|
||||
|
||||
// Expand again
|
||||
const freshDirHeader = screen.getByRole('button', { name: /src\/components\// });
|
||||
await act(async () => {
|
||||
fireEvent.click(freshDirHeader);
|
||||
});
|
||||
|
||||
// File rows are back (virtual list renders up to 15)
|
||||
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBeGreaterThan(0);
|
||||
expect(document.querySelectorAll('[data-testid="dir-header"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// 4. Scroll position saved and restored on Files ↔ Commits tab switch
|
||||
it('restores file rows when returning to Files tab after switching to Commits tab', async () => {
|
||||
renderSidebar(makeFiles(200));
|
||||
|
||||
// Files tab is default — file rows are visible
|
||||
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBeGreaterThan(0);
|
||||
|
||||
// Switch to Commits tab — FilesView unmounts (scroll offset is saved)
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTitle('Commits'));
|
||||
});
|
||||
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBe(0);
|
||||
|
||||
// Switch back to Files tab — FilesView remounts (scroll offset is restored)
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTitle('Files'));
|
||||
});
|
||||
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// 5. Clicking a file calls onFileClick with the correct path
|
||||
it('calls onFileClick when a file row is clicked', () => {
|
||||
const onFileClick = vi.fn();
|
||||
const files = makeFiles(5);
|
||||
render(
|
||||
<ReviewSidebar
|
||||
files={files}
|
||||
comments={NO_COMMENTS}
|
||||
onFileClick={onFileClick}
|
||||
selectedCommit={null}
|
||||
activeFiles={files}
|
||||
commits={NO_COMMITS}
|
||||
onSelectCommit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const fileButtons = document.querySelectorAll('[data-testid="file-row"]');
|
||||
expect(fileButtons.length).toBeGreaterThan(0);
|
||||
fireEvent.click(fileButtons[0]);
|
||||
|
||||
// First file after alphabetical sort within the directory
|
||||
expect(onFileClick).toHaveBeenCalledWith(files[0].newPath);
|
||||
});
|
||||
|
||||
// 6. Root-level files (no subdirectory) render without a directory header
|
||||
it('root-level files render without a directory header', () => {
|
||||
const files = makeFiles(10, ''); // no prefix → root-level files
|
||||
renderSidebar(files);
|
||||
|
||||
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBe(10);
|
||||
expect(document.querySelectorAll('[data-testid="dir-header"]').length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, useRef, useEffect, useCallback } from "react";
|
||||
// Using react-window 2.x (installed version). The task spec was written for react-window 1.x
|
||||
// (VariableSizeList API). react-window 2.x provides a `List` component with a different but
|
||||
// equivalent API: it handles ResizeObserver internally (no explicit height/width props needed),
|
||||
// uses `rowComponent`/`rowProps` for rendering, and exposes `scrollToRow` via `listRef`.
|
||||
import { List } from "react-window";
|
||||
import type { RowComponentProps, ListImperativeAPI } from "react-window";
|
||||
import {
|
||||
MessageSquare,
|
||||
FileCode,
|
||||
FolderOpen,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Minus,
|
||||
Circle,
|
||||
@@ -38,6 +45,8 @@ export function ReviewSidebar({
|
||||
viewedFiles = new Set(),
|
||||
}: ReviewSidebarProps) {
|
||||
const [view, setView] = useState<SidebarView>("files");
|
||||
// Persist Files-tab scroll offset across Files ↔ Commits switches
|
||||
const filesScrollOffsetRef = useRef<number>(0);
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
@@ -58,8 +67,8 @@ export function ReviewSidebar({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content panel */}
|
||||
<div className="flex-1 min-w-0 overflow-y-auto p-4">
|
||||
{/* Content panel — flex column so FilesView can stretch and manage its own scroll */}
|
||||
<div className="flex-1 min-w-0 flex flex-col min-h-0">
|
||||
{view === "files" ? (
|
||||
<FilesView
|
||||
files={files}
|
||||
@@ -69,13 +78,16 @@ export function ReviewSidebar({
|
||||
selectedCommit={selectedCommit}
|
||||
activeFiles={activeFiles}
|
||||
viewedFiles={viewedFiles}
|
||||
scrollOffsetRef={filesScrollOffsetRef}
|
||||
/>
|
||||
) : (
|
||||
<CommitsView
|
||||
commits={commits}
|
||||
selectedCommit={selectedCommit}
|
||||
onSelectCommit={onSelectCommit}
|
||||
/>
|
||||
<div className="overflow-y-auto p-4 flex-1">
|
||||
<CommitsView
|
||||
commits={commits}
|
||||
selectedCommit={selectedCommit}
|
||||
onSelectCommit={onSelectCommit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,6 +183,109 @@ const changeTypeDotColor: Record<string, string> = {
|
||||
renamed: "bg-status-active-fg",
|
||||
};
|
||||
|
||||
// ─── Row type for virtualized list ───
|
||||
|
||||
type Row =
|
||||
| { kind: "dir-header"; dirName: string; fileCount: number; isCollapsed: boolean }
|
||||
| { kind: "file"; file: FileDiff; dirName: string; isViewed: boolean; commentCount: number };
|
||||
|
||||
// Item heights: dir-header ≈ 32px (py-0.5 + icon), file row ≈ 40px (py-1 + text)
|
||||
const DIR_HEADER_HEIGHT = 32;
|
||||
const FILE_ROW_HEIGHT = 40;
|
||||
|
||||
// ─── Virtualized row component (must be stable — defined outside FilesView) ───
|
||||
|
||||
type VirtualRowProps = {
|
||||
rows: Row[];
|
||||
selectedCommit: string | null;
|
||||
activeFilePaths: Set<string>;
|
||||
onFileClick: (filePath: string) => void;
|
||||
onToggleDir: (dirName: string) => void;
|
||||
};
|
||||
|
||||
function VirtualRowItem({
|
||||
index,
|
||||
style,
|
||||
rows,
|
||||
selectedCommit,
|
||||
activeFilePaths,
|
||||
onFileClick,
|
||||
onToggleDir,
|
||||
}: RowComponentProps<VirtualRowProps>) {
|
||||
const row = rows[index];
|
||||
if (!row) return null;
|
||||
|
||||
if (row.kind === "dir-header") {
|
||||
return (
|
||||
<button
|
||||
data-testid="dir-header"
|
||||
style={style}
|
||||
className="flex w-full items-center gap-1 text-[10px] font-mono text-muted-foreground/70 px-2 hover:bg-accent/30 transition-colors"
|
||||
onClick={() => onToggleDir(row.dirName)}
|
||||
title={row.isCollapsed ? "Expand directory" : "Collapse directory"}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-3 w-3 shrink-0 transition-transform ${row.isCollapsed ? "" : "rotate-90"}`}
|
||||
/>
|
||||
<FolderOpen className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{row.dirName}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// kind === "file"
|
||||
const { file, dirName, isViewed, commentCount } = row;
|
||||
const isInView = activeFilePaths.has(file.newPath);
|
||||
const dimmed = selectedCommit && !isInView;
|
||||
const dotColor = changeTypeDotColor[file.changeType];
|
||||
|
||||
return (
|
||||
<button
|
||||
data-testid="file-row"
|
||||
style={style}
|
||||
className={`
|
||||
flex w-full items-center gap-1.5 rounded py-1 text-left text-[11px]
|
||||
hover:bg-accent/50 transition-colors group
|
||||
${dirName ? "pl-4 pr-2" : "px-2"}
|
||||
${dimmed ? "opacity-35" : ""}
|
||||
`}
|
||||
onClick={() => onFileClick(file.newPath)}
|
||||
>
|
||||
{isViewed ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-status-success-fg shrink-0" />
|
||||
) : (
|
||||
<FileCode className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
{dotColor && (
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${dotColor}`} />
|
||||
)}
|
||||
<span className="truncate flex-1 font-mono">
|
||||
{getFileName(file.newPath)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
{commentCount > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-muted-foreground">
|
||||
<MessageSquare className="h-2.5 w-2.5" />
|
||||
{commentCount}
|
||||
</span>
|
||||
)}
|
||||
{file.additions > 0 && (
|
||||
<span className="text-diff-add-fg text-[10px]">
|
||||
<Plus className="h-2.5 w-2.5 inline" />
|
||||
{file.additions}
|
||||
</span>
|
||||
)}
|
||||
{file.deletions > 0 && (
|
||||
<span className="text-diff-remove-fg text-[10px]">
|
||||
<Minus className="h-2.5 w-2.5 inline" />
|
||||
{file.deletions}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function FilesView({
|
||||
files,
|
||||
comments,
|
||||
@@ -179,6 +294,7 @@ function FilesView({
|
||||
selectedCommit,
|
||||
activeFiles,
|
||||
viewedFiles,
|
||||
scrollOffsetRef,
|
||||
}: {
|
||||
files: FileDiff[];
|
||||
comments: ReviewComment[];
|
||||
@@ -187,10 +303,14 @@ function FilesView({
|
||||
selectedCommit: string | null;
|
||||
activeFiles: FileDiff[];
|
||||
viewedFiles: Set<string>;
|
||||
scrollOffsetRef: React.MutableRefObject<number>;
|
||||
}) {
|
||||
const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length;
|
||||
const resolvedCount = comments.filter((c) => c.resolved && !c.parentCommentId).length;
|
||||
const activeFilePaths = new Set(activeFiles.map((f) => f.newPath));
|
||||
const activeFilePaths = useMemo(
|
||||
() => new Set(activeFiles.map((f) => f.newPath)),
|
||||
[activeFiles],
|
||||
);
|
||||
|
||||
const directoryGroups = useMemo(() => groupFilesByDirectory(files), [files]);
|
||||
|
||||
@@ -198,169 +318,308 @@ function FilesView({
|
||||
const totalCount = files.length;
|
||||
const progressPercent = totalCount > 0 ? (viewedCount / totalCount) * 100 : 0;
|
||||
|
||||
// ─── Collapse state ───
|
||||
const [collapsedDirs, setCollapsedDirs] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleDir = useCallback((dirName: string) => {
|
||||
setCollapsedDirs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(dirName)) next.delete(dirName);
|
||||
else next.add(dirName);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ─── Flat row list for virtualization ───
|
||||
const rows = useMemo<Row[]>(() => {
|
||||
const result: Row[] = [];
|
||||
for (const group of directoryGroups) {
|
||||
const isCollapsed = collapsedDirs.has(group.directory);
|
||||
// Root-level files (directory === "") get no dir-header, preserving existing behavior
|
||||
if (group.directory) {
|
||||
result.push({
|
||||
kind: "dir-header",
|
||||
dirName: group.directory,
|
||||
fileCount: group.files.length,
|
||||
isCollapsed,
|
||||
});
|
||||
}
|
||||
if (!isCollapsed) {
|
||||
for (const file of group.files) {
|
||||
const commentCount = comments.filter(
|
||||
(c) => c.filePath === file.newPath && !c.parentCommentId,
|
||||
).length;
|
||||
result.push({
|
||||
kind: "file",
|
||||
file,
|
||||
dirName: group.directory,
|
||||
isViewed: viewedFiles.has(file.newPath),
|
||||
commentCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [directoryGroups, collapsedDirs, comments, viewedFiles]);
|
||||
|
||||
const isVirtualized = rows.length > 50;
|
||||
|
||||
// ─── react-window 2.x imperative ref ───
|
||||
const listRef = useRef<ListImperativeAPI | null>(null);
|
||||
// Fallback container ref for non-virtualized path
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Restore scroll position on mount (both paths)
|
||||
useEffect(() => {
|
||||
const offset = scrollOffsetRef.current;
|
||||
if (!offset) return;
|
||||
if (isVirtualized) {
|
||||
// react-window 2.x: scroll via the outermost DOM element
|
||||
const el = listRef.current?.element;
|
||||
if (el) el.scrollTop = offset;
|
||||
} else if (containerRef.current) {
|
||||
containerRef.current.scrollTop = offset;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // only on mount
|
||||
|
||||
// Save scroll position on unmount (both paths)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isVirtualized) {
|
||||
scrollOffsetRef.current = listRef.current?.element?.scrollTop ?? 0;
|
||||
} else {
|
||||
scrollOffsetRef.current = containerRef.current?.scrollTop ?? 0;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isVirtualized]);
|
||||
|
||||
// Row height function for react-window 2.x List
|
||||
const rowHeight = useCallback(
|
||||
(index: number) => (rows[index]?.kind === "dir-header" ? DIR_HEADER_HEIGHT : FILE_ROW_HEIGHT),
|
||||
[rows],
|
||||
);
|
||||
|
||||
// Handle file click: call onFileClick and scroll virtual list to row
|
||||
const handleFileClick = useCallback(
|
||||
(filePath: string) => {
|
||||
onFileClick(filePath);
|
||||
const rowIndex = rows.findIndex(
|
||||
(r) => r.kind === "file" && r.file.newPath === filePath,
|
||||
);
|
||||
if (rowIndex >= 0) {
|
||||
listRef.current?.scrollToRow({ index: rowIndex, align: "smart" });
|
||||
}
|
||||
},
|
||||
[onFileClick, rows, listRef],
|
||||
);
|
||||
|
||||
// Stable row props for the virtual row component
|
||||
const rowProps = useMemo<VirtualRowProps>(
|
||||
() => ({
|
||||
rows,
|
||||
selectedCommit,
|
||||
activeFilePaths,
|
||||
onFileClick: handleFileClick,
|
||||
onToggleDir: toggleDir,
|
||||
}),
|
||||
[rows, selectedCommit, activeFilePaths, handleFileClick, toggleDir],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Review progress */}
|
||||
{totalCount > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Review Progress
|
||||
</h4>
|
||||
<div className="h-1 rounded-full bg-muted w-full">
|
||||
<div
|
||||
className="h-full rounded-full bg-status-success-fg transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{viewedCount}/{totalCount} files viewed
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discussions — individual threads */}
|
||||
{comments.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider flex items-center justify-between">
|
||||
<span>Discussions</span>
|
||||
<span className="flex items-center gap-2 font-normal normal-case">
|
||||
{unresolvedCount > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-status-warning-fg">
|
||||
<Circle className="h-2.5 w-2.5" />
|
||||
{unresolvedCount}
|
||||
</span>
|
||||
)}
|
||||
{resolvedCount > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-status-success-fg">
|
||||
<CheckCircle2 className="h-2.5 w-2.5" />
|
||||
{resolvedCount}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
{/* Fixed header — review progress + discussions */}
|
||||
<div className="p-4 space-y-4 shrink-0">
|
||||
{/* Review progress */}
|
||||
{totalCount > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Review Progress
|
||||
</h4>
|
||||
<div className="h-1 rounded-full bg-muted w-full">
|
||||
<div
|
||||
className="h-full rounded-full bg-status-success-fg transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{viewedCount}/{totalCount} files viewed
|
||||
</span>
|
||||
</h4>
|
||||
<div className="space-y-0.5">
|
||||
{comments
|
||||
.filter((c) => !c.parentCommentId)
|
||||
.map((thread) => {
|
||||
const replyCount = comments.filter(
|
||||
(c) => c.parentCommentId === thread.id,
|
||||
).length;
|
||||
return (
|
||||
<button
|
||||
key={thread.id}
|
||||
className={`
|
||||
flex w-full flex-col gap-0.5 rounded px-2 py-1.5 text-left
|
||||
transition-colors hover:bg-accent/50
|
||||
${thread.resolved ? "opacity-50" : ""}
|
||||
`}
|
||||
onClick={() => onCommentClick ? onCommentClick(thread.id) : onFileClick(thread.filePath)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 w-full min-w-0">
|
||||
{thread.resolved ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-status-success-fg shrink-0" />
|
||||
) : (
|
||||
<MessageSquare className="h-3 w-3 text-status-warning-fg shrink-0" />
|
||||
)}
|
||||
<span className="text-[10px] font-mono text-muted-foreground truncate">
|
||||
{getFileName(thread.filePath)}:{thread.lineNumber}
|
||||
</span>
|
||||
{replyCount > 0 && (
|
||||
<span className="text-[9px] text-muted-foreground/70 shrink-0 ml-auto">
|
||||
{replyCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] text-foreground/80 truncate pl-[18px]">
|
||||
{thread.body.length > 60
|
||||
? thread.body.slice(0, 57) + "..."
|
||||
: thread.body}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Directory-grouped file tree */}
|
||||
<div>
|
||||
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
Files
|
||||
{selectedCommit && (
|
||||
<span className="font-normal ml-1 normal-case">
|
||||
({activeFiles.length} in commit)
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
{directoryGroups.map((group) => (
|
||||
<div key={group.directory}>
|
||||
{/* Directory header */}
|
||||
{group.directory && (
|
||||
<div className="text-[10px] font-mono text-muted-foreground/70 mt-2 first:mt-0 px-2 py-0.5 flex items-center gap-1">
|
||||
<FolderOpen className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{group.directory}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Files in directory */}
|
||||
{/* Discussions — individual threads */}
|
||||
{comments.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider flex items-center justify-between">
|
||||
<span>Discussions</span>
|
||||
<span className="flex items-center gap-2 font-normal normal-case">
|
||||
{unresolvedCount > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-status-warning-fg">
|
||||
<Circle className="h-2.5 w-2.5" />
|
||||
{unresolvedCount}
|
||||
</span>
|
||||
)}
|
||||
{resolvedCount > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-status-success-fg">
|
||||
<CheckCircle2 className="h-2.5 w-2.5" />
|
||||
{resolvedCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</h4>
|
||||
<div className="space-y-0.5">
|
||||
{group.files.map((file) => {
|
||||
const fileCommentCount = comments.filter(
|
||||
(c) => c.filePath === file.newPath && !c.parentCommentId,
|
||||
).length;
|
||||
const isInView = activeFilePaths.has(file.newPath);
|
||||
const dimmed = selectedCommit && !isInView;
|
||||
const isViewed = viewedFiles.has(file.newPath);
|
||||
const dotColor = changeTypeDotColor[file.changeType];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={file.newPath}
|
||||
className={`
|
||||
flex w-full items-center gap-1.5 rounded py-1 text-left text-[11px]
|
||||
hover:bg-accent/50 transition-colors group
|
||||
${group.directory ? "pl-4 pr-2" : "px-2"}
|
||||
${dimmed ? "opacity-35" : ""}
|
||||
`}
|
||||
onClick={() => onFileClick(file.newPath)}
|
||||
>
|
||||
{isViewed ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-status-success-fg shrink-0" />
|
||||
) : (
|
||||
<FileCode className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
{dotColor && (
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${dotColor}`} />
|
||||
)}
|
||||
<span className="truncate flex-1 font-mono">
|
||||
{getFileName(file.newPath)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
{fileCommentCount > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-muted-foreground">
|
||||
<MessageSquare className="h-2.5 w-2.5" />
|
||||
{fileCommentCount}
|
||||
{comments
|
||||
.filter((c) => !c.parentCommentId)
|
||||
.map((thread) => {
|
||||
const replyCount = comments.filter(
|
||||
(c) => c.parentCommentId === thread.id,
|
||||
).length;
|
||||
return (
|
||||
<button
|
||||
key={thread.id}
|
||||
className={`
|
||||
flex w-full flex-col gap-0.5 rounded px-2 py-1.5 text-left
|
||||
transition-colors hover:bg-accent/50
|
||||
${thread.resolved ? "opacity-50" : ""}
|
||||
`}
|
||||
onClick={() => onCommentClick ? onCommentClick(thread.id) : onFileClick(thread.filePath)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 w-full min-w-0">
|
||||
{thread.resolved ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-status-success-fg shrink-0" />
|
||||
) : (
|
||||
<MessageSquare className="h-3 w-3 text-status-warning-fg shrink-0" />
|
||||
)}
|
||||
<span className="text-[10px] font-mono text-muted-foreground truncate">
|
||||
{getFileName(thread.filePath)}:{thread.lineNumber}
|
||||
</span>
|
||||
)}
|
||||
{file.additions > 0 && (
|
||||
<span className="text-diff-add-fg text-[10px]">
|
||||
<Plus className="h-2.5 w-2.5 inline" />
|
||||
{file.additions}
|
||||
</span>
|
||||
)}
|
||||
{file.deletions > 0 && (
|
||||
<span className="text-diff-remove-fg text-[10px]">
|
||||
<Minus className="h-2.5 w-2.5 inline" />
|
||||
{file.deletions}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{replyCount > 0 && (
|
||||
<span className="text-[9px] text-muted-foreground/70 shrink-0 ml-auto">
|
||||
{replyCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] text-foreground/80 truncate pl-[18px]">
|
||||
{thread.body.length > 60
|
||||
? thread.body.slice(0, 57) + "..."
|
||||
: thread.body}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* Files section heading */}
|
||||
<div>
|
||||
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
Files
|
||||
{selectedCommit && (
|
||||
<span className="font-normal ml-1 normal-case">
|
||||
({activeFiles.length} in commit)
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable file tree — virtualized (react-window 2.x List) when >50 rows */}
|
||||
{isVirtualized ? (
|
||||
<List
|
||||
listRef={listRef}
|
||||
rowCount={rows.length}
|
||||
rowHeight={rowHeight}
|
||||
rowComponent={VirtualRowItem}
|
||||
rowProps={rowProps}
|
||||
defaultHeight={600}
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<div ref={containerRef} className="overflow-y-auto px-4 pb-4">
|
||||
{directoryGroups.map((group) => (
|
||||
<div key={group.directory}>
|
||||
{/* Directory header — collapsible */}
|
||||
{group.directory && (
|
||||
<button
|
||||
data-testid="dir-header"
|
||||
className="flex w-full items-center gap-1 text-[10px] font-mono text-muted-foreground/70 mt-2 first:mt-0 px-2 py-0.5 hover:bg-accent/30 transition-colors"
|
||||
onClick={() => toggleDir(group.directory)}
|
||||
title={collapsedDirs.has(group.directory) ? "Expand directory" : "Collapse directory"}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-3 w-3 shrink-0 transition-transform ${collapsedDirs.has(group.directory) ? "" : "rotate-90"}`}
|
||||
/>
|
||||
<FolderOpen className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{group.directory}</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Files in directory */}
|
||||
{!collapsedDirs.has(group.directory) && (
|
||||
<div className="space-y-0.5">
|
||||
{group.files.map((file) => {
|
||||
const fileCommentCount = comments.filter(
|
||||
(c) => c.filePath === file.newPath && !c.parentCommentId,
|
||||
).length;
|
||||
const isInView = activeFilePaths.has(file.newPath);
|
||||
const dimmed = selectedCommit && !isInView;
|
||||
const isViewed = viewedFiles.has(file.newPath);
|
||||
const dotColor = changeTypeDotColor[file.changeType];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={file.newPath}
|
||||
data-testid="file-row"
|
||||
className={`
|
||||
flex w-full items-center gap-1.5 rounded py-1 text-left text-[11px]
|
||||
hover:bg-accent/50 transition-colors group
|
||||
${group.directory ? "pl-4 pr-2" : "px-2"}
|
||||
${dimmed ? "opacity-35" : ""}
|
||||
`}
|
||||
onClick={() => onFileClick(file.newPath)}
|
||||
>
|
||||
{isViewed ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-status-success-fg shrink-0" />
|
||||
) : (
|
||||
<FileCode className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
{dotColor && (
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${dotColor}`} />
|
||||
)}
|
||||
<span className="truncate flex-1 font-mono">
|
||||
{getFileName(file.newPath)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
{fileCommentCount > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-muted-foreground">
|
||||
<MessageSquare className="h-2.5 w-2.5" />
|
||||
{fileCommentCount}
|
||||
</span>
|
||||
)}
|
||||
{file.additions > 0 && (
|
||||
<span className="text-diff-add-fg text-[10px]">
|
||||
<Plus className="h-2.5 w-2.5 inline" />
|
||||
{file.additions}
|
||||
</span>
|
||||
)}
|
||||
{file.deletions > 0 && (
|
||||
<span className="text-diff-remove-fg text-[10px]">
|
||||
<Minus className="h-2.5 w-2.5 inline" />
|
||||
{file.deletions}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
226
apps/web/src/components/review/ReviewTab.test.tsx
Normal file
226
apps/web/src/components/review/ReviewTab.test.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
// @vitest-environment happy-dom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
// ── Capture props passed to stubs ─────────────────────────────────────────────
|
||||
// These are module-level so the vi.mock factories (which are hoisted) can close over them.
|
||||
let diffViewerProps: Record<string, unknown> = {};
|
||||
let reviewSidebarProps: Record<string, unknown> = {};
|
||||
|
||||
vi.mock("./DiffViewer", () => ({
|
||||
DiffViewer: (props: Record<string, unknown>) => {
|
||||
diffViewerProps = props;
|
||||
return <div data-testid="diff-viewer" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./ReviewSidebar", () => ({
|
||||
ReviewSidebar: (props: Record<string, unknown>) => {
|
||||
reviewSidebarProps = props;
|
||||
return <div data-testid="review-sidebar" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./ReviewHeader", () => ({
|
||||
ReviewHeader: (props: Record<string, unknown>) => (
|
||||
<div data-testid="review-header">
|
||||
{props.onExpandAll && (
|
||||
<button
|
||||
data-testid="expand-all-btn"
|
||||
onClick={props.onExpandAll as () => void}
|
||||
>
|
||||
Expand all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./InitiativeReview", () => ({
|
||||
InitiativeReview: () => <div data-testid="initiative-review" />,
|
||||
}));
|
||||
|
||||
vi.mock("./comment-index", () => ({
|
||||
buildCommentIndex: vi.fn(() => new Map()),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
// ── parseUnifiedDiff spy ───────────────────────────────────────────────────────
|
||||
const mockParseUnifiedDiff = vi.fn((_raw: string) => [
|
||||
{
|
||||
oldPath: "a.ts",
|
||||
newPath: "a.ts",
|
||||
status: "modified" as const,
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
hunks: [],
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mock("./parse-diff", () => ({
|
||||
get parseUnifiedDiff() {
|
||||
return mockParseUnifiedDiff;
|
||||
},
|
||||
}));
|
||||
|
||||
// ── tRPC mock factory ─────────────────────────────────────────────────────────
|
||||
|
||||
const noopMutation = () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
const noopQuery = (data: unknown = undefined) => ({
|
||||
data,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
const mockUtils = {
|
||||
listReviewComments: { invalidate: vi.fn() },
|
||||
};
|
||||
|
||||
// Server format (FileStatEntry): uses `path` not `newPath`
|
||||
const PHASE_FILES = [
|
||||
{
|
||||
path: "a.ts",
|
||||
status: "modified" as const,
|
||||
additions: 5,
|
||||
deletions: 2,
|
||||
},
|
||||
];
|
||||
|
||||
// trpcMock is a let so tests can override it. The getter in the mock reads the current value.
|
||||
let trpcMock = buildTrpcMock();
|
||||
|
||||
function buildTrpcMock(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
getInitiative: { useQuery: vi.fn(() => noopQuery({ status: "in_progress" })) },
|
||||
listPhases: {
|
||||
useQuery: vi.fn(() =>
|
||||
noopQuery([{ id: "phase-1", name: "Phase 1", status: "pending_review" }])
|
||||
),
|
||||
},
|
||||
getInitiativeProjects: { useQuery: vi.fn(() => noopQuery([{ id: "proj-1" }])) },
|
||||
getPhaseReviewDiff: {
|
||||
useQuery: vi.fn(() =>
|
||||
noopQuery({
|
||||
phaseName: "Phase 1",
|
||||
sourceBranch: "cw/phase-1",
|
||||
targetBranch: "main",
|
||||
files: PHASE_FILES,
|
||||
totalAdditions: 5,
|
||||
totalDeletions: 2,
|
||||
})
|
||||
),
|
||||
},
|
||||
getPhaseReviewCommits: {
|
||||
useQuery: vi.fn(() =>
|
||||
noopQuery({ commits: [], sourceBranch: "cw/phase-1", targetBranch: "main" })
|
||||
),
|
||||
},
|
||||
getCommitDiff: {
|
||||
useQuery: vi.fn(() => noopQuery({ rawDiff: "" })),
|
||||
},
|
||||
listPreviews: { useQuery: vi.fn(() => noopQuery([])) },
|
||||
getPreviewStatus: { useQuery: vi.fn(() => noopQuery(null)) },
|
||||
listReviewComments: { useQuery: vi.fn(() => noopQuery([])) },
|
||||
startPreview: { useMutation: vi.fn(() => noopMutation()) },
|
||||
stopPreview: { useMutation: vi.fn(() => noopMutation()) },
|
||||
createReviewComment: { useMutation: vi.fn(() => noopMutation()) },
|
||||
resolveReviewComment: { useMutation: vi.fn(() => noopMutation()) },
|
||||
unresolveReviewComment: { useMutation: vi.fn(() => noopMutation()) },
|
||||
replyToReviewComment: { useMutation: vi.fn(() => noopMutation()) },
|
||||
updateReviewComment: { useMutation: vi.fn(() => noopMutation()) },
|
||||
approvePhaseReview: { useMutation: vi.fn(() => noopMutation()) },
|
||||
requestPhaseChanges: { useMutation: vi.fn(() => noopMutation()) },
|
||||
useUtils: vi.fn(() => mockUtils),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("@/lib/trpc", () => ({
|
||||
get trpc() {
|
||||
return trpcMock;
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Import component after mocks ──────────────────────────────────────────────
|
||||
import { ReviewTab } from "./ReviewTab";
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ReviewTab", () => {
|
||||
beforeEach(() => {
|
||||
diffViewerProps = {};
|
||||
reviewSidebarProps = {};
|
||||
mockParseUnifiedDiff.mockClear();
|
||||
trpcMock = buildTrpcMock();
|
||||
});
|
||||
|
||||
it("1. phase diff loads metadata: DiffViewer receives files array and commitMode=false", () => {
|
||||
render(<ReviewTab initiativeId="init-1" />);
|
||||
|
||||
expect(screen.getByTestId("diff-viewer")).toBeInTheDocument();
|
||||
const files = diffViewerProps.files as unknown[];
|
||||
expect(files).toHaveLength(1);
|
||||
expect(diffViewerProps.commitMode).toBe(false);
|
||||
});
|
||||
|
||||
it("2. no rawDiff parsing in phase mode: parseUnifiedDiff is NOT called", () => {
|
||||
render(<ReviewTab initiativeId="init-1" />);
|
||||
|
||||
expect(mockParseUnifiedDiff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("3. commit view parses rawDiff: parseUnifiedDiff called and DiffViewer gets commitMode=true", async () => {
|
||||
trpcMock = buildTrpcMock({
|
||||
getCommitDiff: {
|
||||
useQuery: vi.fn(() =>
|
||||
noopQuery({ rawDiff: "diff --git a/a.ts b/a.ts\nindex 000..111 100644\n--- a/a.ts\n+++ b/a.ts\n@@ -1,1 +1,1 @@\n-old\n+new\n" })
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
render(<ReviewTab initiativeId="init-1" />);
|
||||
|
||||
// Select a commit via the sidebar stub's onSelectCommit prop
|
||||
const { onSelectCommit } = reviewSidebarProps as {
|
||||
onSelectCommit: (hash: string | null) => void;
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
onSelectCommit("abc123");
|
||||
});
|
||||
|
||||
expect(diffViewerProps.commitMode).toBe(true);
|
||||
expect(mockParseUnifiedDiff).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("4. allFiles uses metadata for sidebar: ReviewSidebar receives files from diffQuery.data.files", () => {
|
||||
render(<ReviewTab initiativeId="init-1" />);
|
||||
|
||||
const sidebarFiles = reviewSidebarProps.files as Array<{ newPath: string }>;
|
||||
expect(sidebarFiles).toHaveLength(1);
|
||||
expect(sidebarFiles[0].newPath).toBe("a.ts");
|
||||
});
|
||||
|
||||
it("5. expandAll prop passed: clicking Expand all button causes DiffViewer to receive expandAll=true", async () => {
|
||||
render(<ReviewTab initiativeId="init-1" />);
|
||||
|
||||
// Before clicking, expandAll should be false
|
||||
expect(diffViewerProps.expandAll).toBe(false);
|
||||
|
||||
const expandBtn = screen.getByTestId("expand-all-btn");
|
||||
await act(async () => {
|
||||
expandBtn.click();
|
||||
});
|
||||
|
||||
expect(diffViewerProps.expandAll).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,8 @@ import { DiffViewer } from "./DiffViewer";
|
||||
import { ReviewSidebar } from "./ReviewSidebar";
|
||||
import { ReviewHeader } from "./ReviewHeader";
|
||||
import { InitiativeReview } from "./InitiativeReview";
|
||||
import type { ReviewStatus, DiffLine } from "./types";
|
||||
import { buildCommentIndex } from "./comment-index";
|
||||
import type { ReviewStatus, DiffLine, FileDiff, FileDiffDetail } from "./types";
|
||||
|
||||
interface ReviewTabProps {
|
||||
initiativeId: string;
|
||||
@@ -17,6 +18,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
const [status, setStatus] = useState<ReviewStatus>("pending");
|
||||
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
|
||||
const [viewedFiles, setViewedFiles] = useState<Set<string>>(new Set());
|
||||
const [expandAll, setExpandAll] = useState(false);
|
||||
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const [headerHeight, setHeaderHeight] = useState(0);
|
||||
@@ -73,7 +75,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId });
|
||||
const firstProjectId = projectsQuery.data?.[0]?.id ?? null;
|
||||
|
||||
// Fetch full branch diff for active phase
|
||||
// Fetch full branch diff for active phase (metadata only, no rawDiff)
|
||||
const diffQuery = trpc.getPhaseReviewDiff.useQuery(
|
||||
{ phaseId: activePhaseId! },
|
||||
{ enabled: !!activePhaseId && !isInitiativePendingReview },
|
||||
@@ -95,7 +97,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
// Preview state
|
||||
const previewsQuery = trpc.listPreviews.useQuery({ initiativeId });
|
||||
const existingPreview = previewsQuery.data?.find(
|
||||
(p) => p.phaseId === activePhaseId || p.initiativeId === initiativeId,
|
||||
(p: { phaseId?: string; initiativeId?: string }) => p.phaseId === activePhaseId || p.initiativeId === initiativeId,
|
||||
);
|
||||
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
|
||||
const previewStatusQuery = trpc.getPreviewStatus.useQuery(
|
||||
@@ -106,12 +108,12 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
const sourceBranch = diffQuery.data?.sourceBranch ?? commitsQuery.data?.sourceBranch ?? "";
|
||||
|
||||
const startPreview = trpc.startPreview.useMutation({
|
||||
onSuccess: (data) => {
|
||||
onSuccess: (data: { id: string; url: string }) => {
|
||||
setActivePreviewId(data.id);
|
||||
previewsQuery.refetch();
|
||||
toast.success(`Preview running at ${data.url}`);
|
||||
},
|
||||
onError: (err) => toast.error(`Preview failed: ${err.message}`),
|
||||
onError: (err: { message: string }) => toast.error(`Preview failed: ${err.message}`),
|
||||
});
|
||||
|
||||
const stopPreview = trpc.stopPreview.useMutation({
|
||||
@@ -120,7 +122,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
toast.success("Preview stopped");
|
||||
previewsQuery.refetch();
|
||||
},
|
||||
onError: (err) => toast.error(`Failed to stop: ${err.message}`),
|
||||
onError: (err: { message: string }) => toast.error(`Failed to stop: ${err.message}`),
|
||||
});
|
||||
|
||||
const previewState = firstProjectId && sourceBranch
|
||||
@@ -156,7 +158,17 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
{ enabled: !!activePhaseId && !isInitiativePendingReview },
|
||||
);
|
||||
const comments = useMemo(() => {
|
||||
return (commentsQuery.data ?? []).map((c) => ({
|
||||
return (commentsQuery.data ?? []).map((c: {
|
||||
id: string;
|
||||
filePath: string;
|
||||
lineNumber: number | null;
|
||||
lineType: string;
|
||||
body: string;
|
||||
author: string;
|
||||
createdAt: string | number;
|
||||
resolved: boolean;
|
||||
parentCommentId?: string | null;
|
||||
}) => ({
|
||||
id: c.id,
|
||||
filePath: c.filePath,
|
||||
lineNumber: c.lineNumber,
|
||||
@@ -169,11 +181,16 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
}));
|
||||
}, [commentsQuery.data]);
|
||||
|
||||
const commentsByLine = useMemo(
|
||||
() => buildCommentIndex(comments),
|
||||
[comments],
|
||||
);
|
||||
|
||||
const createCommentMutation = trpc.createReviewComment.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
|
||||
},
|
||||
onError: (err) => toast.error(`Failed to save comment: ${err.message}`),
|
||||
onError: (err: { message: string }) => toast.error(`Failed to save comment: ${err.message}`),
|
||||
});
|
||||
|
||||
const resolveCommentMutation = trpc.resolveReviewComment.useMutation({
|
||||
@@ -192,14 +209,14 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
onSuccess: () => {
|
||||
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
|
||||
},
|
||||
onError: (err) => toast.error(`Failed to post reply: ${err.message}`),
|
||||
onError: (err: { message: string }) => toast.error(`Failed to post reply: ${err.message}`),
|
||||
});
|
||||
|
||||
const editCommentMutation = trpc.updateReviewComment.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
|
||||
},
|
||||
onError: (err) => toast.error(`Failed to update comment: ${err.message}`),
|
||||
onError: (err: { message: string }) => toast.error(`Failed to update comment: ${err.message}`),
|
||||
});
|
||||
|
||||
const approveMutation = trpc.approvePhaseReview.useMutation({
|
||||
@@ -208,23 +225,48 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
toast.success("Phase approved and merged");
|
||||
phasesQuery.refetch();
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
onError: (err: { message: string }) => toast.error(err.message),
|
||||
});
|
||||
|
||||
// Determine which diff to display
|
||||
const activeDiffRaw = selectedCommit
|
||||
? commitDiffQuery.data?.rawDiff
|
||||
: diffQuery.data?.rawDiff;
|
||||
// Phase branch diff — metadata only, no parsing
|
||||
const phaseFiles: FileDiff[] = useMemo(
|
||||
() => {
|
||||
const serverFiles = diffQuery.data?.files ?? [];
|
||||
// Map server FileStatEntry (path) to frontend FileDiff (newPath)
|
||||
return serverFiles.map((f: {
|
||||
path: string;
|
||||
oldPath?: string;
|
||||
status: FileDiff['status'];
|
||||
additions: number;
|
||||
deletions: number;
|
||||
projectId?: string;
|
||||
}) => ({
|
||||
newPath: f.path,
|
||||
oldPath: f.oldPath ?? f.path,
|
||||
status: f.status,
|
||||
additions: f.additions,
|
||||
deletions: f.deletions,
|
||||
projectId: f.projectId,
|
||||
}));
|
||||
},
|
||||
[diffQuery.data?.files],
|
||||
);
|
||||
|
||||
const files = useMemo(() => {
|
||||
if (!activeDiffRaw) return [];
|
||||
return parseUnifiedDiff(activeDiffRaw);
|
||||
}, [activeDiffRaw]);
|
||||
// Commit diff — still raw, parse client-side
|
||||
const commitFiles: FileDiffDetail[] = useMemo(() => {
|
||||
if (!commitDiffQuery.data?.rawDiff) return [];
|
||||
return parseUnifiedDiff(commitDiffQuery.data.rawDiff);
|
||||
}, [commitDiffQuery.data?.rawDiff]);
|
||||
|
||||
const isDiffLoading = selectedCommit
|
||||
? commitDiffQuery.isLoading
|
||||
: diffQuery.isLoading;
|
||||
|
||||
// All files for sidebar — always from phase metadata
|
||||
const allFiles = phaseFiles;
|
||||
|
||||
const activeFiles: FileDiff[] | FileDiffDetail[] = selectedCommit ? commitFiles : phaseFiles;
|
||||
|
||||
const handleAddComment = useCallback(
|
||||
(filePath: string, lineNumber: number, lineType: DiffLine["type"], body: string) => {
|
||||
if (!activePhaseId) return;
|
||||
@@ -267,7 +309,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
toast.success("Changes requested — revision task dispatched");
|
||||
phasesQuery.refetch();
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
onError: (err: { message: string }) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const handleRequestChanges = useCallback(() => {
|
||||
@@ -297,6 +339,11 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
setSelectedCommit(null);
|
||||
setStatus("pending");
|
||||
setViewedFiles(new Set());
|
||||
setExpandAll(false);
|
||||
}, []);
|
||||
|
||||
const handleExpandAll = useCallback(() => {
|
||||
setExpandAll(v => !v);
|
||||
}, []);
|
||||
|
||||
const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length;
|
||||
@@ -306,12 +353,6 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
reviewablePhases.find((p) => p.id === activePhaseId)?.name ??
|
||||
"Phase";
|
||||
|
||||
// All files from the full branch diff (for sidebar file list)
|
||||
const allFiles = useMemo(() => {
|
||||
if (!diffQuery.data?.rawDiff) return [];
|
||||
return parseUnifiedDiff(diffQuery.data.rawDiff);
|
||||
}, [diffQuery.data?.rawDiff]);
|
||||
|
||||
// Initiative-level review takes priority
|
||||
if (isInitiativePendingReview) {
|
||||
return (
|
||||
@@ -357,6 +398,9 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
preview={previewState}
|
||||
viewedCount={viewedFiles.size}
|
||||
totalCount={allFiles.length}
|
||||
totalAdditions={selectedCommit ? undefined : diffQuery.data?.totalAdditions}
|
||||
totalDeletions={selectedCommit ? undefined : diffQuery.data?.totalDeletions}
|
||||
onExpandAll={handleExpandAll}
|
||||
/>
|
||||
|
||||
{/* Main content area — sidebar always rendered to preserve state */}
|
||||
@@ -376,7 +420,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
onFileClick={handleFileClick}
|
||||
onCommentClick={handleCommentClick}
|
||||
selectedCommit={selectedCommit}
|
||||
activeFiles={files}
|
||||
activeFiles={activeFiles}
|
||||
commits={commits}
|
||||
onSelectCommit={setSelectedCommit}
|
||||
viewedFiles={viewedFiles}
|
||||
@@ -391,7 +435,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading diff...
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
) : activeFiles.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-muted-foreground text-sm">
|
||||
{selectedCommit
|
||||
? "No changes in this commit"
|
||||
@@ -399,8 +443,10 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
</div>
|
||||
) : (
|
||||
<DiffViewer
|
||||
files={files}
|
||||
comments={comments}
|
||||
files={activeFiles}
|
||||
phaseId={activePhaseId!}
|
||||
commitMode={!!selectedCommit}
|
||||
commentsByLine={commentsByLine}
|
||||
onAddComment={handleAddComment}
|
||||
onResolveComment={handleResolveComment}
|
||||
onUnresolveComment={handleUnresolveComment}
|
||||
@@ -409,6 +455,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
viewedFiles={viewedFiles}
|
||||
onToggleViewed={toggleViewed}
|
||||
onRegisterRef={registerFileRef}
|
||||
expandAll={expandAll}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
134
apps/web/src/components/review/comment-index.test.tsx
Normal file
134
apps/web/src/components/review/comment-index.test.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
// @vitest-environment happy-dom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { vi, describe, it, expect } from "vitest";
|
||||
import { buildCommentIndex } from "./comment-index";
|
||||
import type { ReviewComment } from "./types";
|
||||
|
||||
// ── Stub CommentThread and CommentForm so LineWithComments renders without deps ──
|
||||
vi.mock("./CommentThread", () => ({
|
||||
CommentThread: () => <div data-testid="comment-thread" />,
|
||||
}));
|
||||
vi.mock("./CommentForm", () => ({
|
||||
CommentForm: vi.fn().mockReturnValue(<div data-testid="comment-form" />),
|
||||
}));
|
||||
vi.mock("./use-syntax-highlight", () => ({
|
||||
useHighlightedFile: () => null,
|
||||
}));
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeComment(overrides: Partial<ReviewComment> & { id: string }): ReviewComment {
|
||||
return {
|
||||
id: overrides.id,
|
||||
filePath: overrides.filePath ?? "src/foo.ts",
|
||||
lineNumber: overrides.lineNumber !== undefined ? overrides.lineNumber : 1,
|
||||
lineType: overrides.lineType ?? "added",
|
||||
body: overrides.body ?? "comment body",
|
||||
author: overrides.author ?? "alice",
|
||||
createdAt: overrides.createdAt ?? "2024-01-01T00:00:00Z",
|
||||
resolved: overrides.resolved ?? false,
|
||||
parentCommentId: overrides.parentCommentId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── buildCommentIndex — pure function tests ───────────────────────────────────
|
||||
|
||||
describe("buildCommentIndex", () => {
|
||||
it("happy path — basic indexing", () => {
|
||||
const c1 = makeComment({ id: "1", filePath: "src/foo.ts", lineNumber: 10, lineType: "added" });
|
||||
const c2 = makeComment({ id: "2", filePath: "src/bar.ts", lineNumber: 5, lineType: "context" });
|
||||
const map = buildCommentIndex([c1, c2]);
|
||||
expect(map.get("src/foo.ts:10:added")).toEqual([c1]);
|
||||
expect(map.get("src/bar.ts:5:context")).toEqual([c2]);
|
||||
expect(map.size).toBe(2);
|
||||
});
|
||||
|
||||
it("same-line accumulation — two comments land in same array", () => {
|
||||
const c1 = makeComment({ id: "a", filePath: "src/x.ts", lineNumber: 20, lineType: "added" });
|
||||
const c2 = makeComment({ id: "b", filePath: "src/x.ts", lineNumber: 20, lineType: "added" });
|
||||
const map = buildCommentIndex([c1, c2]);
|
||||
expect(map.get("src/x.ts:20:added")).toEqual([c1, c2]);
|
||||
expect(map.size).toBe(1);
|
||||
});
|
||||
|
||||
it("cross-type isolation — same lineNumber but different lineType produces separate entries", () => {
|
||||
const added = makeComment({ id: "a", filePath: "src/x.ts", lineNumber: 10, lineType: "added" });
|
||||
const removed = makeComment({ id: "r", filePath: "src/x.ts", lineNumber: 10, lineType: "removed" });
|
||||
const map = buildCommentIndex([added, removed]);
|
||||
expect(map.get("src/x.ts:10:added")).toEqual([added]);
|
||||
expect(map.get("src/x.ts:10:removed")).toEqual([removed]);
|
||||
expect(map.size).toBe(2);
|
||||
});
|
||||
|
||||
it("null lineNumber — file-level comment stored under filePath:file", () => {
|
||||
const fileComment = makeComment({ id: "f", filePath: "src/z.ts", lineNumber: null, lineType: "context" });
|
||||
const map = buildCommentIndex([fileComment]);
|
||||
expect(map.get("src/z.ts:file")).toEqual([fileComment]);
|
||||
});
|
||||
|
||||
it("empty input — returns empty map", () => {
|
||||
expect(buildCommentIndex([])).toEqual(new Map());
|
||||
});
|
||||
});
|
||||
|
||||
// ── LineWithComments — component tests ───────────────────────────────────────
|
||||
|
||||
import { LineWithComments } from "./LineWithComments";
|
||||
import type { DiffLine } from "./types";
|
||||
|
||||
const addedLine: DiffLine = {
|
||||
type: "added",
|
||||
content: "const x = 1;",
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 5,
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe("LineWithComments", () => {
|
||||
it("renders comment button with title when lineComments is non-empty", () => {
|
||||
const lineComments = [
|
||||
makeComment({ id: "c1", filePath: "src/foo.ts", lineNumber: 5, lineType: "added" }),
|
||||
];
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LineWithComments
|
||||
line={addedLine}
|
||||
lineKey={5}
|
||||
lineComments={lineComments}
|
||||
isCommenting={false}
|
||||
onStartComment={noop}
|
||||
onCancelComment={noop}
|
||||
onSubmitComment={noop}
|
||||
onResolveComment={noop}
|
||||
onUnresolveComment={noop}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(screen.getByTitle(/1 comment/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render comment thread row when lineComments is empty", () => {
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LineWithComments
|
||||
line={addedLine}
|
||||
lineKey={5}
|
||||
lineComments={[]}
|
||||
isCommenting={false}
|
||||
onStartComment={noop}
|
||||
onCancelComment={noop}
|
||||
onSubmitComment={noop}
|
||||
onResolveComment={noop}
|
||||
onUnresolveComment={noop}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(document.querySelector("[data-comment-id]")).toBeNull();
|
||||
});
|
||||
});
|
||||
25
apps/web/src/components/review/comment-index.ts
Normal file
25
apps/web/src/components/review/comment-index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ReviewComment } from "./types";
|
||||
|
||||
/**
|
||||
* Build a Map keyed by `"${filePath}:${lineNumber}:${lineType}"` for line-level
|
||||
* comments, or `"${filePath}:file"` for file-level comments (lineNumber === null).
|
||||
*
|
||||
* The compound key (filePath + lineNumber + lineType) is required because
|
||||
* added and removed lines can share the same numeric position in a replacement
|
||||
* hunk (e.g., old line 10 removed, new line 10 added).
|
||||
*/
|
||||
export function buildCommentIndex(
|
||||
comments: ReviewComment[],
|
||||
): Map<string, ReviewComment[]> {
|
||||
const map = new Map<string, ReviewComment[]>();
|
||||
for (const comment of comments) {
|
||||
const key =
|
||||
comment.lineNumber != null
|
||||
? `${comment.filePath}:${comment.lineNumber}:${comment.lineType}`
|
||||
: `${comment.filePath}:file`;
|
||||
const existing = map.get(key);
|
||||
if (existing) existing.push(comment);
|
||||
else map.set(key, [comment]);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
39
apps/web/src/components/review/highlight-worker.ts
Normal file
39
apps/web/src/components/review/highlight-worker.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ThemedToken } from 'shiki';
|
||||
|
||||
export interface HighlightRequest {
|
||||
id: string;
|
||||
filePath: string;
|
||||
language: string; // resolved lang name (e.g. "typescript") or "text"
|
||||
code: string; // full joined content of new-side lines to highlight
|
||||
lineNumbers: number[]; // new-side line numbers to map tokens back to
|
||||
}
|
||||
|
||||
export interface HighlightResponse {
|
||||
id: string;
|
||||
tokens: Array<{ lineNumber: number; tokens: ThemedToken[] }>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
self.addEventListener('message', async (event: MessageEvent<HighlightRequest>) => {
|
||||
const { id, language, code, lineNumbers } = event.data;
|
||||
try {
|
||||
const { codeToTokens } = await import('shiki');
|
||||
const result = await codeToTokens(code, {
|
||||
lang: language as Parameters<typeof codeToTokens>[1]['lang'],
|
||||
theme: 'github-dark-default',
|
||||
});
|
||||
const tokens: HighlightResponse['tokens'] = result.tokens.map((lineTokens, idx) => ({
|
||||
lineNumber: lineNumbers[idx] ?? idx,
|
||||
tokens: lineTokens,
|
||||
}));
|
||||
const response: HighlightResponse = { id, tokens };
|
||||
self.postMessage(response);
|
||||
} catch (err) {
|
||||
const response: HighlightResponse = {
|
||||
id,
|
||||
tokens: [],
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
self.postMessage(response);
|
||||
}
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { FileDiff, FileChangeType, DiffHunk, DiffLine } from "./types";
|
||||
import type { FileDiffDetail, FileDiff, DiffHunk, DiffLine } from "./types";
|
||||
|
||||
/**
|
||||
* Parse a unified diff string into structured FileDiff objects.
|
||||
* Parse a unified diff string into structured FileDiffDetail objects.
|
||||
*/
|
||||
export function parseUnifiedDiff(raw: string): FileDiff[] {
|
||||
const files: FileDiff[] = [];
|
||||
export function parseUnifiedDiff(raw: string): FileDiffDetail[] {
|
||||
const files: FileDiffDetail[] = [];
|
||||
const fileChunks = raw.split(/^diff --git /m).filter(Boolean);
|
||||
|
||||
for (const chunk of fileChunks) {
|
||||
@@ -90,19 +90,19 @@ export function parseUnifiedDiff(raw: string): FileDiff[] {
|
||||
hunks.push({ header, oldStart, oldCount, newStart, newCount, lines: hunkLines });
|
||||
}
|
||||
|
||||
// Derive changeType from header markers and path comparison
|
||||
let changeType: FileChangeType;
|
||||
// Derive status from header markers and path comparison
|
||||
let status: FileDiff['status'];
|
||||
if (hasOldDevNull) {
|
||||
changeType = "added";
|
||||
status = "added";
|
||||
} else if (hasNewDevNull) {
|
||||
changeType = "deleted";
|
||||
status = "deleted";
|
||||
} else if (oldPath !== newPath) {
|
||||
changeType = "renamed";
|
||||
status = "renamed";
|
||||
} else {
|
||||
changeType = "modified";
|
||||
status = "modified";
|
||||
}
|
||||
|
||||
files.push({ oldPath, newPath, hunks, additions, deletions, changeType });
|
||||
files.push({ oldPath, newPath, hunks, additions, deletions, status });
|
||||
}
|
||||
|
||||
return files;
|
||||
|
||||
29
apps/web/src/components/review/types.test.ts
Normal file
29
apps/web/src/components/review/types.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { FileDiff, FileDiffDetail } from './types';
|
||||
|
||||
describe('FileDiff types', () => {
|
||||
it('FileDiff accepts binary status', () => {
|
||||
const f: FileDiff = {
|
||||
oldPath: 'a.png',
|
||||
newPath: 'a.png',
|
||||
status: 'binary',
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
};
|
||||
expect(f.status).toBe('binary');
|
||||
});
|
||||
|
||||
it('FileDiffDetail extends FileDiff with hunks', () => {
|
||||
const d: FileDiffDetail = {
|
||||
oldPath: 'a.ts',
|
||||
newPath: 'a.ts',
|
||||
status: 'modified',
|
||||
additions: 5,
|
||||
deletions: 2,
|
||||
hunks: [],
|
||||
};
|
||||
expect(d.hunks).toEqual([]);
|
||||
expect(d.additions).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -14,21 +14,26 @@ export interface DiffLine {
|
||||
newLineNumber: number | null;
|
||||
}
|
||||
|
||||
export type FileChangeType = 'added' | 'modified' | 'deleted' | 'renamed';
|
||||
|
||||
/** Metadata returned by getPhaseReviewDiff — no hunk content */
|
||||
export interface FileDiff {
|
||||
oldPath: string;
|
||||
newPath: string;
|
||||
hunks: DiffHunk[];
|
||||
/** 'binary' is new — prior changeType used FileChangeType which had no 'binary' */
|
||||
status: 'added' | 'modified' | 'deleted' | 'renamed' | 'binary';
|
||||
additions: number;
|
||||
deletions: number;
|
||||
changeType: FileChangeType;
|
||||
projectId?: string; // present in multi-project initiatives
|
||||
}
|
||||
|
||||
/** Full diff with parsed hunks — returned by getFileDiff, parsed client-side */
|
||||
export interface FileDiffDetail extends FileDiff {
|
||||
hunks: DiffHunk[];
|
||||
}
|
||||
|
||||
export interface ReviewComment {
|
||||
id: string;
|
||||
filePath: string;
|
||||
lineNumber: number; // new-side line number (or old-side for deletions)
|
||||
lineNumber: number | null; // null = file-level comment
|
||||
lineType: "added" | "removed" | "context";
|
||||
body: string;
|
||||
author: string;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// @vitest-environment happy-dom
|
||||
// This file tests the chunked main-thread fallback path when Worker
|
||||
// construction is blocked (e.g. by CSP). It runs in isolation from the
|
||||
// worker-path tests so that module-level state (workersInitialized, workers)
|
||||
// starts clean.
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { vi, describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
|
||||
const MOCK_TOKEN_A = { content: 'const', color: '#569cd6', offset: 0 }
|
||||
const MOCK_TOKEN_B = { content: 'x', color: '#9cdcfe', offset: 0 }
|
||||
|
||||
// Mock shiki's createHighlighter for the fallback path
|
||||
const mockCodeToTokens = vi.fn()
|
||||
|
||||
vi.mock('shiki', () => ({
|
||||
createHighlighter: vi.fn().mockResolvedValue({
|
||||
codeToTokens: mockCodeToTokens,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Stub Worker to throw (simulating CSP) BEFORE the hook module is loaded.
|
||||
// initWorkers() catches the exception and leaves workers = [].
|
||||
beforeAll(() => {
|
||||
// Use a class so Vitest doesn't warn about constructing vi.fn() without a class impl
|
||||
class BlockedWorker {
|
||||
constructor() {
|
||||
throw new Error('CSP blocks workers')
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Worker', BlockedWorker)
|
||||
|
||||
mockCodeToTokens.mockReturnValue({
|
||||
tokens: [[MOCK_TOKEN_A], [MOCK_TOKEN_B]],
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// Dynamic import ensures this file's module instance is fresh (workersInitialized = false).
|
||||
// We import inside tests below rather than at the top level.
|
||||
|
||||
describe('useHighlightedFile — fallback path (Worker unavailable)', () => {
|
||||
it('falls back to chunked main-thread highlighting when Worker construction throws', async () => {
|
||||
const { useHighlightedFile } = await import('./use-syntax-highlight')
|
||||
|
||||
const lines = [
|
||||
{ content: 'const x = 1', newLineNumber: 1, type: 'added' as const },
|
||||
{ content: 'let y = 2', newLineNumber: 2, type: 'context' as const },
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useHighlightedFile('app.ts', lines))
|
||||
|
||||
// Initially null while chunked highlighting runs
|
||||
expect(result.current).toBeNull()
|
||||
|
||||
// Fallback createHighlighter path eventually resolves tokens
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current).not.toBeNull()
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
|
||||
expect(result.current?.get(1)).toEqual([MOCK_TOKEN_A])
|
||||
expect(result.current?.get(2)).toEqual([MOCK_TOKEN_B])
|
||||
})
|
||||
|
||||
it('returns a complete token map with no lines missing for ≤200-line input (single-chunk equivalence)', async () => {
|
||||
const { useHighlightedFile } = await import('./use-syntax-highlight')
|
||||
|
||||
// 5 lines — well within the 200-line chunk size, so a single codeToTokens call handles all
|
||||
const MOCK_TOKENS = [
|
||||
[{ content: 'line1', color: '#fff', offset: 0 }],
|
||||
[{ content: 'line2', color: '#fff', offset: 0 }],
|
||||
[{ content: 'line3', color: '#fff', offset: 0 }],
|
||||
[{ content: 'line4', color: '#fff', offset: 0 }],
|
||||
[{ content: 'line5', color: '#fff', offset: 0 }],
|
||||
]
|
||||
mockCodeToTokens.mockReturnValueOnce({ tokens: MOCK_TOKENS })
|
||||
|
||||
const lines = [1, 2, 3, 4, 5].map((n) => ({
|
||||
content: `line${n}`,
|
||||
newLineNumber: n,
|
||||
type: 'context' as const,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useHighlightedFile('src/bar.ts', lines))
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current).not.toBeNull()
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
|
||||
// All 5 line numbers must be present — no lines missing
|
||||
expect(result.current!.size).toBe(5)
|
||||
for (let n = 1; n <= 5; n++) {
|
||||
expect(result.current!.get(n)).toEqual(MOCK_TOKENS[n - 1])
|
||||
}
|
||||
})
|
||||
|
||||
it('calls AbortController.abort() when component unmounts during chunked fallback', async () => {
|
||||
const { useHighlightedFile } = await import('./use-syntax-highlight')
|
||||
|
||||
const abortSpy = vi.spyOn(AbortController.prototype, 'abort')
|
||||
|
||||
// Delay the mock so the hook is still in-flight when we unmount
|
||||
mockCodeToTokens.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(() => resolve({ tokens: [[MOCK_TOKEN_A]] }), 500),
|
||||
),
|
||||
)
|
||||
|
||||
const lines = [{ content: 'const x = 1', newLineNumber: 1, type: 'added' as const }]
|
||||
|
||||
const { unmount } = renderHook(() => useHighlightedFile('unmount.ts', lines))
|
||||
|
||||
// Unmount while the async chunked highlight is still pending
|
||||
unmount()
|
||||
|
||||
// The cleanup function calls abortController.abort()
|
||||
expect(abortSpy).toHaveBeenCalled()
|
||||
|
||||
abortSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
240
apps/web/src/components/review/use-syntax-highlight.test.ts
Normal file
240
apps/web/src/components/review/use-syntax-highlight.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { vi, describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'
|
||||
|
||||
// ── Worker mock infrastructure ─────────────────────────────────────────────
|
||||
//
|
||||
// We stub Worker BEFORE importing use-syntax-highlight so that initWorkers()
|
||||
// (called from useEffect on first render) picks up our mock.
|
||||
// Module-level state (workers, pending, workersInitialized) is shared across
|
||||
// all tests in this file — we control behaviour through the mock instances.
|
||||
|
||||
type WorkerHandler = (event: { data: unknown }) => void
|
||||
|
||||
class MockWorker {
|
||||
static instances: MockWorker[] = []
|
||||
|
||||
messageHandler: WorkerHandler | null = null
|
||||
postMessage = vi.fn()
|
||||
|
||||
constructor() {
|
||||
MockWorker.instances.push(this)
|
||||
}
|
||||
|
||||
addEventListener(type: string, handler: WorkerHandler) {
|
||||
if (type === 'message') this.messageHandler = handler
|
||||
}
|
||||
|
||||
/** Simulate a message arriving from the worker thread */
|
||||
simulateResponse(data: unknown) {
|
||||
this.messageHandler?.({ data })
|
||||
}
|
||||
}
|
||||
|
||||
// Stub Worker before the hook module is loaded.
|
||||
// initWorkers() is lazy (called inside useEffect), so the stub is in place
|
||||
// by the time any test renders a hook.
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('Worker', MockWorker)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset call history between tests; keep instances (pool is created once)
|
||||
MockWorker.instances.forEach((w) => w.postMessage.mockClear())
|
||||
})
|
||||
|
||||
// Import the hook AFTER the beforeAll stub is registered (hoisted evaluation
|
||||
// of the module will not call initWorkers() — that happens in useEffect).
|
||||
import { useHighlightedFile } from './use-syntax-highlight'
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_TOKEN_A = { content: 'const', color: '#569cd6', offset: 0 }
|
||||
const MOCK_TOKEN_B = { content: 'x', color: '#9cdcfe', offset: 0 }
|
||||
|
||||
function makeLine(
|
||||
content: string,
|
||||
newLineNumber: number,
|
||||
type: 'added' | 'context' | 'removed' = 'added',
|
||||
) {
|
||||
return { content, newLineNumber, type } as const
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useHighlightedFile — worker path', () => {
|
||||
// ── Test 1: Correct message format ───────────────────────────────────────
|
||||
|
||||
it('posts a message to a worker with filePath, language, code, and lineNumbers', async () => {
|
||||
const lines = [
|
||||
makeLine('const x = 1', 1, 'added'),
|
||||
makeLine('const y = 2', 2, 'context'),
|
||||
]
|
||||
|
||||
renderHook(() => useHighlightedFile('src/index.ts', lines))
|
||||
|
||||
// Wait for initWorkers() to fire and postMessage to be called
|
||||
await waitFor(() => {
|
||||
const totalCalls = MockWorker.instances.reduce(
|
||||
(n, w) => n + w.postMessage.mock.calls.length,
|
||||
0,
|
||||
)
|
||||
expect(totalCalls).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// Find which worker received the message
|
||||
const calledWorker = MockWorker.instances.find((w) => w.postMessage.mock.calls.length > 0)
|
||||
expect(calledWorker).toBeDefined()
|
||||
expect(calledWorker!.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: 'src/index.ts',
|
||||
language: 'typescript',
|
||||
code: 'const x = 1\nconst y = 2',
|
||||
lineNumbers: [1, 2],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// ── Test 2: Response builds token map ─────────────────────────────────────
|
||||
|
||||
it('returns null initially and a LineTokenMap after worker responds', async () => {
|
||||
const lines = [makeLine('const x = 1', 10, 'added')]
|
||||
|
||||
const { result } = renderHook(() => useHighlightedFile('component.ts', lines))
|
||||
|
||||
// Immediately null while worker is pending
|
||||
expect(result.current).toBeNull()
|
||||
|
||||
// Capture the request id from whichever worker received it
|
||||
let requestId = ''
|
||||
let respondingWorker: MockWorker | undefined
|
||||
|
||||
await waitFor(() => {
|
||||
respondingWorker = MockWorker.instances.find((w) => w.postMessage.mock.calls.length > 0)
|
||||
expect(respondingWorker).toBeDefined()
|
||||
requestId = respondingWorker!.postMessage.mock.calls[0][0].id as string
|
||||
expect(requestId).not.toBe('')
|
||||
})
|
||||
|
||||
// Simulate the worker responding
|
||||
act(() => {
|
||||
respondingWorker!.simulateResponse({
|
||||
id: requestId,
|
||||
tokens: [{ lineNumber: 10, tokens: [MOCK_TOKEN_A] }],
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).not.toBeNull()
|
||||
expect(result.current?.get(10)).toEqual([MOCK_TOKEN_A])
|
||||
})
|
||||
})
|
||||
|
||||
// ── Test 3: Worker error response → null ──────────────────────────────────
|
||||
|
||||
it('returns null when worker responds with an error field', async () => {
|
||||
const lines = [makeLine('code here', 1, 'added')]
|
||||
|
||||
const { result } = renderHook(() => useHighlightedFile('bad.ts', lines))
|
||||
|
||||
let requestId = ''
|
||||
let respondingWorker: MockWorker | undefined
|
||||
|
||||
await waitFor(() => {
|
||||
respondingWorker = MockWorker.instances.find((w) => w.postMessage.mock.calls.length > 0)
|
||||
expect(respondingWorker).toBeDefined()
|
||||
requestId = respondingWorker!.postMessage.mock.calls[0][0].id as string
|
||||
})
|
||||
|
||||
act(() => {
|
||||
respondingWorker!.simulateResponse({
|
||||
id: requestId,
|
||||
tokens: [],
|
||||
error: 'Worker crashed',
|
||||
})
|
||||
})
|
||||
|
||||
// Error → stays null (plain text fallback in the UI)
|
||||
await new Promise<void>((r) => setTimeout(r, 20))
|
||||
expect(result.current).toBeNull()
|
||||
})
|
||||
|
||||
// ── Test 4: Unmount before response — no state update ────────────────────
|
||||
|
||||
it('silently discards a late worker response after unmount', async () => {
|
||||
const lines = [makeLine('const z = 3', 5, 'added')]
|
||||
|
||||
const { result, unmount } = renderHook(() => useHighlightedFile('late.ts', lines))
|
||||
|
||||
let requestId = ''
|
||||
let respondingWorker: MockWorker | undefined
|
||||
|
||||
await waitFor(() => {
|
||||
respondingWorker = MockWorker.instances.find((w) => w.postMessage.mock.calls.length > 0)
|
||||
expect(respondingWorker).toBeDefined()
|
||||
requestId = respondingWorker!.postMessage.mock.calls[0][0].id as string
|
||||
})
|
||||
|
||||
// Unmount before the response arrives
|
||||
unmount()
|
||||
|
||||
// Simulate the late response — should be silently dropped
|
||||
act(() => {
|
||||
respondingWorker!.simulateResponse({
|
||||
id: requestId,
|
||||
tokens: [{ lineNumber: 5, tokens: [MOCK_TOKEN_B] }],
|
||||
})
|
||||
})
|
||||
|
||||
// result.current is frozen at last rendered value (null) — no update fired
|
||||
expect(result.current).toBeNull()
|
||||
})
|
||||
|
||||
// ── Test 5: Round-robin — two simultaneous requests go to different workers
|
||||
|
||||
it('distributes two simultaneous requests across both pool workers', async () => {
|
||||
// Ensure the pool has been initialised (first test may have done this)
|
||||
// and reset call counts for clean measurement.
|
||||
MockWorker.instances.forEach((w) => w.postMessage.mockClear())
|
||||
|
||||
const lines1 = [makeLine('alpha', 1, 'added')]
|
||||
const lines2 = [makeLine('beta', 1, 'added')]
|
||||
|
||||
// Render two hook instances at the same time
|
||||
renderHook(() => useHighlightedFile('file1.ts', lines1))
|
||||
renderHook(() => useHighlightedFile('file2.ts', lines2))
|
||||
|
||||
await waitFor(() => {
|
||||
const total = MockWorker.instances.reduce((n, w) => n + w.postMessage.mock.calls.length, 0)
|
||||
expect(total).toBe(2)
|
||||
})
|
||||
|
||||
// Both pool workers should each have received exactly one request
|
||||
// (round-robin: even requestCount → workers[0], odd → workers[1])
|
||||
const counts = MockWorker.instances.map((w) => w.postMessage.mock.calls.length)
|
||||
// Pool has 2 workers; each should have received 1 of the 2 requests
|
||||
expect(counts[0]).toBe(1)
|
||||
expect(counts[1]).toBe(1)
|
||||
})
|
||||
|
||||
// ── Test 6: Unknown language → no request ────────────────────────────────
|
||||
|
||||
it('returns null immediately for files with no detectable language', async () => {
|
||||
MockWorker.instances.forEach((w) => w.postMessage.mockClear())
|
||||
|
||||
const lines = [makeLine('raw data', 1, 'added')]
|
||||
|
||||
const { result } = renderHook(() => useHighlightedFile('data.xyz', lines))
|
||||
|
||||
await new Promise<void>((r) => setTimeout(r, 50))
|
||||
|
||||
expect(result.current).toBeNull()
|
||||
const total = MockWorker.instances.reduce((n, w) => n + w.postMessage.mock.calls.length, 0)
|
||||
expect(total).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,59 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import type { ThemedToken } from "shiki";
|
||||
import type { HighlightRequest, HighlightResponse } from "./highlight-worker";
|
||||
|
||||
/* ── Lazy singleton highlighter ─────────────────────────── */
|
||||
/* ── Worker pool (module-level, shared across all hook instances) ─────── */
|
||||
|
||||
type PendingResolve = (response: HighlightResponse) => void;
|
||||
|
||||
let workers: Worker[] = [];
|
||||
let requestCount = 0;
|
||||
const MAX_WORKERS = 2;
|
||||
const pending = new Map<string, PendingResolve>();
|
||||
|
||||
let workersInitialized = false;
|
||||
|
||||
function initWorkers(): void {
|
||||
if (workersInitialized) return;
|
||||
workersInitialized = true;
|
||||
try {
|
||||
workers = Array.from({ length: MAX_WORKERS }, () => {
|
||||
const w = new Worker(
|
||||
new URL("./highlight-worker.ts", import.meta.url),
|
||||
{ type: "module" },
|
||||
);
|
||||
w.addEventListener("message", (event: MessageEvent<HighlightResponse>) => {
|
||||
const resolve = pending.get(event.data.id);
|
||||
if (resolve) {
|
||||
pending.delete(event.data.id);
|
||||
resolve(event.data);
|
||||
}
|
||||
});
|
||||
return w;
|
||||
});
|
||||
} catch {
|
||||
// CSP or browser compat — fall back to chunked main-thread highlighting
|
||||
workers = [];
|
||||
}
|
||||
}
|
||||
|
||||
function highlightWithWorker(
|
||||
id: string,
|
||||
language: string,
|
||||
code: string,
|
||||
lineNumbers: number[],
|
||||
filePath: string,
|
||||
): Promise<HighlightResponse> {
|
||||
return new Promise<HighlightResponse>((resolve) => {
|
||||
pending.set(id, resolve);
|
||||
const worker = workers[requestCount % MAX_WORKERS];
|
||||
requestCount++;
|
||||
const req: HighlightRequest = { id, filePath, language, code, lineNumbers };
|
||||
worker.postMessage(req);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Lazy singleton highlighter (for main-thread fallback) ───────────── */
|
||||
|
||||
let highlighterPromise: Promise<Awaited<
|
||||
ReturnType<typeof import("shiki")["createHighlighter"]>
|
||||
@@ -40,10 +92,59 @@ function getHighlighter() {
|
||||
return highlighterPromise;
|
||||
}
|
||||
|
||||
// Pre-warm on module load (non-blocking)
|
||||
getHighlighter();
|
||||
/* ── Chunked main-thread fallback ────────────────────────────────────── */
|
||||
|
||||
/* ── Language detection ──────────────────────────────────── */
|
||||
async function highlightChunked(
|
||||
code: string,
|
||||
language: string,
|
||||
lineNumbers: number[],
|
||||
signal: AbortSignal,
|
||||
): Promise<LineTokenMap> {
|
||||
const CHUNK = 200;
|
||||
const result: LineTokenMap = new Map();
|
||||
const lines = code.split("\n");
|
||||
const highlighter = await getHighlighter();
|
||||
if (!highlighter) return result;
|
||||
|
||||
for (let i = 0; i < lines.length; i += CHUNK) {
|
||||
if (signal.aborted) break;
|
||||
const chunkLines = lines.slice(i, i + CHUNK);
|
||||
const chunkCode = chunkLines.join("\n");
|
||||
try {
|
||||
const tokenized = highlighter.codeToTokens(chunkCode, {
|
||||
lang: language as Parameters<typeof highlighter.codeToTokens>[1]["lang"],
|
||||
theme: "github-dark-default",
|
||||
});
|
||||
tokenized.tokens.forEach((lineTokens: ThemedToken[], idx: number) => {
|
||||
const lineNum = lineNumbers[i + idx];
|
||||
if (lineNum !== undefined) result.set(lineNum, lineTokens);
|
||||
});
|
||||
} catch {
|
||||
// Skip unparseable chunk
|
||||
}
|
||||
|
||||
// Yield between chunks to avoid blocking the main thread
|
||||
await new Promise<void>((r) => {
|
||||
if (
|
||||
"scheduler" in globalThis &&
|
||||
"yield" in (globalThis as Record<string, unknown>).scheduler
|
||||
) {
|
||||
(
|
||||
(globalThis as Record<string, unknown>).scheduler as {
|
||||
yield: () => Promise<void>;
|
||||
}
|
||||
)
|
||||
.yield()
|
||||
.then(r);
|
||||
} else {
|
||||
setTimeout(r, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ── Language detection ──────────────────────────────────────────────── */
|
||||
|
||||
const EXT_TO_LANG: Record<string, string> = {
|
||||
ts: "typescript",
|
||||
@@ -77,7 +178,7 @@ function detectLang(path: string): string | null {
|
||||
return EXT_TO_LANG[ext] ?? null;
|
||||
}
|
||||
|
||||
/* ── Types ───────────────────────────────────────────────── */
|
||||
/* ── Types ───────────────────────────────────────────────────────────── */
|
||||
|
||||
export type TokenizedLine = ThemedToken[];
|
||||
/** Maps newLineNumber → highlighted tokens for that line */
|
||||
@@ -89,12 +190,23 @@ interface DiffLineInput {
|
||||
type: "added" | "removed" | "context";
|
||||
}
|
||||
|
||||
/* ── Hook ────────────────────────────────────────────────── */
|
||||
/* ── Hook ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Highlights the "new-side" content of a file diff.
|
||||
* Returns null until highlighting is ready (progressive enhancement).
|
||||
* Only context + added lines are highlighted (removed lines fall back to plain text).
|
||||
* Highlights the "new-side" content of a file diff, returning a map of
|
||||
* line number → syntax tokens.
|
||||
*
|
||||
* Progressive rendering: returns `null` while highlighting is in progress.
|
||||
* Callers (HunkRows → LineWithComments) render plain text when `null` and
|
||||
* patch in highlighted tokens on re-render once the worker or chunked call
|
||||
* resolves.
|
||||
*
|
||||
* Worker path: uses a module-level pool of 2 Web Workers. Round-robin
|
||||
* assignment. Late responses after unmount are silently discarded.
|
||||
*
|
||||
* Fallback path: if Worker construction fails (CSP, browser compat),
|
||||
* falls back to chunked main-thread highlighting via codeToTokens (200
|
||||
* lines/chunk) with scheduler.yield()/setTimeout(0) between chunks.
|
||||
*/
|
||||
export function useHighlightedFile(
|
||||
filePath: string,
|
||||
@@ -129,32 +241,37 @@ export function useHighlightedFile(
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
initWorkers(); // no-op after first call
|
||||
|
||||
getHighlighter().then((highlighter) => {
|
||||
if (cancelled || !highlighter) return;
|
||||
const id = crypto.randomUUID();
|
||||
let unmounted = false;
|
||||
const abortController = new AbortController();
|
||||
|
||||
try {
|
||||
const result = highlighter.codeToTokens(code, {
|
||||
lang: lang as Parameters<typeof highlighter.codeToTokens>[1]["lang"],
|
||||
theme: "github-dark-default",
|
||||
});
|
||||
if (workers.length > 0) {
|
||||
highlightWithWorker(id, lang, code, lineNums, filePath).then((response) => {
|
||||
if (unmounted) return; // ignore late responses after unmount
|
||||
if (response.error || response.tokens.length === 0) {
|
||||
setTokenMap(null);
|
||||
return;
|
||||
}
|
||||
const map: LineTokenMap = new Map();
|
||||
|
||||
result.tokens.forEach((lineTokens: ThemedToken[], idx: number) => {
|
||||
if (idx < lineNums.length) {
|
||||
map.set(lineNums[idx], lineTokens);
|
||||
}
|
||||
});
|
||||
|
||||
if (!cancelled) setTokenMap(map);
|
||||
} catch {
|
||||
// Language not loaded or parse error — no highlighting
|
||||
}
|
||||
});
|
||||
for (const { lineNumber, tokens } of response.tokens) {
|
||||
map.set(lineNumber, tokens);
|
||||
}
|
||||
setTokenMap(map);
|
||||
});
|
||||
} else {
|
||||
highlightChunked(code, lang, lineNums, abortController.signal).then((map) => {
|
||||
if (unmounted) return;
|
||||
setTokenMap(map.size > 0 ? map : null);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unmounted = true;
|
||||
abortController.abort();
|
||||
// Remove pending resolver so a late worker response is silently dropped
|
||||
pending.delete(id);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cacheKey]);
|
||||
|
||||
@@ -9,10 +9,10 @@ import type { ConnectionState } from '@/hooks/useConnectionStatus'
|
||||
const navItems = [
|
||||
{ label: 'HQ', to: '/hq', badgeKey: null },
|
||||
{ label: 'Initiatives', to: '/initiatives', badgeKey: null },
|
||||
{ label: 'Errands', to: '/errands', badgeKey: 'pendingErrands' as const },
|
||||
{ label: 'Agents', to: '/agents', badgeKey: 'running' as const },
|
||||
{ label: 'Inbox', to: '/inbox', badgeKey: 'questions' as const },
|
||||
{ label: 'Settings', to: '/settings', badgeKey: null },
|
||||
{ label: 'Agents', to: '/agents', badgeKey: 'running' as const },
|
||||
{ label: 'Radar', to: '/radar', badgeKey: null },
|
||||
{ label: 'Inbox', to: '/inbox', badgeKey: 'questions' as const },
|
||||
{ label: 'Settings', to: '/settings', badgeKey: null },
|
||||
] as const
|
||||
|
||||
interface AppLayoutProps {
|
||||
@@ -26,12 +26,9 @@ export function AppLayout({ children, onOpenCommandPalette, connectionState }: A
|
||||
refetchInterval: 10000,
|
||||
})
|
||||
|
||||
const errandsData = trpc.errand.list.useQuery()
|
||||
|
||||
const badgeCounts = {
|
||||
running: agents.data?.filter((a) => a.status === 'running').length ?? 0,
|
||||
questions: agents.data?.filter((a) => a.status === 'waiting_for_input').length ?? 0,
|
||||
pendingErrands: errandsData.data?.filter((e) => e.status === 'pending_review').length ?? 0,
|
||||
running: agents.data?.filter((a) => a.status === 'running').length ?? 0,
|
||||
questions: agents.data?.filter((a) => a.status === 'waiting_for_input').length ?? 0,
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -41,7 +38,7 @@ export function AppLayout({ children, onOpenCommandPalette, connectionState }: A
|
||||
<div className="flex h-12 items-center justify-between px-5">
|
||||
{/* Left: Logo + Nav */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Link to="/initiatives" className="flex items-center gap-2">
|
||||
<Link to="/hq" className="flex items-center gap-2">
|
||||
<img src="/icon-dark-48.png" alt="" className="h-7 w-7 dark:hidden" />
|
||||
<img src="/icon-light-48.png" alt="" className="hidden h-7 w-7 dark:block" />
|
||||
<span className="hidden font-display font-bold tracking-tight sm:inline">
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as RadarRouteImport } from './routes/radar'
|
||||
import { Route as InboxRouteImport } from './routes/inbox'
|
||||
import { Route as HqRouteImport } from './routes/hq'
|
||||
import { Route as AgentsRouteImport } from './routes/agents'
|
||||
@@ -25,6 +26,11 @@ const SettingsRoute = SettingsRouteImport.update({
|
||||
path: '/settings',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const RadarRoute = RadarRouteImport.update({
|
||||
id: '/radar',
|
||||
path: '/radar',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const InboxRoute = InboxRouteImport.update({
|
||||
id: '/inbox',
|
||||
path: '/inbox',
|
||||
@@ -76,6 +82,7 @@ export interface FileRoutesByFullPath {
|
||||
'/agents': typeof AgentsRoute
|
||||
'/hq': typeof HqRoute
|
||||
'/inbox': typeof InboxRoute
|
||||
'/radar': typeof RadarRoute
|
||||
'/settings': typeof SettingsRouteWithChildren
|
||||
'/initiatives/$id': typeof InitiativesIdRoute
|
||||
'/settings/health': typeof SettingsHealthRoute
|
||||
@@ -88,6 +95,7 @@ export interface FileRoutesByTo {
|
||||
'/agents': typeof AgentsRoute
|
||||
'/hq': typeof HqRoute
|
||||
'/inbox': typeof InboxRoute
|
||||
'/radar': typeof RadarRoute
|
||||
'/initiatives/$id': typeof InitiativesIdRoute
|
||||
'/settings/health': typeof SettingsHealthRoute
|
||||
'/settings/projects': typeof SettingsProjectsRoute
|
||||
@@ -100,6 +108,7 @@ export interface FileRoutesById {
|
||||
'/agents': typeof AgentsRoute
|
||||
'/hq': typeof HqRoute
|
||||
'/inbox': typeof InboxRoute
|
||||
'/radar': typeof RadarRoute
|
||||
'/settings': typeof SettingsRouteWithChildren
|
||||
'/initiatives/$id': typeof InitiativesIdRoute
|
||||
'/settings/health': typeof SettingsHealthRoute
|
||||
@@ -114,6 +123,7 @@ export interface FileRouteTypes {
|
||||
| '/agents'
|
||||
| '/hq'
|
||||
| '/inbox'
|
||||
| '/radar'
|
||||
| '/settings'
|
||||
| '/initiatives/$id'
|
||||
| '/settings/health'
|
||||
@@ -126,6 +136,7 @@ export interface FileRouteTypes {
|
||||
| '/agents'
|
||||
| '/hq'
|
||||
| '/inbox'
|
||||
| '/radar'
|
||||
| '/initiatives/$id'
|
||||
| '/settings/health'
|
||||
| '/settings/projects'
|
||||
@@ -137,6 +148,7 @@ export interface FileRouteTypes {
|
||||
| '/agents'
|
||||
| '/hq'
|
||||
| '/inbox'
|
||||
| '/radar'
|
||||
| '/settings'
|
||||
| '/initiatives/$id'
|
||||
| '/settings/health'
|
||||
@@ -150,6 +162,7 @@ export interface RootRouteChildren {
|
||||
AgentsRoute: typeof AgentsRoute
|
||||
HqRoute: typeof HqRoute
|
||||
InboxRoute: typeof InboxRoute
|
||||
RadarRoute: typeof RadarRoute
|
||||
SettingsRoute: typeof SettingsRouteWithChildren
|
||||
InitiativesIdRoute: typeof InitiativesIdRoute
|
||||
InitiativesIndexRoute: typeof InitiativesIndexRoute
|
||||
@@ -164,6 +177,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SettingsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/radar': {
|
||||
id: '/radar'
|
||||
path: '/radar'
|
||||
fullPath: '/radar'
|
||||
preLoaderRoute: typeof RadarRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/inbox': {
|
||||
id: '/inbox'
|
||||
path: '/inbox'
|
||||
@@ -251,6 +271,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
AgentsRoute: AgentsRoute,
|
||||
HqRoute: HqRoute,
|
||||
InboxRoute: InboxRoute,
|
||||
RadarRoute: RadarRoute,
|
||||
SettingsRoute: SettingsRouteWithChildren,
|
||||
InitiativesIdRoute: InitiativesIdRoute,
|
||||
InitiativesIndexRoute: InitiativesIndexRoute,
|
||||
|
||||
@@ -29,6 +29,10 @@ vi.mock('@/components/hq/HQNeedsApprovalSection', () => ({
|
||||
HQNeedsApprovalSection: ({ items }: any) => <div data-testid="needs-approval">{items.length}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hq/HQResolvingConflictsSection', () => ({
|
||||
HQResolvingConflictsSection: ({ items }: any) => <div data-testid="resolving-conflicts">{items.length}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hq/HQBlockedSection', () => ({
|
||||
HQBlockedSection: ({ items }: any) => <div data-testid="blocked">{items.length}</div>,
|
||||
}))
|
||||
@@ -45,6 +49,7 @@ const emptyData = {
|
||||
pendingReviewInitiatives: [],
|
||||
pendingReviewPhases: [],
|
||||
planningInitiatives: [],
|
||||
resolvingConflicts: [],
|
||||
blockedPhases: [],
|
||||
}
|
||||
|
||||
@@ -109,7 +114,7 @@ describe('HeadquartersPage', () => {
|
||||
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all four sections when all arrays have items', () => {
|
||||
it('renders all sections when all arrays have items', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -118,7 +123,8 @@ describe('HeadquartersPage', () => {
|
||||
pendingReviewInitiatives: [{ id: '2' }],
|
||||
pendingReviewPhases: [{ id: '3' }],
|
||||
planningInitiatives: [{ id: '4' }],
|
||||
blockedPhases: [{ id: '5' }],
|
||||
resolvingConflicts: [{ id: '5' }],
|
||||
blockedPhases: [{ id: '6' }],
|
||||
},
|
||||
})
|
||||
render(<HeadquartersPage />)
|
||||
@@ -126,6 +132,7 @@ describe('HeadquartersPage', () => {
|
||||
expect(screen.getByTestId('waiting')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('needs-approval')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('resolving-conflicts')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('blocked')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { HQWaitingForInputSection } from "@/components/hq/HQWaitingForInputSection";
|
||||
import { HQNeedsReviewSection } from "@/components/hq/HQNeedsReviewSection";
|
||||
import { HQNeedsApprovalSection } from "@/components/hq/HQNeedsApprovalSection";
|
||||
import { HQResolvingConflictsSection } from "@/components/hq/HQResolvingConflictsSection";
|
||||
import { HQBlockedSection } from "@/components/hq/HQBlockedSection";
|
||||
import { HQEmptyState } from "@/components/hq/HQEmptyState";
|
||||
|
||||
@@ -74,6 +75,7 @@ export function HeadquartersPage() {
|
||||
data.pendingReviewInitiatives.length > 0 ||
|
||||
data.pendingReviewPhases.length > 0 ||
|
||||
data.planningInitiatives.length > 0 ||
|
||||
data.resolvingConflicts.length > 0 ||
|
||||
data.blockedPhases.length > 0;
|
||||
|
||||
return (
|
||||
@@ -107,6 +109,9 @@ export function HeadquartersPage() {
|
||||
{data.planningInitiatives.length > 0 && (
|
||||
<HQNeedsApprovalSection items={data.planningInitiatives} />
|
||||
)}
|
||||
{data.resolvingConflicts.length > 0 && (
|
||||
<HQResolvingConflictsSection items={data.resolvingConflicts} />
|
||||
)}
|
||||
{data.blockedPhases.length > 0 && (
|
||||
<HQBlockedSection items={data.blockedPhases} />
|
||||
)}
|
||||
|
||||
388
apps/web/src/routes/radar.tsx
Normal file
388
apps/web/src/routes/radar.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { createFileRoute, useNavigate, useSearch, Link } from '@tanstack/react-router'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { useLiveUpdates } from '@/hooks'
|
||||
import type { LiveUpdateRule } from '@/hooks'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { CompactionEventsDialog } from '@/components/radar/CompactionEventsDialog'
|
||||
import { SubagentSpawnsDialog } from '@/components/radar/SubagentSpawnsDialog'
|
||||
import { QuestionsAskedDialog } from '@/components/radar/QuestionsAskedDialog'
|
||||
import { InterAgentMessagesDialog } from '@/components/radar/InterAgentMessagesDialog'
|
||||
|
||||
type TimeRange = '1h' | '6h' | '24h' | '7d' | 'all'
|
||||
type StatusFilter = 'all' | 'running' | 'completed' | 'crashed'
|
||||
type ModeFilter = 'all' | 'execute' | 'discuss' | 'plan' | 'detail' | 'refine' | 'chat' | 'errand'
|
||||
type SortColumn =
|
||||
| 'name'
|
||||
| 'mode'
|
||||
| 'status'
|
||||
| 'initiative'
|
||||
| 'task'
|
||||
| 'started'
|
||||
| 'questions'
|
||||
| 'messages'
|
||||
| 'subagents'
|
||||
| 'compactions'
|
||||
|
||||
const VALID_TIME_RANGES: TimeRange[] = ['1h', '6h', '24h', '7d', 'all']
|
||||
const VALID_STATUSES: StatusFilter[] = ['all', 'running', 'completed', 'crashed']
|
||||
const VALID_MODES: ModeFilter[] = [
|
||||
'all',
|
||||
'execute',
|
||||
'discuss',
|
||||
'plan',
|
||||
'detail',
|
||||
'refine',
|
||||
'chat',
|
||||
'errand',
|
||||
]
|
||||
|
||||
export const Route = createFileRoute('/radar')({
|
||||
component: RadarPage,
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
timeRange: VALID_TIME_RANGES.includes(search.timeRange as TimeRange)
|
||||
? (search.timeRange as TimeRange)
|
||||
: '24h',
|
||||
status: VALID_STATUSES.includes(search.status as StatusFilter)
|
||||
? (search.status as StatusFilter)
|
||||
: 'all',
|
||||
initiativeId: typeof search.initiativeId === 'string' ? search.initiativeId : undefined,
|
||||
mode: VALID_MODES.includes(search.mode as ModeFilter) ? (search.mode as ModeFilter) : 'all',
|
||||
}),
|
||||
})
|
||||
|
||||
const RADAR_LIVE_UPDATE_RULES: LiveUpdateRule[] = [
|
||||
{ prefix: 'agent:waiting', invalidate: ['agent'] },
|
||||
{ prefix: 'conversation:created', invalidate: ['agent'] },
|
||||
{ prefix: 'agent:stopped', invalidate: ['agent'] },
|
||||
{ prefix: 'agent:crashed', invalidate: ['agent'] },
|
||||
]
|
||||
|
||||
export function RadarPage() {
|
||||
const { timeRange, status, initiativeId, mode } = useSearch({ from: '/radar' }) as {
|
||||
timeRange: TimeRange
|
||||
status: StatusFilter
|
||||
initiativeId: string | undefined
|
||||
mode: ModeFilter
|
||||
}
|
||||
const navigate = useNavigate()
|
||||
|
||||
useLiveUpdates(RADAR_LIVE_UPDATE_RULES)
|
||||
|
||||
const { data: agents = [], isLoading } = trpc.agent.listForRadar.useQuery({
|
||||
timeRange,
|
||||
status: status === 'all' ? undefined : status,
|
||||
initiativeId: initiativeId ?? undefined,
|
||||
mode: mode === 'all' ? undefined : mode,
|
||||
})
|
||||
|
||||
const { data: initiatives = [] } = trpc.listInitiatives.useQuery()
|
||||
|
||||
type DrilldownType = 'questions' | 'messages' | 'subagents' | 'compactions'
|
||||
|
||||
const [drilldown, setDrilldown] = useState<{
|
||||
type: DrilldownType
|
||||
agentId: string
|
||||
agentName: string
|
||||
} | null>(null)
|
||||
|
||||
const [sortState, setSortState] = useState<{ column: SortColumn; direction: 'asc' | 'desc' }>({
|
||||
column: 'started',
|
||||
direction: 'desc',
|
||||
})
|
||||
|
||||
function handleSort(column: SortColumn) {
|
||||
setSortState((prev) =>
|
||||
prev.column === column
|
||||
? { column, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
|
||||
: { column, direction: 'asc' },
|
||||
)
|
||||
}
|
||||
|
||||
const sortedAgents = useMemo(() => {
|
||||
return [...agents].sort((a, b) => {
|
||||
let cmp = 0
|
||||
switch (sortState.column) {
|
||||
case 'name':
|
||||
cmp = a.name.localeCompare(b.name)
|
||||
break
|
||||
case 'mode':
|
||||
cmp = a.mode.localeCompare(b.mode)
|
||||
break
|
||||
case 'status':
|
||||
cmp = a.status.localeCompare(b.status)
|
||||
break
|
||||
case 'initiative':
|
||||
cmp = (a.initiativeName ?? '').localeCompare(b.initiativeName ?? '')
|
||||
break
|
||||
case 'task':
|
||||
cmp = (a.taskName ?? '').localeCompare(b.taskName ?? '')
|
||||
break
|
||||
case 'started':
|
||||
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
break
|
||||
case 'questions':
|
||||
cmp = a.questionsCount - b.questionsCount
|
||||
break
|
||||
case 'messages':
|
||||
cmp = a.messagesCount - b.messagesCount
|
||||
break
|
||||
case 'subagents':
|
||||
cmp = a.subagentsCount - b.subagentsCount
|
||||
break
|
||||
case 'compactions':
|
||||
cmp = a.compactionsCount - b.compactionsCount
|
||||
break
|
||||
}
|
||||
return sortState.direction === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [agents, sortState])
|
||||
|
||||
const totalQuestions = agents.reduce((sum, a) => sum + a.questionsCount, 0)
|
||||
const totalMessages = agents.reduce((sum, a) => sum + a.messagesCount, 0)
|
||||
const totalSubagents = agents.reduce((sum, a) => sum + a.subagentsCount, 0)
|
||||
const totalCompactions = agents.reduce((sum, a) => sum + a.compactionsCount, 0)
|
||||
|
||||
function sortIndicator(column: SortColumn) {
|
||||
if (sortState.column !== column) return null
|
||||
return sortState.direction === 'asc' ? ' ▲' : ' ▼'
|
||||
}
|
||||
|
||||
function SortableTh({
|
||||
column,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
column: SortColumn
|
||||
label: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<th
|
||||
className={`cursor-pointer select-none whitespace-nowrap px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground ${className ?? ''}`}
|
||||
onClick={() => handleSort(column)}
|
||||
>
|
||||
{label}
|
||||
{sortIndicator(column)}
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
const isAgentRunning = drilldown
|
||||
? agents.find((a) => a.id === drilldown.agentId)?.status === 'running'
|
||||
: false
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Radar</h1>
|
||||
|
||||
{/* Summary stat cards */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-3xl font-bold">{totalQuestions}</p>
|
||||
<p className="text-sm text-muted-foreground">Total Questions Asked</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-3xl font-bold">{totalMessages}</p>
|
||||
<p className="text-sm text-muted-foreground">Total Inter-Agent Messages</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-3xl font-bold">{totalSubagents}</p>
|
||||
<p className="text-sm text-muted-foreground">Total Subagent Spawns</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-3xl font-bold">{totalCompactions}</p>
|
||||
<p className="text-sm text-muted-foreground">Total Compaction Events</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="flex gap-4 items-center">
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value as TimeRange
|
||||
navigate({ search: (prev) => ({ ...prev, timeRange: val }) })
|
||||
}}
|
||||
>
|
||||
<option value="1h">Last 1h</option>
|
||||
<option value="6h">Last 6h</option>
|
||||
<option value="24h">Last 24h</option>
|
||||
<option value="7d">Last 7d</option>
|
||||
<option value="all">All time</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value as StatusFilter
|
||||
navigate({ search: (prev) => ({ ...prev, status: val }) })
|
||||
}}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="crashed">Crashed</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={initiativeId ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
navigate({
|
||||
search: (prev) => ({ ...prev, initiativeId: val === '' ? undefined : val }),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<option value="">All Initiatives</option>
|
||||
{initiatives.map((ini: { id: string; name: string }) => (
|
||||
<option key={ini.id} value={ini.id}>
|
||||
{ini.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value as ModeFilter
|
||||
navigate({ search: (prev) => ({ ...prev, mode: val }) })
|
||||
}}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="execute">Execute</option>
|
||||
<option value="discuss">Discuss</option>
|
||||
<option value="plan">Plan</option>
|
||||
<option value="detail">Detail</option>
|
||||
<option value="refine">Refine</option>
|
||||
<option value="chat">Chat</option>
|
||||
<option value="errand">Errand</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && agents.length === 0 && (
|
||||
<p className="text-center text-muted-foreground">No agent activity in this time period</p>
|
||||
)}
|
||||
|
||||
{/* Agent activity table */}
|
||||
{(isLoading || agents.length > 0) && (
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<SortableTh column="name" label="Agent Name" />
|
||||
<SortableTh column="mode" label="Mode" />
|
||||
<SortableTh column="status" label="Status" />
|
||||
<SortableTh column="initiative" label="Initiative" />
|
||||
<SortableTh column="task" label="Task" />
|
||||
<SortableTh column="started" label="Started" />
|
||||
<SortableTh column="questions" label="Questions" className="text-right" />
|
||||
<SortableTh column="messages" label="Messages" className="text-right" />
|
||||
<SortableTh column="subagents" label="Subagents" className="text-right" />
|
||||
<SortableTh column="compactions" label="Compactions" className="text-right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading
|
||||
? Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td colSpan={10} className="px-3 py-2">
|
||||
<div className="h-4 bg-muted rounded animate-pulse" />
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
: sortedAgents.map((agent) => (
|
||||
<tr key={agent.id} className="border-b border-border/50 hover:bg-muted/30">
|
||||
<td className="px-3 py-2">
|
||||
<Link to="/agents" search={{ selected: agent.id }}>
|
||||
{agent.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2">{agent.mode}</td>
|
||||
<td className="px-3 py-2">{agent.status}</td>
|
||||
<td className="px-3 py-2">{agent.initiativeName ?? '—'}</td>
|
||||
<td className="px-3 py-2">{agent.taskName ?? '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
{new Date(agent.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td
|
||||
data-testid={`cell-questions-${agent.id}`}
|
||||
className={agent.questionsCount > 0 ? 'cursor-pointer hover:bg-muted/50 text-right px-3 py-2' : 'text-muted-foreground text-right px-3 py-2'}
|
||||
onClick={agent.questionsCount > 0
|
||||
? () => setDrilldown({ type: 'questions', agentId: agent.id, agentName: agent.name })
|
||||
: undefined}
|
||||
>
|
||||
{agent.questionsCount}
|
||||
</td>
|
||||
<td
|
||||
data-testid={`cell-messages-${agent.id}`}
|
||||
className={agent.messagesCount > 0 ? 'cursor-pointer hover:bg-muted/50 text-right px-3 py-2' : 'text-muted-foreground text-right px-3 py-2'}
|
||||
onClick={agent.messagesCount > 0
|
||||
? () => setDrilldown({ type: 'messages', agentId: agent.id, agentName: agent.name })
|
||||
: undefined}
|
||||
>
|
||||
{agent.messagesCount}
|
||||
</td>
|
||||
<td
|
||||
data-testid={`cell-subagents-${agent.id}`}
|
||||
className={agent.subagentsCount > 0 ? 'cursor-pointer hover:bg-muted/50 text-right px-3 py-2' : 'text-muted-foreground text-right px-3 py-2'}
|
||||
onClick={agent.subagentsCount > 0
|
||||
? () => setDrilldown({ type: 'subagents', agentId: agent.id, agentName: agent.name })
|
||||
: undefined}
|
||||
>
|
||||
{agent.subagentsCount}
|
||||
</td>
|
||||
<td
|
||||
data-testid={`cell-compactions-${agent.id}`}
|
||||
className={agent.compactionsCount > 0 ? 'cursor-pointer hover:bg-muted/50 text-right px-3 py-2' : 'text-muted-foreground text-right px-3 py-2'}
|
||||
onClick={agent.compactionsCount > 0
|
||||
? () => setDrilldown({ type: 'compactions', agentId: agent.id, agentName: agent.name })
|
||||
: undefined}
|
||||
>
|
||||
{agent.compactionsCount}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<CompactionEventsDialog
|
||||
open={drilldown?.type === 'compactions'}
|
||||
onOpenChange={(open) => { if (!open) setDrilldown(null) }}
|
||||
agentId={drilldown?.agentId ?? ''}
|
||||
agentName={drilldown?.agentName ?? ''}
|
||||
isAgentRunning={isAgentRunning}
|
||||
/>
|
||||
<SubagentSpawnsDialog
|
||||
open={drilldown?.type === 'subagents'}
|
||||
onOpenChange={(open) => { if (!open) setDrilldown(null) }}
|
||||
agentId={drilldown?.agentId ?? ''}
|
||||
agentName={drilldown?.agentName ?? ''}
|
||||
isAgentRunning={isAgentRunning}
|
||||
/>
|
||||
<QuestionsAskedDialog
|
||||
open={drilldown?.type === 'questions'}
|
||||
onOpenChange={(open) => { if (!open) setDrilldown(null) }}
|
||||
agentId={drilldown?.agentId ?? ''}
|
||||
agentName={drilldown?.agentName ?? ''}
|
||||
isAgentRunning={isAgentRunning}
|
||||
/>
|
||||
<InterAgentMessagesDialog
|
||||
open={drilldown?.type === 'messages'}
|
||||
onOpenChange={(open) => { if (!open) setDrilldown(null) }}
|
||||
agentId={drilldown?.agentId ?? ''}
|
||||
agentName={drilldown?.agentName ?? ''}
|
||||
isAgentRunning={isAgentRunning}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -4,13 +4,27 @@ import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [TanStackRouterVite({ autoCodeSplitting: true }), react()],
|
||||
plugins: [
|
||||
TanStackRouterVite({
|
||||
autoCodeSplitting: true,
|
||||
routeFileIgnorePattern: '__tests__',
|
||||
}),
|
||||
react(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
// ES module workers are required when the app uses code-splitting (Rollup
|
||||
// can't bundle IIFE workers alongside dynamic imports).
|
||||
format: "es",
|
||||
},
|
||||
server: {
|
||||
watch: {
|
||||
ignored: ['**/routeTree.gen.ts'],
|
||||
},
|
||||
proxy: {
|
||||
"/trpc": {
|
||||
target: "http://127.0.0.1:3847",
|
||||
|
||||
Reference in New Issue
Block a user