registry.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. "use client";
  2. import { defineRegistry } from "@json-render/react";
  3. import {
  4. AlertTriangle,
  5. Check,
  6. CircleDashed,
  7. FileMinus,
  8. FilePen,
  9. FilePlus,
  10. Info,
  11. Loader2,
  12. TriangleAlert,
  13. X,
  14. } from "lucide-react";
  15. import { agentReportCatalog } from "./catalog";
  16. const toneStyles: Record<string, string> = {
  17. neutral: "bg-muted text-muted-foreground",
  18. success:
  19. "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300",
  20. warning: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
  21. error: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
  22. };
  23. const calloutStyles: Record<string, string> = {
  24. info: "border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/40",
  25. success:
  26. "border-emerald-200 bg-emerald-50 dark:border-emerald-900 dark:bg-emerald-950/40",
  27. warning:
  28. "border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/40",
  29. error: "border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950/40",
  30. };
  31. const calloutIcons = {
  32. info: Info,
  33. success: Check,
  34. warning: TriangleAlert,
  35. error: AlertTriangle,
  36. } as const;
  37. const stepIcons = {
  38. done: <Check className="h-3.5 w-3.5 text-emerald-600" />,
  39. active: <Loader2 className="h-3.5 w-3.5 animate-spin text-blue-600" />,
  40. pending: <CircleDashed className="h-3.5 w-3.5 text-muted-foreground" />,
  41. error: <X className="h-3.5 w-3.5 text-red-600" />,
  42. } as const;
  43. const fileChangeMeta = {
  44. created: { icon: FilePlus, label: "created", className: "text-emerald-600" },
  45. modified: { icon: FilePen, label: "modified", className: "text-blue-600" },
  46. deleted: { icon: FileMinus, label: "deleted", className: "text-red-600" },
  47. } as const;
  48. type ChartPoint = { label: string; value: number };
  49. // Charts are intentionally monochrome — they ink in the foreground color.
  50. const CHART_COLOR = "var(--foreground)";
  51. /** Format a value compactly, appending an optional unit. */
  52. function formatChartValue(value: number, unit: string | null): string {
  53. const rounded =
  54. Math.abs(value) >= 100 || Number.isInteger(value)
  55. ? Math.round(value).toString()
  56. : value.toFixed(1);
  57. return unit ? `${rounded}${unit}` : rounded;
  58. }
  59. function ChartFrame({
  60. title,
  61. children,
  62. }: {
  63. title: string | null;
  64. children: React.ReactNode;
  65. }) {
  66. // No border/background of its own: a chart is content, not a card. This
  67. // keeps it from looking like a card nested inside a Card.
  68. return (
  69. <div>
  70. {title && (
  71. <div className="mb-2.5 text-xs font-medium text-muted-foreground">
  72. {title}
  73. </div>
  74. )}
  75. {children}
  76. </div>
  77. );
  78. }
  79. export const { registry } = defineRegistry(agentReportCatalog, {
  80. actions: {},
  81. components: {
  82. Stack: ({ props, children }) => (
  83. <div
  84. className={`flex ${
  85. props.direction === "horizontal"
  86. ? "flex-row flex-wrap items-start"
  87. : "flex-col"
  88. } ${{ sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"]}`}
  89. >
  90. {children}
  91. </div>
  92. ),
  93. Grid: ({ props, children }) => (
  94. <div
  95. className={`grid gap-4 ${
  96. props.columns === "3" ? "sm:grid-cols-3" : "sm:grid-cols-2"
  97. }`}
  98. >
  99. {children}
  100. </div>
  101. ),
  102. Card: ({ props, children }) => (
  103. <div className="rounded-2xl border border-border/70 bg-card/80 p-5 shadow-elevated backdrop-blur-sm">
  104. {props.title && (
  105. <h3 className="text-sm font-semibold tracking-tight mb-1">
  106. {props.title}
  107. </h3>
  108. )}
  109. {props.description && (
  110. <p className="text-sm text-muted-foreground mb-3">
  111. {props.description}
  112. </p>
  113. )}
  114. <div className="flex flex-col gap-3">{children}</div>
  115. </div>
  116. ),
  117. Heading: ({ props }) => {
  118. const sizes = { "1": "text-xl", "2": "text-lg", "3": "text-base" };
  119. return (
  120. <div className={`font-semibold ${sizes[props.level ?? "2"]}`}>
  121. {props.text}
  122. </div>
  123. );
  124. },
  125. Text: ({ props }) => (
  126. <p
  127. className={`text-sm leading-relaxed ${
  128. props.muted ? "text-muted-foreground" : ""
  129. }`}
  130. >
  131. {props.content}
  132. </p>
  133. ),
  134. Badge: ({ props }) => (
  135. <span
  136. className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
  137. toneStyles[props.tone ?? "neutral"]
  138. }`}
  139. >
  140. {props.label}
  141. </span>
  142. ),
  143. Callout: ({ props }) => {
  144. const tone = props.tone ?? "info";
  145. const Icon = calloutIcons[tone];
  146. return (
  147. <div className={`rounded-lg border px-3 py-2.5 ${calloutStyles[tone]}`}>
  148. <div className="flex gap-2">
  149. <Icon className="h-4 w-4 mt-0.5 shrink-0" />
  150. <div className="text-sm">
  151. {props.title && (
  152. <span className="font-medium">{props.title}: </span>
  153. )}
  154. {props.content}
  155. </div>
  156. </div>
  157. </div>
  158. );
  159. },
  160. Metric: ({ props }) => (
  161. <div className="rounded-xl border border-border/70 bg-gradient-to-b from-card to-muted/40 px-3.5 py-3">
  162. <div className="text-xs font-medium text-muted-foreground">
  163. {props.label}
  164. </div>
  165. <div className="mt-0.5 text-2xl font-semibold tracking-tight tabular-nums">
  166. {props.value}
  167. </div>
  168. {props.detail && (
  169. <div className="text-xs text-muted-foreground tabular-nums">
  170. {props.detail}
  171. </div>
  172. )}
  173. </div>
  174. ),
  175. Steps: ({ props }) => (
  176. <ol className="flex flex-col gap-2">
  177. {props.items.map((item, i) => (
  178. <li key={i} className="flex items-start gap-2.5 text-sm">
  179. <span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border bg-card">
  180. {stepIcons[item.status]}
  181. </span>
  182. <span>
  183. <span
  184. className={
  185. item.status === "pending" ? "text-muted-foreground" : ""
  186. }
  187. >
  188. {item.title}
  189. </span>
  190. {item.detail && (
  191. <span className="block text-xs text-muted-foreground">
  192. {item.detail}
  193. </span>
  194. )}
  195. </span>
  196. </li>
  197. ))}
  198. </ol>
  199. ),
  200. FileChange: ({ props }) => {
  201. const meta = fileChangeMeta[props.kind];
  202. const Icon = meta.icon;
  203. return (
  204. <div className="flex items-start gap-2.5 rounded-lg border bg-card px-3 py-2">
  205. <Icon className={`h-4 w-4 mt-0.5 shrink-0 ${meta.className}`} />
  206. <div className="min-w-0 text-sm">
  207. <div className="flex flex-wrap items-center gap-2">
  208. <code className="font-mono text-xs">{props.path}</code>
  209. <span className={`text-xs ${meta.className}`}>{meta.label}</span>
  210. {(props.additions != null || props.deletions != null) && (
  211. <span className="text-xs tabular-nums">
  212. {props.additions != null && (
  213. <span className="text-emerald-600">
  214. +{props.additions}{" "}
  215. </span>
  216. )}
  217. {props.deletions != null && (
  218. <span className="text-red-600">-{props.deletions}</span>
  219. )}
  220. </span>
  221. )}
  222. </div>
  223. {props.summary && (
  224. <div className="text-xs text-muted-foreground">
  225. {props.summary}
  226. </div>
  227. )}
  228. </div>
  229. </div>
  230. );
  231. },
  232. CodeBlock: ({ props }) => (
  233. <div className="overflow-hidden rounded-lg border">
  234. {props.title && (
  235. <div className="border-b bg-muted/50 px-3 py-1.5 font-mono text-xs text-muted-foreground">
  236. {props.title}
  237. </div>
  238. )}
  239. <pre className="overflow-x-auto bg-card p-3 text-xs leading-relaxed">
  240. <code>{props.code}</code>
  241. </pre>
  242. </div>
  243. ),
  244. Terminal: ({ props }) => (
  245. <div className="overflow-hidden rounded-lg bg-zinc-950 text-zinc-100">
  246. <div className="flex items-center justify-between gap-2 border-b border-zinc-800 px-3 py-1.5">
  247. <code className="font-mono text-xs text-zinc-300">
  248. $ {props.command}
  249. </code>
  250. {props.exitCode != null && (
  251. <span
  252. className={`text-xs tabular-nums ${
  253. props.exitCode === 0 ? "text-emerald-400" : "text-red-400"
  254. }`}
  255. >
  256. exit {props.exitCode}
  257. </span>
  258. )}
  259. </div>
  260. {props.output && (
  261. <pre className="max-h-64 overflow-auto p-3 font-mono text-xs leading-relaxed text-zinc-300 whitespace-pre-wrap">
  262. {props.output}
  263. </pre>
  264. )}
  265. </div>
  266. ),
  267. TestResults: ({ props }) => (
  268. <div className="flex flex-col gap-2">
  269. <div className="flex gap-2">
  270. <span
  271. className={`rounded-md px-2 py-1 text-xs ${toneStyles.success}`}
  272. >
  273. {props.passed} passed
  274. </span>
  275. <span
  276. className={`rounded-md px-2 py-1 text-xs ${
  277. props.failed > 0 ? toneStyles.error : toneStyles.neutral
  278. }`}
  279. >
  280. {props.failed} failed
  281. </span>
  282. {props.skipped != null && props.skipped > 0 && (
  283. <span
  284. className={`rounded-md px-2 py-1 text-xs ${toneStyles.warning}`}
  285. >
  286. {props.skipped} skipped
  287. </span>
  288. )}
  289. </div>
  290. {props.failures && props.failures.length > 0 && (
  291. <ul className="flex flex-col gap-1.5">
  292. {props.failures.map((f, i) => (
  293. <li
  294. key={i}
  295. className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs dark:border-red-900 dark:bg-red-950/40"
  296. >
  297. <div className="font-mono font-medium">{f.name}</div>
  298. <div className="text-muted-foreground">{f.message}</div>
  299. </li>
  300. ))}
  301. </ul>
  302. )}
  303. </div>
  304. ),
  305. BarChart: ({ props }) => {
  306. const data = props.data as ChartPoint[];
  307. const color = CHART_COLOR;
  308. const values = data.map((d) => d.value);
  309. const max = Math.max(...values, 0);
  310. const min = Math.min(...values, 0);
  311. const span = max - min || 1;
  312. const zeroTop = ((max - 0) / span) * 100;
  313. return (
  314. <ChartFrame title={props.title}>
  315. {data.length === 0 ? (
  316. <div className="text-xs text-muted-foreground">No data</div>
  317. ) : (
  318. <div className="flex h-40 gap-2.5">
  319. {data.map((d, i) => {
  320. const rawHeight = (Math.abs(d.value) / span) * 100;
  321. const availableHeight = d.value >= 0 ? zeroTop : 100 - zeroTop;
  322. const height =
  323. d.value === 0
  324. ? 0
  325. : Math.min(Math.max(rawHeight, 1.5), availableHeight);
  326. const top = d.value >= 0 ? zeroTop - height : zeroTop;
  327. return (
  328. <div
  329. key={i}
  330. className="flex h-full min-w-0 flex-1 flex-col items-center gap-1.5"
  331. >
  332. <div className="text-[11px] tabular-nums text-muted-foreground">
  333. {formatChartValue(d.value, props.unit)}
  334. </div>
  335. <div className="relative min-h-0 w-full flex-1">
  336. <div
  337. className="absolute inset-x-0 border-t border-muted-foreground/25"
  338. style={{ top: `${zeroTop}%` }}
  339. />
  340. {d.value === 0 ? (
  341. <div
  342. className="absolute inset-x-0 h-px"
  343. style={{
  344. top: `${zeroTop}%`,
  345. backgroundColor: color,
  346. }}
  347. />
  348. ) : (
  349. <div
  350. className="absolute inset-x-0 rounded-sm"
  351. style={{
  352. top: `${top}%`,
  353. height: `${height}%`,
  354. backgroundColor: color,
  355. }}
  356. />
  357. )}
  358. </div>
  359. <div className="w-full truncate text-center text-[11px] text-muted-foreground">
  360. {d.label}
  361. </div>
  362. </div>
  363. );
  364. })}
  365. </div>
  366. )}
  367. </ChartFrame>
  368. );
  369. },
  370. LineChart: ({ props }) => {
  371. const data = props.data as ChartPoint[];
  372. const color = CHART_COLOR;
  373. const W = 100;
  374. const H = 40;
  375. const values = data.map((d) => d.value);
  376. const max = Math.max(...values, 0);
  377. const min = Math.min(...values, 0);
  378. const span = max - min || 1;
  379. // Map each point into the viewBox; single point sits centered.
  380. const points = data.map((d, i) => {
  381. const x = data.length === 1 ? W / 2 : (i / (data.length - 1)) * W;
  382. const y = H - ((d.value - min) / span) * H;
  383. return { x, y };
  384. });
  385. const line = points.map((p) => `${p.x},${p.y}`).join(" ");
  386. const area = `0,${H} ${line} ${W},${H}`;
  387. const first = data[0];
  388. const last = data[data.length - 1];
  389. return (
  390. <ChartFrame title={props.title}>
  391. {!first || !last ? (
  392. <div className="text-xs text-muted-foreground">No data</div>
  393. ) : (
  394. <>
  395. <svg
  396. viewBox={`0 0 ${W} ${H}`}
  397. preserveAspectRatio="none"
  398. className="h-28 w-full"
  399. role="img"
  400. >
  401. <polygon points={area} fill={color} fillOpacity={0.06} />
  402. <polyline
  403. points={line}
  404. fill="none"
  405. stroke={color}
  406. strokeWidth={1.5}
  407. strokeLinejoin="round"
  408. strokeLinecap="round"
  409. vectorEffect="non-scaling-stroke"
  410. />
  411. </svg>
  412. <div className="mt-1 flex justify-between text-[10px] text-muted-foreground">
  413. <span className="truncate">
  414. {first.label}
  415. <span className="tabular-nums">
  416. {" "}
  417. · {formatChartValue(first.value, props.unit)}
  418. </span>
  419. </span>
  420. {data.length > 1 && (
  421. <span className="truncate">
  422. {last.label}
  423. <span className="tabular-nums">
  424. {" "}
  425. · {formatChartValue(last.value, props.unit)}
  426. </span>
  427. </span>
  428. )}
  429. </div>
  430. </>
  431. )}
  432. </ChartFrame>
  433. );
  434. },
  435. },
  436. });
  437. export function Fallback({ type }: { type: string }) {
  438. return (
  439. <div className="rounded-lg border border-dashed px-3 py-2 text-xs text-muted-foreground">
  440. Unknown component: {type}
  441. </div>
  442. );
  443. }