@tonyclaw/agent-inspector 2.0.30 → 2.0.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-BDzfRSt1.js → CompareDrawer-tIUf2EJm.js} +1 -1
  3. package/.output/public/assets/ProxyViewerContainer-BxRaXhKR.js +115 -0
  4. package/.output/public/assets/{ReplayDialog-p-5TKg75.js → ReplayDialog-D6v1xcuC.js} +1 -1
  5. package/.output/public/assets/{RequestAnatomy-G0dV8-D_.js → RequestAnatomy-D6PniTlx.js} +1 -1
  6. package/.output/public/assets/{ResponseView-D2wE6lD-.js → ResponseView-Sx8PjuvU.js} +1 -1
  7. package/.output/public/assets/{StreamingChunkSequence-D8JRK-j_.js → StreamingChunkSequence-D-Rbfnxh.js} +1 -1
  8. package/.output/public/assets/_sessionId-BhNTuZ31.js +1 -0
  9. package/.output/public/assets/index-Byk60-jA.css +1 -0
  10. package/.output/public/assets/index-Kxptlkpg.js +1 -0
  11. package/.output/public/assets/{main-XHHZsOBK.js → main-B1kdhY98.js} +2 -2
  12. package/.output/server/_libs/lucide-react.mjs +159 -134
  13. package/.output/server/{_sessionId-CK9QEhkB.mjs → _sessionId-BceHFKf4.mjs} +2 -2
  14. package/.output/server/_ssr/{CompareDrawer-BJVPFMIx.mjs → CompareDrawer-uSxK5Ei5.mjs} +3 -3
  15. package/.output/server/_ssr/{ProxyViewerContainer-DhbmJ2zj.mjs → ProxyViewerContainer-_npQ_f2i.mjs} +566 -17
  16. package/.output/server/_ssr/{ReplayDialog-DXRj95Gj.mjs → ReplayDialog-BHAzSLCT.mjs} +4 -4
  17. package/.output/server/_ssr/{RequestAnatomy-CrYJTXdJ.mjs → RequestAnatomy-DjlZLXBd.mjs} +3 -3
  18. package/.output/server/_ssr/{ResponseView-4ESqSITr.mjs → ResponseView-BuDEJ3fl.mjs} +3 -3
  19. package/.output/server/_ssr/{StreamingChunkSequence-Czdko5n_.mjs → StreamingChunkSequence-DFcu0bD8.mjs} +3 -3
  20. package/.output/server/_ssr/{index-bei0I7qL.mjs → index-Bw6Fw33c.mjs} +2 -2
  21. package/.output/server/_ssr/index.mjs +2 -2
  22. package/.output/server/_ssr/{router-sQh4Gk8J.mjs → router-D90tMCb3.mjs} +50 -15
  23. package/.output/server/{_tanstack-start-manifest_v-C0fdSWnA.mjs → _tanstack-start-manifest_v-BVM4AUlx.mjs} +1 -1
  24. package/.output/server/index.mjs +56 -56
  25. package/package.json +1 -1
  26. package/src/components/ProxyViewer.tsx +9 -2
  27. package/src/components/groups/GroupsDialog.tsx +732 -0
  28. package/src/lib/groupContract.ts +11 -2
  29. package/src/lib/runContract.ts +2 -0
  30. package/src/lib/useGroupEvidence.ts +33 -0
  31. package/src/lib/useGroups.ts +28 -0
  32. package/src/mcp/server.ts +4 -1
  33. package/src/proxy/groupEvidenceExporter.ts +13 -0
  34. package/src/routes/api/groups.$groupId.evidence.ts +14 -2
  35. package/.output/public/assets/ProxyViewerContainer-NtOD8Ex_.js +0 -115
  36. package/.output/public/assets/_sessionId-zimzVrJE.js +0 -1
  37. package/.output/public/assets/index-Cv5vPgpr.js +0 -1
  38. package/.output/public/assets/index-CwlHPmgL.css +0 -1
@@ -0,0 +1,732 @@
1
+ import { type JSX, useCallback, useEffect, useMemo, useState } from "react";
2
+ import { Check, Copy, Download, ExternalLink, Layers, Loader2, RefreshCw } from "lucide-react";
3
+ import { Button } from "../ui/button";
4
+ import { Badge } from "../ui/badge";
5
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog";
6
+ import { cn } from "../../lib/utils";
7
+ import { parseJsonResponse, readApiError } from "../../lib/apiClient";
8
+ import {
9
+ GroupEvidenceExportResultSchema,
10
+ type GroupEvidenceReadResponse,
11
+ type GroupEvidenceExportResult,
12
+ type InspectorGroup,
13
+ type InspectorGroupEvidence,
14
+ type InspectorGroupExportMember,
15
+ type InspectorGroupMember,
16
+ } from "../../lib/groupContract";
17
+ import type { EvidenceReport } from "../../lib/runContract";
18
+ import type { SessionLogSummary } from "../../lib/sessionInfoContract";
19
+ import { getSessionPath } from "../../lib/sessionUrl";
20
+ import { useGroupEvidence } from "../../lib/useGroupEvidence";
21
+ import { useGroups } from "../../lib/useGroups";
22
+ import { useCopyFeedback } from "../proxy-viewer/useCopyFeedback";
23
+
24
+ type MemberStatus = InspectorGroupMember["status"];
25
+
26
+ type GroupSummary = {
27
+ memberCount: number;
28
+ completedMembers: number;
29
+ failedMembers: number;
30
+ runningMembers: number;
31
+ missingStatusMembers: number;
32
+ };
33
+
34
+ type GroupMemberRow = {
35
+ member: InspectorGroupMember;
36
+ exportMember: InspectorGroupExportMember | null;
37
+ };
38
+
39
+ export function summarizeGroup(group: InspectorGroup): GroupSummary {
40
+ let completedMembers = 0;
41
+ let failedMembers = 0;
42
+ let runningMembers = 0;
43
+ let missingStatusMembers = 0;
44
+
45
+ for (const member of group.members) {
46
+ switch (member.status) {
47
+ case "completed":
48
+ completedMembers += 1;
49
+ break;
50
+ case "failed":
51
+ case "cancelled":
52
+ failedMembers += 1;
53
+ break;
54
+ case "running":
55
+ runningMembers += 1;
56
+ break;
57
+ case "created":
58
+ break;
59
+ case null:
60
+ missingStatusMembers += 1;
61
+ break;
62
+ }
63
+ }
64
+
65
+ return {
66
+ memberCount: group.members.length,
67
+ completedMembers,
68
+ failedMembers,
69
+ runningMembers,
70
+ missingStatusMembers,
71
+ };
72
+ }
73
+
74
+ export function buildGroupMemberRows(
75
+ group: InspectorGroup,
76
+ exportResult: GroupEvidenceExportResult | null,
77
+ ): GroupMemberRow[] {
78
+ if (exportResult !== null && exportResult.group.id === group.id) {
79
+ return exportResult.members.map((exportMember) => ({
80
+ member: exportMember.member,
81
+ exportMember,
82
+ }));
83
+ }
84
+
85
+ return group.members.map((member) => ({ member, exportMember: null }));
86
+ }
87
+
88
+ function statusClassName(status: InspectorGroup["status"] | MemberStatus): string {
89
+ switch (status) {
90
+ case "completed":
91
+ return "border-emerald-200 bg-emerald-50 text-emerald-700";
92
+ case "failed":
93
+ case "cancelled":
94
+ return "border-red-200 bg-red-50 text-red-700";
95
+ case "running":
96
+ return "border-sky-200 bg-sky-50 text-sky-700";
97
+ case "created":
98
+ return "border-zinc-200 bg-zinc-50 text-zinc-600";
99
+ case null:
100
+ return "border-zinc-200 bg-zinc-50 text-zinc-500";
101
+ }
102
+ }
103
+
104
+ function statusLabel(status: InspectorGroup["status"] | MemberStatus): string {
105
+ return status ?? "unknown";
106
+ }
107
+
108
+ function reportStatusClassName(status: EvidenceReport["status"]): string {
109
+ switch (status) {
110
+ case "PASS":
111
+ return "border-emerald-200 bg-emerald-50 text-emerald-700";
112
+ case "FAIL":
113
+ return "border-red-200 bg-red-50 text-red-700";
114
+ case "RUNNING":
115
+ return "border-sky-200 bg-sky-50 text-sky-700";
116
+ case "UNKNOWN":
117
+ return "border-zinc-200 bg-zinc-50 text-zinc-600";
118
+ }
119
+ }
120
+
121
+ function displayText(value: string | null): string {
122
+ if (value === null || value.trim().length === 0) return "-";
123
+ return value;
124
+ }
125
+
126
+ function formatNumber(value: number): string {
127
+ return value.toLocaleString();
128
+ }
129
+
130
+ function formatMs(value: number | null | undefined): string {
131
+ if (value === null || value === undefined) return "-";
132
+ if (value < 1000) return `${formatNumber(value)} ms`;
133
+ return `${(value / 1000).toFixed(1)} s`;
134
+ }
135
+
136
+ function formatTimestamp(value: string): string {
137
+ const millis = Date.parse(value);
138
+ if (!Number.isFinite(millis)) return value;
139
+ return new Intl.DateTimeFormat(undefined, {
140
+ month: "short",
141
+ day: "2-digit",
142
+ hour: "2-digit",
143
+ minute: "2-digit",
144
+ hour12: false,
145
+ }).format(new Date(millis));
146
+ }
147
+
148
+ function firstNonEmpty(...values: Array<string | null>): string {
149
+ for (const value of values) {
150
+ if (value !== null && value.trim().length > 0) return value;
151
+ }
152
+ return "-";
153
+ }
154
+
155
+ function providerModel(member: InspectorGroupMember): string {
156
+ const provider = member.provider;
157
+ const model = member.model;
158
+
159
+ if (provider !== null && provider.length > 0 && model !== null && model.length > 0) {
160
+ return `${provider} / ${model}`;
161
+ }
162
+ return firstNonEmpty(provider, model);
163
+ }
164
+
165
+ function selectedGroupFrom(
166
+ groups: InspectorGroup[],
167
+ selectedGroupId: string | null,
168
+ ): InspectorGroup | null {
169
+ if (selectedGroupId !== null) {
170
+ const selected = groups.find((group) => group.id === selectedGroupId);
171
+ if (selected !== undefined) return selected;
172
+ }
173
+ return groups[0] ?? null;
174
+ }
175
+
176
+ function latestLog(row: GroupMemberRow): SessionLogSummary | null {
177
+ const logs = row.exportMember?.session?.latestLogs ?? [];
178
+ return logs[0] ?? null;
179
+ }
180
+
181
+ function evidenceFor(
182
+ group: InspectorGroup,
183
+ exportResult: GroupEvidenceExportResult | null,
184
+ ): InspectorGroupEvidence | null {
185
+ if (exportResult !== null && exportResult.group.id === group.id) return exportResult.evidence;
186
+ return group.evidence;
187
+ }
188
+
189
+ function exportSummaryFor(
190
+ group: InspectorGroup,
191
+ exportResult: GroupEvidenceExportResult | null,
192
+ evidenceResponse: GroupEvidenceReadResponse | null,
193
+ ): GroupEvidenceExportResult["summary"] | null {
194
+ if (exportResult !== null && exportResult.group.id === group.id) return exportResult.summary;
195
+ if (evidenceResponse !== null && evidenceResponse.group.id === group.id) {
196
+ return evidenceResponse.summary;
197
+ }
198
+ return null;
199
+ }
200
+
201
+ function evidenceReportFor(
202
+ group: InspectorGroup,
203
+ exportResult: GroupEvidenceExportResult | null,
204
+ evidenceResponse: GroupEvidenceReadResponse | null,
205
+ ): EvidenceReport | null {
206
+ if (exportResult !== null && exportResult.group.id === group.id) {
207
+ return exportResult.jenkinsReport;
208
+ }
209
+ if (evidenceResponse !== null && evidenceResponse.group.id === group.id) {
210
+ return evidenceResponse.report;
211
+ }
212
+ return null;
213
+ }
214
+
215
+ export function GroupsDialog(): JSX.Element {
216
+ const [open, setOpen] = useState(false);
217
+ const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
218
+ const [exportingGroupId, setExportingGroupId] = useState<string | null>(null);
219
+ const [exportError, setExportError] = useState<string | null>(null);
220
+ const [exportResult, setExportResult] = useState<GroupEvidenceExportResult | null>(null);
221
+ const { groups, isLoading, error, mutate } = useGroups();
222
+
223
+ const selectedGroup = useMemo(
224
+ () => selectedGroupFrom(groups, selectedGroupId),
225
+ [groups, selectedGroupId],
226
+ );
227
+
228
+ useEffect(() => {
229
+ if (!open) return;
230
+ const hasSelected =
231
+ selectedGroupId !== null && groups.some((group) => group.id === selectedGroupId);
232
+ if (hasSelected) return;
233
+ setSelectedGroupId(groups[0]?.id ?? null);
234
+ }, [groups, open, selectedGroupId]);
235
+
236
+ const selectedExportResult =
237
+ selectedGroup !== null && exportResult !== null && exportResult.group.id === selectedGroup.id
238
+ ? exportResult
239
+ : null;
240
+ const {
241
+ evidenceResponse,
242
+ isLoading: evidenceLoading,
243
+ error: evidenceError,
244
+ mutate: mutateEvidence,
245
+ } = useGroupEvidence(selectedGroup);
246
+
247
+ const handleRefresh = useCallback(() => {
248
+ void mutate();
249
+ }, [mutate]);
250
+
251
+ const handleExport = useCallback(
252
+ async (group: InspectorGroup) => {
253
+ setExportingGroupId(group.id);
254
+ setExportError(null);
255
+
256
+ try {
257
+ const response = await fetch(`/api/groups/${encodeURIComponent(group.id)}/evidence`, {
258
+ method: "POST",
259
+ headers: { "content-type": "application/json" },
260
+ body: JSON.stringify({ latestLogLimit: 10 }),
261
+ });
262
+
263
+ if (!response.ok) {
264
+ setExportError(
265
+ await readApiError(response, `Export failed with status ${String(response.status)}`),
266
+ );
267
+ return;
268
+ }
269
+
270
+ const parsed = await parseJsonResponse(response, GroupEvidenceExportResultSchema);
271
+ setExportResult(parsed);
272
+ await mutate();
273
+ await mutateEvidence();
274
+ } catch (err) {
275
+ setExportError(err instanceof Error ? err.message : String(err));
276
+ } finally {
277
+ setExportingGroupId(null);
278
+ }
279
+ },
280
+ [mutate, mutateEvidence],
281
+ );
282
+
283
+ return (
284
+ <Dialog open={open} onOpenChange={setOpen}>
285
+ <DialogTrigger asChild>
286
+ <Button
287
+ type="button"
288
+ variant="ghost"
289
+ size="icon"
290
+ className="relative size-8"
291
+ aria-label="Evaluation groups"
292
+ title="Evaluation groups"
293
+ >
294
+ <Layers className="size-4" />
295
+ {isLoading ? (
296
+ <Loader2 className="absolute -right-0.5 -top-0.5 size-3 animate-spin text-muted-foreground" />
297
+ ) : groups.length > 0 ? (
298
+ <span className="absolute -right-1 -top-1 min-w-4 rounded-full bg-primary px-1 text-[10px] leading-4 text-primary-foreground">
299
+ {groups.length}
300
+ </span>
301
+ ) : null}
302
+ <span className="sr-only">Evaluation groups</span>
303
+ </Button>
304
+ </DialogTrigger>
305
+ <DialogContent className="flex max-h-[85vh] max-w-5xl flex-col overflow-hidden">
306
+ <DialogHeader>
307
+ <DialogTitle className="flex items-center gap-2 pr-8">
308
+ <Layers className="size-5 text-muted-foreground" />
309
+ <span>Groups</span>
310
+ <Badge variant="outline">{formatNumber(groups.length)}</Badge>
311
+ </DialogTitle>
312
+ </DialogHeader>
313
+
314
+ <div className="flex min-h-0 items-center justify-between gap-3 border-b border-border pb-3">
315
+ <p className="text-xs text-muted-foreground">
316
+ Evaluation batches, model runs, and exported evidence packs.
317
+ </p>
318
+ <Button
319
+ type="button"
320
+ variant="outline"
321
+ size="sm"
322
+ className="shrink-0"
323
+ onClick={handleRefresh}
324
+ >
325
+ <RefreshCw className="size-3.5" />
326
+ Refresh
327
+ </Button>
328
+ </div>
329
+
330
+ <div className="grid min-h-0 flex-1 gap-4 md:grid-cols-[260px_minmax(0,1fr)]">
331
+ <GroupList
332
+ groups={groups}
333
+ selectedGroupId={selectedGroup?.id ?? null}
334
+ isLoading={isLoading}
335
+ error={error}
336
+ onSelect={setSelectedGroupId}
337
+ />
338
+ <GroupDetails
339
+ group={selectedGroup}
340
+ exportResult={selectedExportResult}
341
+ evidenceResponse={evidenceResponse}
342
+ evidenceLoading={evidenceLoading}
343
+ evidenceError={evidenceError}
344
+ exportError={exportError}
345
+ exportingGroupId={exportingGroupId}
346
+ onExport={handleExport}
347
+ />
348
+ </div>
349
+ </DialogContent>
350
+ </Dialog>
351
+ );
352
+ }
353
+
354
+ function GroupList({
355
+ groups,
356
+ selectedGroupId,
357
+ isLoading,
358
+ error,
359
+ onSelect,
360
+ }: {
361
+ groups: InspectorGroup[];
362
+ selectedGroupId: string | null;
363
+ isLoading: boolean;
364
+ error: Error | undefined;
365
+ onSelect: (groupId: string) => void;
366
+ }): JSX.Element {
367
+ if (isLoading) {
368
+ return (
369
+ <div className="flex min-h-48 items-center justify-center rounded-md border border-dashed border-border text-xs text-muted-foreground">
370
+ Loading groups...
371
+ </div>
372
+ );
373
+ }
374
+
375
+ if (error !== undefined) {
376
+ return (
377
+ <div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
378
+ {error.message}
379
+ </div>
380
+ );
381
+ }
382
+
383
+ if (groups.length === 0) {
384
+ return (
385
+ <div className="rounded-md border border-dashed border-border px-3 py-8 text-center text-xs text-muted-foreground">
386
+ No groups yet.
387
+ </div>
388
+ );
389
+ }
390
+
391
+ return (
392
+ <div className="min-h-0 overflow-y-auto pr-1">
393
+ <div className="space-y-2">
394
+ {groups.map((group) => {
395
+ const summary = summarizeGroup(group);
396
+ const selected = selectedGroupId === group.id;
397
+ return (
398
+ <button
399
+ key={group.id}
400
+ type="button"
401
+ onClick={() => onSelect(group.id)}
402
+ className={cn(
403
+ "w-full rounded-md border px-3 py-2 text-left text-xs transition-colors",
404
+ selected
405
+ ? "border-primary bg-primary/5"
406
+ : "border-border bg-background hover:bg-muted/50",
407
+ )}
408
+ >
409
+ <div className="flex min-w-0 items-center justify-between gap-2">
410
+ <span className="min-w-0 truncate text-sm font-medium">{group.title}</span>
411
+ <Badge variant="outline" className={statusClassName(group.status)}>
412
+ {statusLabel(group.status)}
413
+ </Badge>
414
+ </div>
415
+ <div className="mt-1 flex items-center gap-2 text-muted-foreground">
416
+ <span>{summary.memberCount} sessions</span>
417
+ <span>{formatTimestamp(group.updatedAt)}</span>
418
+ </div>
419
+ </button>
420
+ );
421
+ })}
422
+ </div>
423
+ </div>
424
+ );
425
+ }
426
+
427
+ function GroupDetails({
428
+ group,
429
+ exportResult,
430
+ evidenceResponse,
431
+ evidenceLoading,
432
+ evidenceError,
433
+ exportError,
434
+ exportingGroupId,
435
+ onExport,
436
+ }: {
437
+ group: InspectorGroup | null;
438
+ exportResult: GroupEvidenceExportResult | null;
439
+ evidenceResponse: GroupEvidenceReadResponse | null;
440
+ evidenceLoading: boolean;
441
+ evidenceError: Error | undefined;
442
+ exportError: string | null;
443
+ exportingGroupId: string | null;
444
+ onExport: (group: InspectorGroup) => Promise<void>;
445
+ }): JSX.Element {
446
+ if (group === null) {
447
+ return (
448
+ <div className="flex min-h-48 items-center justify-center rounded-md border border-dashed border-border text-xs text-muted-foreground">
449
+ Select a group to inspect.
450
+ </div>
451
+ );
452
+ }
453
+
454
+ const summary = summarizeGroup(group);
455
+ const exportedSummary = exportSummaryFor(group, exportResult, evidenceResponse);
456
+ const evidenceReport = evidenceReportFor(group, exportResult, evidenceResponse);
457
+ const evidence = evidenceFor(group, exportResult);
458
+ const rows = buildGroupMemberRows(group, exportResult);
459
+ const isExporting = exportingGroupId === group.id;
460
+
461
+ return (
462
+ <div className="min-h-0 overflow-y-auto pr-1">
463
+ <div className="space-y-4">
464
+ <div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
465
+ <div className="min-w-0 space-y-1">
466
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
467
+ <h3 className="min-w-0 truncate text-base font-semibold">{group.title}</h3>
468
+ <Badge variant="outline" className={statusClassName(group.status)}>
469
+ {statusLabel(group.status)}
470
+ </Badge>
471
+ <Badge variant="outline">{group.kind}</Badge>
472
+ </div>
473
+ <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
474
+ <span>{displayText(group.project)}</span>
475
+ <span>updated {formatTimestamp(group.updatedAt)}</span>
476
+ </div>
477
+ </div>
478
+ <Button
479
+ type="button"
480
+ variant="default"
481
+ size="sm"
482
+ className="shrink-0"
483
+ disabled={isExporting}
484
+ onClick={() => void onExport(group)}
485
+ >
486
+ {isExporting ? (
487
+ <Loader2 className="size-3.5 animate-spin" />
488
+ ) : (
489
+ <Download className="size-3.5" />
490
+ )}
491
+ Export evidence
492
+ </Button>
493
+ </div>
494
+
495
+ {group.task !== null && group.task.trim().length > 0 && (
496
+ <p className="rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
497
+ {group.task}
498
+ </p>
499
+ )}
500
+
501
+ <div className="grid gap-2 sm:grid-cols-4">
502
+ <SummaryStat label="Members" value={summary.memberCount} />
503
+ <SummaryStat label="Done" value={summary.completedMembers} />
504
+ <SummaryStat label="Failed" value={summary.failedMembers} />
505
+ <SummaryStat label="Running" value={summary.runningMembers} />
506
+ </div>
507
+
508
+ {exportedSummary !== null && (
509
+ <div className="grid gap-2 sm:grid-cols-4">
510
+ <SummaryStat label="Requests" value={exportedSummary.requestCount} />
511
+ <SummaryStat label="Errors" value={exportedSummary.errorCount} />
512
+ <SummaryStat label="Tokens" value={exportedSummary.tokenUsage.total} />
513
+ <SummaryStat label="Missing" value={exportedSummary.missingSessions} />
514
+ </div>
515
+ )}
516
+
517
+ <EvidenceReportCard
518
+ report={evidenceReport}
519
+ loading={evidenceLoading}
520
+ error={evidenceError}
521
+ hasEvidence={evidence !== null}
522
+ />
523
+
524
+ <EvidencePaths evidence={evidence} />
525
+
526
+ {exportError !== null && (
527
+ <div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
528
+ {exportError}
529
+ </div>
530
+ )}
531
+
532
+ <MemberTable rows={rows} />
533
+ </div>
534
+ </div>
535
+ );
536
+ }
537
+
538
+ function SummaryStat({ label, value }: { label: string; value: number }): JSX.Element {
539
+ return (
540
+ <div className="rounded-md border border-border bg-muted/20 px-3 py-2">
541
+ <div className="text-[11px] font-medium uppercase text-muted-foreground">{label}</div>
542
+ <div className="mt-1 font-mono text-sm font-semibold">{formatNumber(value)}</div>
543
+ </div>
544
+ );
545
+ }
546
+
547
+ function EvidenceReportCard({
548
+ report,
549
+ loading,
550
+ error,
551
+ hasEvidence,
552
+ }: {
553
+ report: EvidenceReport | null;
554
+ loading: boolean;
555
+ error: Error | undefined;
556
+ hasEvidence: boolean;
557
+ }): JSX.Element | null {
558
+ const { copied, copy } = useCopyFeedback(report?.markdown ?? null);
559
+
560
+ if (report === null && !loading && error === undefined) return null;
561
+
562
+ if (loading) {
563
+ return (
564
+ <div className="flex items-center gap-2 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
565
+ <Loader2 className="size-3.5 animate-spin" />
566
+ Loading evidence report...
567
+ </div>
568
+ );
569
+ }
570
+
571
+ if (error !== undefined && hasEvidence) {
572
+ return (
573
+ <div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
574
+ Failed to load evidence report: {error.message}
575
+ </div>
576
+ );
577
+ }
578
+
579
+ if (report === null) return null;
580
+
581
+ return (
582
+ <div className="rounded-md border border-border bg-muted/20 px-3 py-2">
583
+ <div className="flex min-w-0 items-center justify-between gap-2">
584
+ <div className="flex min-w-0 items-center gap-2">
585
+ <span className="text-xs font-semibold">Evidence report</span>
586
+ <Badge variant="outline" className={reportStatusClassName(report.status)}>
587
+ {report.status}
588
+ </Badge>
589
+ </div>
590
+ <Button
591
+ type="button"
592
+ variant="ghost"
593
+ size="icon"
594
+ className="size-7 shrink-0"
595
+ aria-label={copied ? "Copied evidence report" : "Copy evidence report"}
596
+ onClick={copy}
597
+ >
598
+ {copied ? <Check className="size-3.5 text-emerald-500" /> : <Copy className="size-3.5" />}
599
+ </Button>
600
+ </div>
601
+ <p className="mt-1 text-xs text-muted-foreground">{report.summary}</p>
602
+ </div>
603
+ );
604
+ }
605
+
606
+ function EvidencePaths({ evidence }: { evidence: InspectorGroupEvidence | null }): JSX.Element {
607
+ if (evidence === null) {
608
+ return (
609
+ <div className="rounded-md border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">
610
+ No evidence pack exported yet.
611
+ </div>
612
+ );
613
+ }
614
+
615
+ return (
616
+ <div className="space-y-2 rounded-md border border-border bg-muted/20 px-3 py-2">
617
+ <div className="flex items-center justify-between gap-2">
618
+ <span className="text-xs font-semibold">Evidence paths</span>
619
+ <span className="text-[11px] text-muted-foreground">
620
+ {formatTimestamp(evidence.exportedAt)}
621
+ </span>
622
+ </div>
623
+ <div className="grid gap-2">
624
+ <CopyPathRow label="JSON" value={evidence.jsonPath} />
625
+ <CopyPathRow label="Markdown" value={evidence.markdownPath} />
626
+ <CopyPathRow label="HTML" value={evidence.htmlPath} />
627
+ </div>
628
+ </div>
629
+ );
630
+ }
631
+
632
+ function CopyPathRow({ label, value }: { label: string; value: string }): JSX.Element {
633
+ const { copied, copy } = useCopyFeedback(value);
634
+ return (
635
+ <div className="flex min-w-0 items-center gap-2 rounded-md border border-border bg-background px-2 py-1.5">
636
+ <span className="w-16 shrink-0 text-[11px] font-medium text-muted-foreground">{label}</span>
637
+ <code className="min-w-0 flex-1 truncate font-mono text-xs">{value}</code>
638
+ <Button
639
+ type="button"
640
+ variant="ghost"
641
+ size="icon"
642
+ className="size-7 shrink-0"
643
+ aria-label={copied ? `Copied ${label} path` : `Copy ${label} path`}
644
+ onClick={copy}
645
+ >
646
+ {copied ? <Check className="size-3.5 text-emerald-500" /> : <Copy className="size-3.5" />}
647
+ </Button>
648
+ </div>
649
+ );
650
+ }
651
+
652
+ function MemberTable({ rows }: { rows: GroupMemberRow[] }): JSX.Element {
653
+ if (rows.length === 0) {
654
+ return (
655
+ <div className="rounded-md border border-dashed border-border px-3 py-8 text-center text-xs text-muted-foreground">
656
+ No sessions attached.
657
+ </div>
658
+ );
659
+ }
660
+
661
+ return (
662
+ <div className="overflow-hidden rounded-md border border-border">
663
+ <div className="border-b border-border bg-muted/30 px-3 py-2 text-xs font-semibold">
664
+ Members
665
+ </div>
666
+ <div className="overflow-x-auto">
667
+ <table className="w-full min-w-[760px] text-left text-xs">
668
+ <thead className="border-b border-border bg-muted/20 text-muted-foreground">
669
+ <tr>
670
+ <th className="px-3 py-2 font-medium">Session</th>
671
+ <th className="px-3 py-2 font-medium">Provider / Model</th>
672
+ <th className="px-3 py-2 font-medium">Status</th>
673
+ <th className="px-3 py-2 font-medium">Requests</th>
674
+ <th className="px-3 py-2 font-medium">Errors</th>
675
+ <th className="px-3 py-2 font-medium">First chunk</th>
676
+ <th className="px-3 py-2 font-medium">Tokens</th>
677
+ </tr>
678
+ </thead>
679
+ <tbody>
680
+ {rows.map((row) => (
681
+ <MemberRow key={row.member.id} row={row} />
682
+ ))}
683
+ </tbody>
684
+ </table>
685
+ </div>
686
+ </div>
687
+ );
688
+ }
689
+
690
+ function MemberRow({ row }: { row: GroupMemberRow }): JSX.Element {
691
+ const member = row.member;
692
+ const session = row.exportMember?.session ?? null;
693
+ const log = latestLog(row);
694
+ const sessionPath = session?.inspectorPath ?? getSessionPath(member.sessionId);
695
+ const requests = session?.requestCount ?? null;
696
+ const errors = session?.errorCount ?? null;
697
+ const tokens = session?.tokenUsage.total ?? null;
698
+ const firstChunkMs = log?.firstChunkMs ?? null;
699
+
700
+ return (
701
+ <tr className="border-b border-border last:border-0">
702
+ <td className="max-w-56 px-3 py-2">
703
+ <div className="flex min-w-0 items-center gap-2">
704
+ <a
705
+ href={sessionPath}
706
+ target="_blank"
707
+ rel="noreferrer"
708
+ className="min-w-0 truncate font-mono text-xs text-primary hover:underline"
709
+ >
710
+ {firstNonEmpty(member.label, member.sessionId)}
711
+ </a>
712
+ <ExternalLink className="size-3 shrink-0 text-muted-foreground" />
713
+ </div>
714
+ <div className="mt-0.5 truncate text-[11px] text-muted-foreground">
715
+ {displayText(member.agent)}
716
+ </div>
717
+ </td>
718
+ <td className="max-w-52 px-3 py-2">
719
+ <span className="block truncate">{providerModel(member)}</span>
720
+ </td>
721
+ <td className="px-3 py-2">
722
+ <Badge variant="outline" className={statusClassName(member.status)}>
723
+ {statusLabel(member.status)}
724
+ </Badge>
725
+ </td>
726
+ <td className="px-3 py-2 font-mono">{requests === null ? "-" : formatNumber(requests)}</td>
727
+ <td className="px-3 py-2 font-mono">{errors === null ? "-" : formatNumber(errors)}</td>
728
+ <td className="px-3 py-2 font-mono">{formatMs(firstChunkMs)}</td>
729
+ <td className="px-3 py-2 font-mono">{tokens === null ? "-" : formatNumber(tokens)}</td>
730
+ </tr>
731
+ );
732
+ }