@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.
- package/LICENSE +21 -0
- package/README.md +490 -0
- package/next.config.mjs +25 -0
- package/package.json +104 -0
- package/postcss.config.mjs +8 -0
- package/src/app/actions/__tests__/approve-breakpoint.test.ts +246 -0
- package/src/app/actions/approve-breakpoint.ts +145 -0
- package/src/app/api/config/route.ts +137 -0
- package/src/app/api/digest/route.ts +45 -0
- package/src/app/api/runs/[runId]/events/route.ts +56 -0
- package/src/app/api/runs/[runId]/route.ts +84 -0
- package/src/app/api/runs/[runId]/tasks/[effectId]/route.ts +44 -0
- package/src/app/api/runs/route.ts +48 -0
- package/src/app/api/stream/route.ts +136 -0
- package/src/app/api/test/route.ts +1 -0
- package/src/app/api/version/route.ts +57 -0
- package/src/app/globals.css +555 -0
- package/src/app/icon.svg +20 -0
- package/src/app/layout.tsx +39 -0
- package/src/app/not-found.tsx +16 -0
- package/src/app/page.tsx +120 -0
- package/src/app/runs/[runId]/page.tsx +279 -0
- package/src/cli.ts +271 -0
- package/src/components/breakpoint/__tests__/breakpoint-approval.test.tsx +212 -0
- package/src/components/breakpoint/__tests__/breakpoint-panel.test.tsx +130 -0
- package/src/components/breakpoint/__tests__/file-preview.test.tsx +313 -0
- package/src/components/breakpoint/breakpoint-approval.tsx +138 -0
- package/src/components/breakpoint/breakpoint-panel.tsx +95 -0
- package/src/components/breakpoint/file-preview.tsx +215 -0
- package/src/components/dashboard/.gitkeep +0 -0
- package/src/components/dashboard/__tests__/breakpoint-banner.test.tsx +177 -0
- package/src/components/dashboard/__tests__/catch-up-banner.test.tsx +141 -0
- package/src/components/dashboard/__tests__/executive-summary-banner.test.tsx +164 -0
- package/src/components/dashboard/__tests__/kpi-grid.test.tsx +101 -0
- package/src/components/dashboard/__tests__/pagination-controls.test.tsx +125 -0
- package/src/components/dashboard/__tests__/project-accordion.test.tsx +97 -0
- package/src/components/dashboard/__tests__/project-list-view.test.tsx +174 -0
- package/src/components/dashboard/__tests__/project-search-input.test.tsx +110 -0
- package/src/components/dashboard/__tests__/project-section-header.test.tsx +91 -0
- package/src/components/dashboard/__tests__/project-section.test.tsx +151 -0
- package/src/components/dashboard/__tests__/run-card.test.tsx +164 -0
- package/src/components/dashboard/__tests__/run-filter-bar.test.tsx +109 -0
- package/src/components/dashboard/__tests__/run-list.test.tsx +123 -0
- package/src/components/dashboard/__tests__/search-filter.test.tsx +150 -0
- package/src/components/dashboard/__tests__/virtualized-run-list.test.tsx +179 -0
- package/src/components/dashboard/breakpoint-banner.tsx +301 -0
- package/src/components/dashboard/catch-up-banner.tsx +88 -0
- package/src/components/dashboard/executive-summary-banner.tsx +174 -0
- package/src/components/dashboard/global-search.tsx +323 -0
- package/src/components/dashboard/kpi-grid.tsx +140 -0
- package/src/components/dashboard/pagination-controls.tsx +100 -0
- package/src/components/dashboard/project-accordion.tsx +72 -0
- package/src/components/dashboard/project-health-card.tsx +536 -0
- package/src/components/dashboard/project-list-view.tsx +246 -0
- package/src/components/dashboard/project-search-input.tsx +41 -0
- package/src/components/dashboard/project-section-header.tsx +73 -0
- package/src/components/dashboard/project-section.tsx +89 -0
- package/src/components/dashboard/run-card.tsx +218 -0
- package/src/components/dashboard/run-filter-bar.tsx +100 -0
- package/src/components/dashboard/run-list.tsx +77 -0
- package/src/components/dashboard/search-filter.tsx +69 -0
- package/src/components/dashboard/virtualized-run-list.tsx +130 -0
- package/src/components/details/.gitkeep +0 -0
- package/src/components/details/__tests__/agent-panel.test.tsx +236 -0
- package/src/components/details/__tests__/json-tree.test.tsx +347 -0
- package/src/components/details/__tests__/log-viewer.test.tsx +168 -0
- package/src/components/details/__tests__/task-detail.test.tsx +212 -0
- package/src/components/details/__tests__/timing-panel.test.tsx +271 -0
- package/src/components/details/agent-panel.tsx +234 -0
- package/src/components/details/json-tree/categorize.ts +131 -0
- package/src/components/details/json-tree/index.tsx +120 -0
- package/src/components/details/json-tree/json-node.tsx +223 -0
- package/src/components/details/json-tree/smart-summary.tsx +596 -0
- package/src/components/details/json-tree/tree-controls.tsx +47 -0
- package/src/components/details/json-tree.tsx +9 -0
- package/src/components/details/log-viewer.tsx +140 -0
- package/src/components/details/task-detail.tsx +114 -0
- package/src/components/details/timing-panel.tsx +247 -0
- package/src/components/events/.gitkeep +0 -0
- package/src/components/events/__tests__/event-item.test.tsx +211 -0
- package/src/components/events/__tests__/event-stream.test.tsx +225 -0
- package/src/components/events/event-item.tsx +121 -0
- package/src/components/events/event-stream.tsx +260 -0
- package/src/components/notifications/.gitkeep +0 -0
- package/src/components/notifications/__tests__/notification-panel.test.tsx +287 -0
- package/src/components/notifications/__tests__/notification-provider.test.tsx +585 -0
- package/src/components/notifications/__tests__/toast-stack.test.tsx +217 -0
- package/src/components/notifications/notification-panel.tsx +124 -0
- package/src/components/notifications/notification-provider.tsx +175 -0
- package/src/components/notifications/toast-stack.tsx +75 -0
- package/src/components/pipeline/.gitkeep +0 -0
- package/src/components/pipeline/__tests__/parallel-group.test.tsx +88 -0
- package/src/components/pipeline/__tests__/pipeline-view.test.tsx +345 -0
- package/src/components/pipeline/__tests__/step-card.test.tsx +330 -0
- package/src/components/pipeline/parallel-group.tsx +39 -0
- package/src/components/pipeline/pipeline-view.tsx +197 -0
- package/src/components/pipeline/step-card.tsx +166 -0
- package/src/components/providers/event-stream-provider.tsx +29 -0
- package/src/components/providers.tsx +24 -0
- package/src/components/shared/.gitkeep +0 -0
- package/src/components/shared/__tests__/empty-state.test.tsx +49 -0
- package/src/components/shared/__tests__/friendly-id.test.tsx +47 -0
- package/src/components/shared/__tests__/kbd.test.tsx +45 -0
- package/src/components/shared/__tests__/kind-badge.test.tsx +71 -0
- package/src/components/shared/__tests__/metrics-row.test.tsx +74 -0
- package/src/components/shared/__tests__/outcome-banner.test.tsx +71 -0
- package/src/components/shared/__tests__/progress-bar.test.tsx +89 -0
- package/src/components/shared/__tests__/session-pill.test.tsx +62 -0
- package/src/components/shared/__tests__/settings-modal.test.tsx +201 -0
- package/src/components/shared/__tests__/shortcuts-help.test.tsx +103 -0
- package/src/components/shared/__tests__/status-badge.test.tsx +98 -0
- package/src/components/shared/__tests__/theme-provider.test.tsx +100 -0
- package/src/components/shared/__tests__/truncated-id.test.tsx +53 -0
- package/src/components/shared/app-footer.tsx +80 -0
- package/src/components/shared/app-header.tsx +160 -0
- package/src/components/shared/empty-state.tsx +18 -0
- package/src/components/shared/error-boundary.tsx +81 -0
- package/src/components/shared/friendly-id.tsx +48 -0
- package/src/components/shared/kbd.tsx +15 -0
- package/src/components/shared/kind-badge.tsx +51 -0
- package/src/components/shared/metrics-row.tsx +106 -0
- package/src/components/shared/outcome-banner.tsx +56 -0
- package/src/components/shared/progress-bar.tsx +42 -0
- package/src/components/shared/session-pill.tsx +69 -0
- package/src/components/shared/settings-modal.tsx +509 -0
- package/src/components/shared/shortcuts-help.tsx +113 -0
- package/src/components/shared/status-badge.tsx +110 -0
- package/src/components/shared/theme-provider.tsx +46 -0
- package/src/components/shared/truncated-id.tsx +51 -0
- package/src/components/ui/.gitkeep +0 -0
- package/src/components/ui/__tests__/accordion.test.tsx +96 -0
- package/src/components/ui/__tests__/badge.test.tsx +69 -0
- package/src/components/ui/__tests__/button.test.tsx +113 -0
- package/src/components/ui/__tests__/tabs.test.tsx +75 -0
- package/src/components/ui/__tests__/tooltip.test.tsx +90 -0
- package/src/components/ui/accordion.tsx +61 -0
- package/src/components/ui/badge.tsx +25 -0
- package/src/components/ui/button.tsx +40 -0
- package/src/components/ui/card.tsx +21 -0
- package/src/components/ui/scroll-area.tsx +35 -0
- package/src/components/ui/separator.tsx +24 -0
- package/src/components/ui/tabs.tsx +64 -0
- package/src/components/ui/tooltip.tsx +37 -0
- package/src/hooks/.gitkeep +0 -0
- package/src/hooks/__tests__/use-animated-number.test.ts +184 -0
- package/src/hooks/__tests__/use-batched-updates.test.ts +315 -0
- package/src/hooks/__tests__/use-event-stream.test.ts +243 -0
- package/src/hooks/__tests__/use-keyboard.test.ts +217 -0
- package/src/hooks/__tests__/use-notifications.test.ts +230 -0
- package/src/hooks/__tests__/use-polling.test.ts +274 -0
- package/src/hooks/__tests__/use-project-runs.test.ts +163 -0
- package/src/hooks/__tests__/use-projects.test.ts +248 -0
- package/src/hooks/__tests__/use-run-dashboard.test.ts +168 -0
- package/src/hooks/__tests__/use-run-detail.test.ts +273 -0
- package/src/hooks/__tests__/use-smart-polling.test.ts +305 -0
- package/src/hooks/use-animated-number.ts +87 -0
- package/src/hooks/use-batched-updates.ts +150 -0
- package/src/hooks/use-event-stream.ts +150 -0
- package/src/hooks/use-keyboard.ts +45 -0
- package/src/hooks/use-notifications.ts +82 -0
- package/src/hooks/use-persisted-state.ts +60 -0
- package/src/hooks/use-polling.ts +60 -0
- package/src/hooks/use-project-runs.ts +51 -0
- package/src/hooks/use-projects.ts +26 -0
- package/src/hooks/use-run-dashboard.ts +207 -0
- package/src/hooks/use-run-detail.ts +77 -0
- package/src/hooks/use-smart-polling.ts +144 -0
- package/src/lib/.gitkeep +0 -0
- package/src/lib/__tests__/cn.test.ts +69 -0
- package/src/lib/__tests__/config-loader.test.ts +210 -0
- package/src/lib/__tests__/config.test.ts +561 -0
- package/src/lib/__tests__/error-handler.test.ts +143 -0
- package/src/lib/__tests__/fetcher.test.ts +517 -0
- package/src/lib/__tests__/global-registry.test.ts +214 -0
- package/src/lib/__tests__/parser.test.ts +1532 -0
- package/src/lib/__tests__/path-resolver.test.ts +112 -0
- package/src/lib/__tests__/run-cache.test.ts +591 -0
- package/src/lib/__tests__/server-init.test.ts +512 -0
- package/src/lib/__tests__/source-discovery.test.ts +246 -0
- package/src/lib/__tests__/utils.test.ts +160 -0
- package/src/lib/__tests__/watcher.test.ts +227 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/config-loader.ts +195 -0
- package/src/lib/config.ts +20 -0
- package/src/lib/error-handler.ts +76 -0
- package/src/lib/fetcher.ts +394 -0
- package/src/lib/global-registry.ts +117 -0
- package/src/lib/parser.ts +794 -0
- package/src/lib/path-resolver.ts +16 -0
- package/src/lib/run-cache.ts +404 -0
- package/src/lib/server-init.ts +226 -0
- package/src/lib/services/__tests__/run-query-service.test.ts +819 -0
- package/src/lib/services/run-query-service.ts +286 -0
- package/src/lib/source-discovery.ts +216 -0
- package/src/lib/utils.ts +103 -0
- package/src/lib/watcher.ts +265 -0
- package/src/test/fixtures.ts +269 -0
- package/src/test/mocks/handlers.ts +110 -0
- package/src/test/mocks/server.ts +17 -0
- package/src/test/setup.ts +200 -0
- package/src/test/test-utils.tsx +36 -0
- package/src/types/.gitkeep +0 -0
- package/src/types/breakpoint.ts +17 -0
- package/src/types/index.ts +214 -0
- 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";
|