@a5c-ai/babysitter-observer-dashboard 1.0.0

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 (205) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +490 -0
  3. package/next.config.mjs +25 -0
  4. package/package.json +104 -0
  5. package/postcss.config.mjs +8 -0
  6. package/src/app/actions/__tests__/approve-breakpoint.test.ts +246 -0
  7. package/src/app/actions/approve-breakpoint.ts +145 -0
  8. package/src/app/api/config/route.ts +137 -0
  9. package/src/app/api/digest/route.ts +45 -0
  10. package/src/app/api/runs/[runId]/events/route.ts +56 -0
  11. package/src/app/api/runs/[runId]/route.ts +84 -0
  12. package/src/app/api/runs/[runId]/tasks/[effectId]/route.ts +44 -0
  13. package/src/app/api/runs/route.ts +48 -0
  14. package/src/app/api/stream/route.ts +136 -0
  15. package/src/app/api/test/route.ts +1 -0
  16. package/src/app/api/version/route.ts +57 -0
  17. package/src/app/globals.css +555 -0
  18. package/src/app/icon.svg +20 -0
  19. package/src/app/layout.tsx +39 -0
  20. package/src/app/not-found.tsx +16 -0
  21. package/src/app/page.tsx +120 -0
  22. package/src/app/runs/[runId]/page.tsx +279 -0
  23. package/src/cli.ts +271 -0
  24. package/src/components/breakpoint/__tests__/breakpoint-approval.test.tsx +212 -0
  25. package/src/components/breakpoint/__tests__/breakpoint-panel.test.tsx +130 -0
  26. package/src/components/breakpoint/__tests__/file-preview.test.tsx +313 -0
  27. package/src/components/breakpoint/breakpoint-approval.tsx +138 -0
  28. package/src/components/breakpoint/breakpoint-panel.tsx +95 -0
  29. package/src/components/breakpoint/file-preview.tsx +215 -0
  30. package/src/components/dashboard/.gitkeep +0 -0
  31. package/src/components/dashboard/__tests__/breakpoint-banner.test.tsx +177 -0
  32. package/src/components/dashboard/__tests__/catch-up-banner.test.tsx +141 -0
  33. package/src/components/dashboard/__tests__/executive-summary-banner.test.tsx +164 -0
  34. package/src/components/dashboard/__tests__/kpi-grid.test.tsx +101 -0
  35. package/src/components/dashboard/__tests__/pagination-controls.test.tsx +125 -0
  36. package/src/components/dashboard/__tests__/project-accordion.test.tsx +97 -0
  37. package/src/components/dashboard/__tests__/project-list-view.test.tsx +174 -0
  38. package/src/components/dashboard/__tests__/project-search-input.test.tsx +110 -0
  39. package/src/components/dashboard/__tests__/project-section-header.test.tsx +91 -0
  40. package/src/components/dashboard/__tests__/project-section.test.tsx +151 -0
  41. package/src/components/dashboard/__tests__/run-card.test.tsx +164 -0
  42. package/src/components/dashboard/__tests__/run-filter-bar.test.tsx +109 -0
  43. package/src/components/dashboard/__tests__/run-list.test.tsx +123 -0
  44. package/src/components/dashboard/__tests__/search-filter.test.tsx +150 -0
  45. package/src/components/dashboard/__tests__/virtualized-run-list.test.tsx +179 -0
  46. package/src/components/dashboard/breakpoint-banner.tsx +301 -0
  47. package/src/components/dashboard/catch-up-banner.tsx +88 -0
  48. package/src/components/dashboard/executive-summary-banner.tsx +174 -0
  49. package/src/components/dashboard/global-search.tsx +323 -0
  50. package/src/components/dashboard/kpi-grid.tsx +140 -0
  51. package/src/components/dashboard/pagination-controls.tsx +100 -0
  52. package/src/components/dashboard/project-accordion.tsx +72 -0
  53. package/src/components/dashboard/project-health-card.tsx +536 -0
  54. package/src/components/dashboard/project-list-view.tsx +246 -0
  55. package/src/components/dashboard/project-search-input.tsx +41 -0
  56. package/src/components/dashboard/project-section-header.tsx +73 -0
  57. package/src/components/dashboard/project-section.tsx +89 -0
  58. package/src/components/dashboard/run-card.tsx +218 -0
  59. package/src/components/dashboard/run-filter-bar.tsx +100 -0
  60. package/src/components/dashboard/run-list.tsx +77 -0
  61. package/src/components/dashboard/search-filter.tsx +69 -0
  62. package/src/components/dashboard/virtualized-run-list.tsx +130 -0
  63. package/src/components/details/.gitkeep +0 -0
  64. package/src/components/details/__tests__/agent-panel.test.tsx +236 -0
  65. package/src/components/details/__tests__/json-tree.test.tsx +347 -0
  66. package/src/components/details/__tests__/log-viewer.test.tsx +168 -0
  67. package/src/components/details/__tests__/task-detail.test.tsx +212 -0
  68. package/src/components/details/__tests__/timing-panel.test.tsx +271 -0
  69. package/src/components/details/agent-panel.tsx +234 -0
  70. package/src/components/details/json-tree/categorize.ts +131 -0
  71. package/src/components/details/json-tree/index.tsx +120 -0
  72. package/src/components/details/json-tree/json-node.tsx +223 -0
  73. package/src/components/details/json-tree/smart-summary.tsx +596 -0
  74. package/src/components/details/json-tree/tree-controls.tsx +47 -0
  75. package/src/components/details/json-tree.tsx +9 -0
  76. package/src/components/details/log-viewer.tsx +140 -0
  77. package/src/components/details/task-detail.tsx +114 -0
  78. package/src/components/details/timing-panel.tsx +247 -0
  79. package/src/components/events/.gitkeep +0 -0
  80. package/src/components/events/__tests__/event-item.test.tsx +211 -0
  81. package/src/components/events/__tests__/event-stream.test.tsx +225 -0
  82. package/src/components/events/event-item.tsx +121 -0
  83. package/src/components/events/event-stream.tsx +260 -0
  84. package/src/components/notifications/.gitkeep +0 -0
  85. package/src/components/notifications/__tests__/notification-panel.test.tsx +287 -0
  86. package/src/components/notifications/__tests__/notification-provider.test.tsx +585 -0
  87. package/src/components/notifications/__tests__/toast-stack.test.tsx +217 -0
  88. package/src/components/notifications/notification-panel.tsx +124 -0
  89. package/src/components/notifications/notification-provider.tsx +175 -0
  90. package/src/components/notifications/toast-stack.tsx +75 -0
  91. package/src/components/pipeline/.gitkeep +0 -0
  92. package/src/components/pipeline/__tests__/parallel-group.test.tsx +88 -0
  93. package/src/components/pipeline/__tests__/pipeline-view.test.tsx +345 -0
  94. package/src/components/pipeline/__tests__/step-card.test.tsx +330 -0
  95. package/src/components/pipeline/parallel-group.tsx +39 -0
  96. package/src/components/pipeline/pipeline-view.tsx +197 -0
  97. package/src/components/pipeline/step-card.tsx +166 -0
  98. package/src/components/providers/event-stream-provider.tsx +29 -0
  99. package/src/components/providers.tsx +24 -0
  100. package/src/components/shared/.gitkeep +0 -0
  101. package/src/components/shared/__tests__/empty-state.test.tsx +49 -0
  102. package/src/components/shared/__tests__/friendly-id.test.tsx +47 -0
  103. package/src/components/shared/__tests__/kbd.test.tsx +45 -0
  104. package/src/components/shared/__tests__/kind-badge.test.tsx +71 -0
  105. package/src/components/shared/__tests__/metrics-row.test.tsx +74 -0
  106. package/src/components/shared/__tests__/outcome-banner.test.tsx +71 -0
  107. package/src/components/shared/__tests__/progress-bar.test.tsx +89 -0
  108. package/src/components/shared/__tests__/session-pill.test.tsx +62 -0
  109. package/src/components/shared/__tests__/settings-modal.test.tsx +201 -0
  110. package/src/components/shared/__tests__/shortcuts-help.test.tsx +103 -0
  111. package/src/components/shared/__tests__/status-badge.test.tsx +98 -0
  112. package/src/components/shared/__tests__/theme-provider.test.tsx +100 -0
  113. package/src/components/shared/__tests__/truncated-id.test.tsx +53 -0
  114. package/src/components/shared/app-footer.tsx +80 -0
  115. package/src/components/shared/app-header.tsx +160 -0
  116. package/src/components/shared/empty-state.tsx +18 -0
  117. package/src/components/shared/error-boundary.tsx +81 -0
  118. package/src/components/shared/friendly-id.tsx +48 -0
  119. package/src/components/shared/kbd.tsx +15 -0
  120. package/src/components/shared/kind-badge.tsx +51 -0
  121. package/src/components/shared/metrics-row.tsx +106 -0
  122. package/src/components/shared/outcome-banner.tsx +56 -0
  123. package/src/components/shared/progress-bar.tsx +42 -0
  124. package/src/components/shared/session-pill.tsx +69 -0
  125. package/src/components/shared/settings-modal.tsx +509 -0
  126. package/src/components/shared/shortcuts-help.tsx +113 -0
  127. package/src/components/shared/status-badge.tsx +110 -0
  128. package/src/components/shared/theme-provider.tsx +46 -0
  129. package/src/components/shared/truncated-id.tsx +51 -0
  130. package/src/components/ui/.gitkeep +0 -0
  131. package/src/components/ui/__tests__/accordion.test.tsx +96 -0
  132. package/src/components/ui/__tests__/badge.test.tsx +69 -0
  133. package/src/components/ui/__tests__/button.test.tsx +113 -0
  134. package/src/components/ui/__tests__/tabs.test.tsx +75 -0
  135. package/src/components/ui/__tests__/tooltip.test.tsx +90 -0
  136. package/src/components/ui/accordion.tsx +61 -0
  137. package/src/components/ui/badge.tsx +25 -0
  138. package/src/components/ui/button.tsx +40 -0
  139. package/src/components/ui/card.tsx +21 -0
  140. package/src/components/ui/scroll-area.tsx +35 -0
  141. package/src/components/ui/separator.tsx +24 -0
  142. package/src/components/ui/tabs.tsx +64 -0
  143. package/src/components/ui/tooltip.tsx +37 -0
  144. package/src/hooks/.gitkeep +0 -0
  145. package/src/hooks/__tests__/use-animated-number.test.ts +184 -0
  146. package/src/hooks/__tests__/use-batched-updates.test.ts +315 -0
  147. package/src/hooks/__tests__/use-event-stream.test.ts +243 -0
  148. package/src/hooks/__tests__/use-keyboard.test.ts +217 -0
  149. package/src/hooks/__tests__/use-notifications.test.ts +230 -0
  150. package/src/hooks/__tests__/use-polling.test.ts +274 -0
  151. package/src/hooks/__tests__/use-project-runs.test.ts +163 -0
  152. package/src/hooks/__tests__/use-projects.test.ts +248 -0
  153. package/src/hooks/__tests__/use-run-dashboard.test.ts +168 -0
  154. package/src/hooks/__tests__/use-run-detail.test.ts +273 -0
  155. package/src/hooks/__tests__/use-smart-polling.test.ts +305 -0
  156. package/src/hooks/use-animated-number.ts +87 -0
  157. package/src/hooks/use-batched-updates.ts +150 -0
  158. package/src/hooks/use-event-stream.ts +150 -0
  159. package/src/hooks/use-keyboard.ts +45 -0
  160. package/src/hooks/use-notifications.ts +82 -0
  161. package/src/hooks/use-persisted-state.ts +60 -0
  162. package/src/hooks/use-polling.ts +60 -0
  163. package/src/hooks/use-project-runs.ts +51 -0
  164. package/src/hooks/use-projects.ts +26 -0
  165. package/src/hooks/use-run-dashboard.ts +207 -0
  166. package/src/hooks/use-run-detail.ts +77 -0
  167. package/src/hooks/use-smart-polling.ts +144 -0
  168. package/src/lib/.gitkeep +0 -0
  169. package/src/lib/__tests__/cn.test.ts +69 -0
  170. package/src/lib/__tests__/config-loader.test.ts +210 -0
  171. package/src/lib/__tests__/config.test.ts +561 -0
  172. package/src/lib/__tests__/error-handler.test.ts +143 -0
  173. package/src/lib/__tests__/fetcher.test.ts +517 -0
  174. package/src/lib/__tests__/global-registry.test.ts +214 -0
  175. package/src/lib/__tests__/parser.test.ts +1532 -0
  176. package/src/lib/__tests__/path-resolver.test.ts +112 -0
  177. package/src/lib/__tests__/run-cache.test.ts +591 -0
  178. package/src/lib/__tests__/server-init.test.ts +512 -0
  179. package/src/lib/__tests__/source-discovery.test.ts +246 -0
  180. package/src/lib/__tests__/utils.test.ts +160 -0
  181. package/src/lib/__tests__/watcher.test.ts +227 -0
  182. package/src/lib/cn.ts +6 -0
  183. package/src/lib/config-loader.ts +195 -0
  184. package/src/lib/config.ts +20 -0
  185. package/src/lib/error-handler.ts +76 -0
  186. package/src/lib/fetcher.ts +394 -0
  187. package/src/lib/global-registry.ts +117 -0
  188. package/src/lib/parser.ts +794 -0
  189. package/src/lib/path-resolver.ts +16 -0
  190. package/src/lib/run-cache.ts +404 -0
  191. package/src/lib/server-init.ts +226 -0
  192. package/src/lib/services/__tests__/run-query-service.test.ts +819 -0
  193. package/src/lib/services/run-query-service.ts +286 -0
  194. package/src/lib/source-discovery.ts +216 -0
  195. package/src/lib/utils.ts +103 -0
  196. package/src/lib/watcher.ts +265 -0
  197. package/src/test/fixtures.ts +269 -0
  198. package/src/test/mocks/handlers.ts +110 -0
  199. package/src/test/mocks/server.ts +17 -0
  200. package/src/test/setup.ts +200 -0
  201. package/src/test/test-utils.tsx +36 -0
  202. package/src/types/.gitkeep +0 -0
  203. package/src/types/breakpoint.ts +17 -0
  204. package/src/types/index.ts +214 -0
  205. package/tsconfig.json +50 -0
@@ -0,0 +1,596 @@
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import {
5
+ ChevronDown,
6
+ Copy,
7
+ Check,
8
+ CheckCircle2,
9
+ XCircle,
10
+ AlertTriangle,
11
+ Info,
12
+ Clock,
13
+ Hash,
14
+ } from "lucide-react";
15
+ import { cn } from "@/lib/cn";
16
+ import { CopyButton, JsonNode, JsonTreeView } from "./json-node";
17
+ import { formatLabel } from "./categorize";
18
+
19
+ /* ------------------------------------------------------------------ */
20
+ /* SmartSectionHeader */
21
+ /* ------------------------------------------------------------------ */
22
+
23
+ /** SmartSectionHeader -- reusable section header with consistent styling */
24
+ export function SmartSectionHeader({ children, className: extraClass }: { children: React.ReactNode; className?: string }) {
25
+ return (
26
+ <h4 className={cn("text-xs font-medium text-foreground-muted tracking-wider uppercase pl-2 border-l-2 border-primary", extraClass)}>
27
+ {children}
28
+ </h4>
29
+ );
30
+ }
31
+
32
+ /* ------------------------------------------------------------------ */
33
+ /* StatusPill */
34
+ /* ------------------------------------------------------------------ */
35
+
36
+ /** Status pill -- colored dot + text */
37
+ export function StatusPill({ status }: { status: string }) {
38
+ const normalized = status.toLowerCase();
39
+ const isOk =
40
+ normalized === "ok" ||
41
+ normalized === "success" ||
42
+ normalized === "resolved" ||
43
+ normalized === "completed" ||
44
+ normalized === "pass";
45
+ const isError =
46
+ normalized === "error" ||
47
+ normalized === "failed" ||
48
+ normalized === "fail" ||
49
+ normalized === "rejected";
50
+ const isPending =
51
+ normalized === "pending" ||
52
+ normalized === "waiting" ||
53
+ normalized === "running" ||
54
+ normalized === "requested";
55
+
56
+ const dotColor = isOk
57
+ ? "bg-success"
58
+ : isError
59
+ ? "bg-error"
60
+ : isPending
61
+ ? "bg-warning"
62
+ : "bg-foreground-muted";
63
+
64
+ const textColor = isOk
65
+ ? "text-success"
66
+ : isError
67
+ ? "text-error"
68
+ : isPending
69
+ ? "text-warning"
70
+ : "text-foreground-secondary";
71
+
72
+ const bgColor = isOk
73
+ ? "bg-success-muted"
74
+ : isError
75
+ ? "bg-error-muted"
76
+ : isPending
77
+ ? "bg-warning-muted"
78
+ : "bg-background-tertiary";
79
+
80
+ return (
81
+ <span
82
+ className={cn(
83
+ "inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium",
84
+ bgColor,
85
+ textColor
86
+ )}
87
+ >
88
+ <span
89
+ className={cn("h-1.5 w-1.5 rounded-full animate-pulse-dot", dotColor)}
90
+ />
91
+ {status}
92
+ </span>
93
+ );
94
+ }
95
+
96
+ /* ------------------------------------------------------------------ */
97
+ /* ScoreBar */
98
+ /* ------------------------------------------------------------------ */
99
+
100
+ /** Score bar -- colored progress indicator */
101
+ export function ScoreBar({ score }: { score: number }) {
102
+ const clamped = Math.max(0, Math.min(100, score));
103
+ const color =
104
+ clamped < 50 ? "bg-error" : clamped < 80 ? "bg-warning" : "bg-success";
105
+ const glowShadow =
106
+ clamped < 50
107
+ ? "shadow-progress-glow-error"
108
+ : clamped < 80
109
+ ? "shadow-progress-glow-warning"
110
+ : "shadow-progress-glow-success";
111
+ const textColor =
112
+ clamped < 50
113
+ ? "text-error"
114
+ : clamped < 80
115
+ ? "text-warning"
116
+ : "text-success";
117
+
118
+ return (
119
+ <div className="flex items-center gap-2 min-w-[120px]">
120
+ <span className={cn("text-[11px] font-mono font-bold", textColor)}>
121
+ {score}
122
+ </span>
123
+ <div className="flex-1 h-1.5 rounded-full bg-background-tertiary overflow-hidden">
124
+ <div
125
+ className={cn("h-full rounded-full transition-all duration-500", color, glowShadow)}
126
+ style={{ width: `${clamped}%` }}
127
+ />
128
+ </div>
129
+ <span className="text-xs text-foreground-muted">/100</span>
130
+ </div>
131
+ );
132
+ }
133
+
134
+ /* ------------------------------------------------------------------ */
135
+ /* QualityBadge */
136
+ /* ------------------------------------------------------------------ */
137
+
138
+ /** Quality pass/fail badge */
139
+ export function QualityBadge({ passes }: { passes: boolean }) {
140
+ return (
141
+ <span
142
+ className={cn(
143
+ "inline-flex items-center gap-1 px-2 py-1 rounded-full text-[11px] font-medium",
144
+ passes
145
+ ? "bg-success-muted text-success"
146
+ : "bg-error-muted text-error"
147
+ )}
148
+ >
149
+ {passes ? (
150
+ <CheckCircle2 className="h-3 w-3" />
151
+ ) : (
152
+ <XCircle className="h-3 w-3" />
153
+ )}
154
+ {passes ? "Pass" : "Fail"}
155
+ </span>
156
+ );
157
+ }
158
+
159
+ /* ------------------------------------------------------------------ */
160
+ /* AtAGlanceHeader */
161
+ /* ------------------------------------------------------------------ */
162
+
163
+ /** At-a-Glance Header Bar */
164
+ export function AtAGlanceHeader({
165
+ status,
166
+ score,
167
+ passesQuality,
168
+ taskId,
169
+ }: {
170
+ status: string | null;
171
+ score: number | null;
172
+ passesQuality: boolean | null;
173
+ taskId: string | null;
174
+ }) {
175
+ const hasAny =
176
+ status !== null ||
177
+ score !== null ||
178
+ passesQuality !== null ||
179
+ taskId !== null;
180
+ if (!hasAny) return null;
181
+
182
+ return (
183
+ <div className="flex flex-wrap items-center gap-3 rounded-lg bg-background-secondary/60 border border-border/50 px-3 py-2">
184
+ {status !== null && <StatusPill status={status} />}
185
+ {score !== null && <ScoreBar score={score} />}
186
+ {passesQuality !== null && <QualityBadge passes={passesQuality} />}
187
+ {taskId !== null && (
188
+ <span className="flex items-center gap-1 text-[11px] text-foreground-muted font-mono">
189
+ <Hash className="h-3 w-3" />
190
+ <span title={taskId}>
191
+ {taskId.length > 12
192
+ ? taskId.slice(0, 6) + "\u2026" + taskId.slice(-4)
193
+ : taskId}
194
+ </span>
195
+ <CopyButton value={taskId} />
196
+ </span>
197
+ )}
198
+ </div>
199
+ );
200
+ }
201
+
202
+ /* ------------------------------------------------------------------ */
203
+ /* BooleanFlagsGrid */
204
+ /* ------------------------------------------------------------------ */
205
+
206
+ /** Boolean Flags Grid */
207
+ export function BooleanFlagsGrid({
208
+ booleans,
209
+ }: {
210
+ booleans: Array<{ key: string; value: boolean }>;
211
+ }) {
212
+ if (booleans.length === 0) return null;
213
+
214
+ return (
215
+ <div className="space-y-1.5">
216
+ <SmartSectionHeader>Flags</SmartSectionHeader>
217
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
218
+ {booleans.map(({ key, value }) => (
219
+ <div
220
+ key={key}
221
+ className={cn(
222
+ "flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-[11px] font-medium border transition-colors",
223
+ value
224
+ ? "bg-success-muted/50 border-success/20 text-success"
225
+ : "bg-background-tertiary/50 border-border/30 text-foreground-muted"
226
+ )}
227
+ >
228
+ {value ? (
229
+ <CheckCircle2 className="h-3 w-3 shrink-0" />
230
+ ) : (
231
+ <XCircle className="h-3 w-3 shrink-0" />
232
+ )}
233
+ <span className="truncate">{formatLabel(key)}</span>
234
+ </div>
235
+ ))}
236
+ </div>
237
+ </div>
238
+ );
239
+ }
240
+
241
+ /* ------------------------------------------------------------------ */
242
+ /* FindingCard & FindingsSection */
243
+ /* ------------------------------------------------------------------ */
244
+
245
+ /** Single finding card with truncation + expand */
246
+ function FindingCard({
247
+ index,
248
+ text,
249
+ isWarning,
250
+ }: {
251
+ index: number;
252
+ text: string;
253
+ isWarning: boolean;
254
+ }) {
255
+ const [expanded, setExpanded] = useState(false);
256
+ const isLong = text.length > 120;
257
+ const display = isLong && !expanded ? text.slice(0, 120) + "\u2026" : text;
258
+
259
+ const Icon = isWarning ? AlertTriangle : Info;
260
+ const iconColor = isWarning ? "text-warning" : "text-info";
261
+
262
+ return (
263
+ <div className="group/finding flex items-start gap-2 rounded-md bg-background-secondary/50 border border-border/40 px-3 py-2 transition-colors hover:border-border-hover/60">
264
+ <Icon className={cn("h-3.5 w-3.5 mt-0.5 shrink-0", iconColor)} />
265
+ <div className="flex-1 min-w-0">
266
+ <span className="text-[11px] text-foreground-muted font-mono mr-1.5">
267
+ {index}.
268
+ </span>
269
+ <span className="text-xs text-foreground-secondary leading-relaxed">
270
+ {display}
271
+ </span>
272
+ {isLong && (
273
+ <button
274
+ type="button"
275
+ onClick={() => setExpanded((p) => !p)}
276
+ className="ml-1 text-xs text-primary hover:text-primary-hover transition-colors min-h-[44px] inline-flex items-center"
277
+ >
278
+ {expanded ? "Show less" : "Show more"}
279
+ </button>
280
+ )}
281
+ </div>
282
+ <span className="opacity-0 group-hover/finding:opacity-100 transition-opacity">
283
+ <CopyButton value={text} />
284
+ </span>
285
+ </div>
286
+ );
287
+ }
288
+
289
+ /** Findings / Issues Section */
290
+ export function FindingsSection({
291
+ findings,
292
+ }: {
293
+ findings: Array<{ key: string; items: string[] }>;
294
+ }) {
295
+ if (findings.length === 0) return null;
296
+
297
+ const warningKeys = new Set([
298
+ "issues",
299
+ "errors",
300
+ "warnings",
301
+ "problems",
302
+ ]);
303
+
304
+ return (
305
+ <>
306
+ {findings.map(({ key, items }) => {
307
+ const isWarning = warningKeys.has(key.toLowerCase());
308
+ const Icon = isWarning ? AlertTriangle : Info;
309
+ const iconColor = isWarning ? "text-warning" : "text-info";
310
+
311
+ return (
312
+ <div key={key} className="space-y-1.5">
313
+ <SmartSectionHeader className="flex items-center gap-1.5">
314
+ <Icon className={cn("h-3 w-3", iconColor)} />
315
+ {formatLabel(key)}
316
+ <span className="ml-1 inline-flex items-center justify-center h-4 min-w-[16px] px-1 rounded-full bg-primary-muted text-primary text-xs font-bold">
317
+ {items.length}
318
+ </span>
319
+ </SmartSectionHeader>
320
+ <div className="space-y-1">
321
+ {items.map((item, i) => (
322
+ <FindingCard
323
+ key={`${key}-${i}`}
324
+ index={i + 1}
325
+ text={item}
326
+ isWarning={isWarning}
327
+ />
328
+ ))}
329
+ </div>
330
+ </div>
331
+ );
332
+ })}
333
+ </>
334
+ );
335
+ }
336
+
337
+ /* ------------------------------------------------------------------ */
338
+ /* SummaryBlock */
339
+ /* ------------------------------------------------------------------ */
340
+
341
+ /** Summary Block -- quote/info style card */
342
+ export function SummaryBlock({ summary }: { summary: string }) {
343
+ return (
344
+ <div className="space-y-1.5">
345
+ <SmartSectionHeader>Summary</SmartSectionHeader>
346
+ <div className="rounded-md bg-background-secondary/50 border border-border/40 border-l-[3px] border-l-secondary px-3 py-2.5">
347
+ <p className="text-xs text-foreground-secondary leading-relaxed">
348
+ {summary}
349
+ </p>
350
+ </div>
351
+ </div>
352
+ );
353
+ }
354
+
355
+ /* ------------------------------------------------------------------ */
356
+ /* Timestamp & ID helpers */
357
+ /* ------------------------------------------------------------------ */
358
+
359
+ /** Format a timestamp as relative time with full ISO on hover */
360
+ function formatRelativeTime(value: string): { relative: string; full: string } | null {
361
+ const date = new Date(value);
362
+ if (isNaN(date.getTime())) return null;
363
+
364
+ const now = Date.now();
365
+ const diff = now - date.getTime();
366
+ const seconds = Math.floor(diff / 1000);
367
+ const minutes = Math.floor(seconds / 60);
368
+ const hours = Math.floor(minutes / 60);
369
+ const days = Math.floor(hours / 24);
370
+
371
+ let relative: string;
372
+ if (seconds < 60) relative = `${seconds}s ago`;
373
+ else if (minutes < 60) relative = `${minutes}m ago`;
374
+ else if (hours < 24) relative = `${hours}h ago`;
375
+ else relative = `${days}d ago`;
376
+
377
+ return { relative, full: date.toISOString() };
378
+ }
379
+
380
+ /** Check if a string looks like an ISO timestamp */
381
+ function isTimestamp(value: string): boolean {
382
+ return /^\d{4}-\d{2}-\d{2}T/.test(value) || /^\d{4}-\d{2}-\d{2} /.test(value);
383
+ }
384
+
385
+ /** Check if a string looks like an ID (long hex, uuid, etc.) */
386
+ function isIdLike(value: string): boolean {
387
+ return /^[a-f0-9-]{16,}$/i.test(value) || /^[a-zA-Z0-9_-]{20,}$/.test(value);
388
+ }
389
+
390
+ /* ------------------------------------------------------------------ */
391
+ /* MetadataGrid & MetadataRow */
392
+ /* ------------------------------------------------------------------ */
393
+
394
+ /** Single metadata row */
395
+ function MetadataRow({
396
+ label,
397
+ value,
398
+ }: {
399
+ label: string;
400
+ value: unknown;
401
+ }) {
402
+ const strVal = value === null ? "null" : String(value);
403
+
404
+ // Timestamp formatting
405
+ if (typeof value === "string" && isTimestamp(value)) {
406
+ const rel = formatRelativeTime(value);
407
+ if (rel) {
408
+ return (
409
+ <div className="flex items-baseline gap-2 py-0.5 min-w-0">
410
+ <span className="text-xs text-foreground-muted uppercase tracking-wider shrink-0">
411
+ {formatLabel(label)}
412
+ </span>
413
+ <span
414
+ className="text-xs text-foreground-secondary font-mono flex items-center gap-1 truncate"
415
+ title={rel.full}
416
+ >
417
+ <Clock className="h-2.5 w-2.5 shrink-0 text-foreground-muted" />
418
+ {rel.relative}
419
+ </span>
420
+ </div>
421
+ );
422
+ }
423
+ }
424
+
425
+ // ID-like values -- truncate + copy
426
+ if (typeof value === "string" && isIdLike(value)) {
427
+ const truncated =
428
+ value.length > 16
429
+ ? value.slice(0, 8) + "\u2026" + value.slice(-4)
430
+ : value;
431
+ return (
432
+ <div className="flex items-baseline gap-2 py-0.5 min-w-0">
433
+ <span className="text-xs text-foreground-muted uppercase tracking-wider shrink-0">
434
+ {formatLabel(label)}
435
+ </span>
436
+ <span
437
+ className="text-xs text-foreground-secondary font-mono truncate"
438
+ title={value}
439
+ >
440
+ {truncated}
441
+ </span>
442
+ <CopyButton value={value} />
443
+ </div>
444
+ );
445
+ }
446
+
447
+ // Boolean in metadata (from the < 2 fallback)
448
+ if (typeof value === "boolean") {
449
+ return (
450
+ <div className="flex items-baseline gap-2 py-0.5 min-w-0">
451
+ <span className="text-xs text-foreground-muted uppercase tracking-wider shrink-0">
452
+ {formatLabel(label)}
453
+ </span>
454
+ <span
455
+ className={cn(
456
+ "text-xs font-mono",
457
+ value ? "text-success" : "text-error"
458
+ )}
459
+ >
460
+ {String(value)}
461
+ </span>
462
+ </div>
463
+ );
464
+ }
465
+
466
+ // Default
467
+ return (
468
+ <div className="flex items-baseline gap-2 py-0.5 min-w-0">
469
+ <span className="text-xs text-foreground-muted uppercase tracking-wider shrink-0">
470
+ {formatLabel(label)}
471
+ </span>
472
+ <span className="text-xs text-foreground-secondary font-mono truncate" title={strVal}>
473
+ {strVal.length > 60 ? strVal.slice(0, 60) + "\u2026" : strVal}
474
+ </span>
475
+ </div>
476
+ );
477
+ }
478
+
479
+ /** Metadata Grid -- compact 2-column key-value layout */
480
+ export function MetadataGrid({
481
+ metadata,
482
+ }: {
483
+ metadata: Array<{ key: string; value: unknown }>;
484
+ }) {
485
+ if (metadata.length === 0) return null;
486
+
487
+ // Separate simple (primitive) values from complex (object/array) values
488
+ const simpleEntries = metadata.filter(
489
+ ({ value }) =>
490
+ typeof value === "string" ||
491
+ typeof value === "number" ||
492
+ typeof value === "boolean" ||
493
+ value === null
494
+ );
495
+ const complexEntries = metadata.filter(
496
+ ({ value }) =>
497
+ typeof value === "object" && value !== null
498
+ );
499
+
500
+ return (
501
+ <div className="space-y-1.5">
502
+ <SmartSectionHeader>Metadata</SmartSectionHeader>
503
+ {simpleEntries.length > 0 && (
504
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1 rounded-md bg-background-secondary/50 border border-border/40 px-3 py-2">
505
+ {simpleEntries.map(({ key, value }) => (
506
+ <MetadataRow key={key} label={key} value={value} />
507
+ ))}
508
+ </div>
509
+ )}
510
+ {complexEntries.map(({ key, value }) => (
511
+ <div key={key} className="rounded-md bg-background-secondary/50 border border-border/40 px-3 py-2">
512
+ <div className="text-xs text-foreground-muted uppercase tracking-wider mb-1">
513
+ {formatLabel(key)}
514
+ </div>
515
+ <div className="font-mono text-xs">
516
+ <JsonNode keyName={null} value={value} isLast />
517
+ </div>
518
+ </div>
519
+ ))}
520
+ </div>
521
+ );
522
+ }
523
+
524
+ /* ------------------------------------------------------------------ */
525
+ /* CollapsibleRawJson */
526
+ /* ------------------------------------------------------------------ */
527
+
528
+ /** Collapsible Raw JSON section */
529
+ export function CollapsibleRawJson({ data }: { data: unknown }) {
530
+ const [expanded, setExpanded] = useState(false);
531
+ const [copied, setCopied] = useState(false);
532
+
533
+ const handleCopyAll = () => {
534
+ try {
535
+ const text = JSON.stringify(data, null, 2);
536
+ navigator.clipboard.writeText(text).then(() => {
537
+ setCopied(true);
538
+ setTimeout(() => setCopied(false), 1500);
539
+ }).catch(() => {});
540
+ } catch {
541
+ // JSON.stringify can throw on circular references
542
+ }
543
+ };
544
+
545
+ return (
546
+ <div className="space-y-0">
547
+ <div
548
+ role="button"
549
+ tabIndex={0}
550
+ onClick={() => setExpanded((p) => !p)}
551
+ onKeyDown={(e) => {
552
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setExpanded((p) => !p); }
553
+ }}
554
+ className="flex items-center gap-2 w-full text-left px-2 py-1.5 rounded-md hover:bg-background-secondary/50 transition-colors group cursor-pointer select-none"
555
+ >
556
+ <ChevronDown
557
+ className={cn(
558
+ "h-3 w-3 text-primary transition-transform duration-150",
559
+ !expanded && "-rotate-90"
560
+ )}
561
+ />
562
+ <span className="text-xs font-medium text-foreground-muted tracking-wider uppercase">
563
+ Raw JSON
564
+ </span>
565
+ {!expanded && (
566
+ <span className="text-xs text-foreground-muted">
567
+ (click to expand)
568
+ </span>
569
+ )}
570
+ {expanded && (
571
+ <button
572
+ type="button"
573
+ onClick={(e) => {
574
+ e.stopPropagation();
575
+ handleCopyAll();
576
+ }}
577
+ className="ml-auto inline-flex items-center gap-1 text-xs text-foreground-muted hover:text-primary transition-colors px-1.5 py-0.5 rounded"
578
+ title="Copy all JSON"
579
+ >
580
+ {copied ? (
581
+ <Check className="h-3 w-3 text-success" />
582
+ ) : (
583
+ <Copy className="h-3 w-3" />
584
+ )}
585
+ Copy All
586
+ </button>
587
+ )}
588
+ </div>
589
+ {expanded && (
590
+ <div className="animate-[fadeIn_100ms_ease-out] rounded-md bg-background-secondary p-3 mt-1">
591
+ <JsonTreeView data={data} />
592
+ </div>
593
+ )}
594
+ </div>
595
+ );
596
+ }
@@ -0,0 +1,47 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { cn } from "@/lib/cn";
5
+
6
+ /* ------------------------------------------------------------------ */
7
+ /* Input / Output toggle */
8
+ /* ------------------------------------------------------------------ */
9
+
10
+ interface DataToggleProps {
11
+ showInput: boolean;
12
+ onToggle: (showInput: boolean) => void;
13
+ }
14
+
15
+ /** Input/Output tab toggle for switching between task input and result data */
16
+ export function DataToggle({ showInput, onToggle }: DataToggleProps) {
17
+ return (
18
+ <div className="flex items-center gap-2 mb-3">
19
+ <button
20
+ type="button"
21
+ onClick={() => onToggle(true)}
22
+ className={cn(
23
+ "text-xs px-3 py-1 min-h-[44px] rounded transition-colors",
24
+ showInput
25
+ ? "bg-primary-muted text-primary"
26
+ : "text-foreground-muted hover:text-foreground-secondary"
27
+ )}
28
+ >
29
+ Input
30
+ </button>
31
+ <button
32
+ type="button"
33
+ onClick={() => onToggle(false)}
34
+ className={cn(
35
+ "text-xs px-3 py-1 min-h-[44px] rounded transition-colors",
36
+ !showInput
37
+ ? "bg-primary-muted text-primary"
38
+ : "text-foreground-muted hover:text-foreground-secondary"
39
+ )}
40
+ >
41
+ Output
42
+ </button>
43
+ </div>
44
+ );
45
+ }
46
+
47
+ export type { DataToggleProps };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Barrel re-export for backward compatibility.
3
+ * The actual implementation has been decomposed into the json-tree/ directory.
4
+ *
5
+ * NOTE: When both json-tree.tsx and json-tree/index.tsx exist, bundlers
6
+ * (webpack/Next.js) resolve the file over the directory. This barrel ensures
7
+ * existing imports like `from "../json-tree"` continue to work.
8
+ */
9
+ export { JsonTree, JsonTreeView } from "./json-tree/index";