@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,301 @@
1
+ "use client";
2
+ import { useState, useEffect, useLayoutEffect, useRef, useCallback, useTransition } from "react";
3
+ import Link from "next/link";
4
+ import { cn } from "@/lib/cn";
5
+ import { Hand, AlertTriangle, CheckCircle2, Check, X } from "lucide-react";
6
+ import { approveBreakpoint } from "@/app/actions/approve-breakpoint";
7
+ import type { BreakpointRunInfo } from "@/types";
8
+
9
+ interface ResolvedEntry {
10
+ bp: BreakpointRunInfo;
11
+ resolvedAt: number;
12
+ }
13
+
14
+ const RESOLVED_DISPLAY_MS = 20000; // 20 seconds
15
+ const STALENESS_THRESHOLD_MS = 120000; // 2 minutes — if a breakpoint has been shown
16
+ // continuously for this long, show a hint
17
+ const DISMISSED_KEY = "observer:dismissed-breakpoints";
18
+
19
+ /** Inline approve button for a single breakpoint in the dashboard banner. */
20
+ function BreakpointBannerItem({ bp, stale, onDismiss }: { bp: BreakpointRunInfo; stale: boolean; onDismiss?: () => void }) {
21
+ const [isPending, startTransition] = useTransition();
22
+ const [result, setResult] = useState<{ ok: boolean; msg?: string } | null>(null);
23
+
24
+ const handleApprove = (e: React.MouseEvent) => {
25
+ e.preventDefault(); // Don't navigate via the Link
26
+ e.stopPropagation();
27
+ startTransition(async () => {
28
+ const res = await approveBreakpoint(bp.runId, bp.effectId, "Approved from dashboard");
29
+ setResult(res.success ? { ok: true } : { ok: false, msg: res.error });
30
+ });
31
+ };
32
+
33
+ if (result?.ok) {
34
+ return (
35
+ <div className={cn(
36
+ "group relative flex items-center gap-3 px-4 py-3 rounded-lg",
37
+ "bg-success-muted border border-success/30",
38
+ )}>
39
+ <CheckCircle2 className="h-5 w-5 text-success shrink-0" />
40
+ <span className="text-sm text-success font-medium">Approved — {bp.projectName}</span>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ return (
46
+ <div className={cn(
47
+ "group relative flex items-center gap-3 px-4 py-3 rounded-lg",
48
+ "bg-warning-muted border border-warning/30",
49
+ "shadow-breakpoint-glow animate-breakpoint-glow",
50
+ stale && "opacity-70"
51
+ )}>
52
+ <Link href={`/runs/${bp.runId}`} className="flex items-center gap-3 flex-1 min-w-0">
53
+ <div className="relative shrink-0">
54
+ <Hand className="h-5 w-5 text-warning animate-pulse-dot" />
55
+ <span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-warning animate-ping" />
56
+ </div>
57
+ <div className="flex-1 min-w-0">
58
+ <div className="flex items-center gap-2 mb-0.5">
59
+ <AlertTriangle className="h-3 w-3 text-warning shrink-0" />
60
+ <span className="text-xs font-bold text-warning uppercase tracking-wider">
61
+ Approval Needed
62
+ </span>
63
+ {stale && (
64
+ <span className="text-xs text-foreground-muted italic" data-testid="staleness-indicator">
65
+ (checking...)
66
+ </span>
67
+ )}
68
+ <span className="text-xs text-foreground-muted font-medium">
69
+ {bp.projectName}
70
+ </span>
71
+ <span className="font-mono text-xs text-info">
72
+ {bp.runId.slice(0, 8)}
73
+ </span>
74
+ </div>
75
+ <p className="text-sm text-foreground truncate">
76
+ {bp.breakpointQuestion}
77
+ </p>
78
+ </div>
79
+ </Link>
80
+ <button
81
+ onClick={handleApprove}
82
+ disabled={isPending || !bp.effectId}
83
+ className={cn(
84
+ "shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-semibold",
85
+ "bg-success/20 text-success border border-success/30",
86
+ "hover:bg-success/30 hover:border-success/50",
87
+ "disabled:opacity-50 disabled:cursor-not-allowed",
88
+ "transition-colors"
89
+ )}
90
+ title="Approve this breakpoint"
91
+ >
92
+ <Check className="h-3.5 w-3.5" />
93
+ {isPending ? "Approving..." : "Approve"}
94
+ </button>
95
+ {stale && onDismiss && (
96
+ <button
97
+ onClick={(e) => {
98
+ e.preventDefault();
99
+ e.stopPropagation();
100
+ onDismiss();
101
+ }}
102
+ className={cn(
103
+ "shrink-0 p-1 rounded text-foreground-muted/50",
104
+ "hover:text-foreground-muted hover:bg-foreground-muted/10",
105
+ "transition-colors"
106
+ )}
107
+ title="Dismiss this stale breakpoint"
108
+ data-testid="dismiss-breakpoint"
109
+ >
110
+ <X className="h-3.5 w-3.5" />
111
+ </button>
112
+ )}
113
+ {result && !result.ok && (
114
+ <span className="text-xs text-error ml-2">{result.msg}</span>
115
+ )}
116
+ </div>
117
+ );
118
+ }
119
+
120
+ interface BreakpointBannerProps {
121
+ breakpointRuns: BreakpointRunInfo[];
122
+ }
123
+
124
+ export function BreakpointBanner({ breakpointRuns }: BreakpointBannerProps) {
125
+ const [resolvedEntries, setResolvedEntries] = useState<ResolvedEntry[]>([]);
126
+ const prevRunIdsRef = useRef<Map<string, BreakpointRunInfo>>(new Map());
127
+
128
+ // Track when each breakpoint entry was first seen (by runId).
129
+ // Entries are cleaned up when the breakpoint disappears from the list.
130
+ const firstSeenRef = useRef<Map<string, number>>(new Map());
131
+
132
+ // Dismissed stale breakpoints (client-side only, persisted in localStorage)
133
+ // Start with empty set to match SSR, then read localStorage before paint.
134
+ const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
135
+
136
+ useLayoutEffect(() => {
137
+ try {
138
+ const raw = localStorage.getItem(DISMISSED_KEY);
139
+ if (raw) {
140
+ setDismissedIds(new Set(JSON.parse(raw) as string[]));
141
+ }
142
+ } catch {
143
+ // localStorage unavailable
144
+ }
145
+ }, []);
146
+
147
+ const dismissBreakpoint = useCallback((runId: string) => {
148
+ setDismissedIds((prev) => {
149
+ const next = new Set(prev);
150
+ next.add(runId);
151
+ try { localStorage.setItem(DISMISSED_KEY, JSON.stringify([...next])); } catch { /* noop */ }
152
+ return next;
153
+ });
154
+ }, []);
155
+
156
+ // Clean up dismissed IDs that are no longer in the breakpointRuns list
157
+ useEffect(() => {
158
+ const currentIds = new Set(breakpointRuns.map((bp) => bp.runId));
159
+ setDismissedIds((prev) => {
160
+ const cleaned = new Set([...prev].filter((id) => currentIds.has(id)));
161
+ if (cleaned.size !== prev.size) {
162
+ try { localStorage.setItem(DISMISSED_KEY, JSON.stringify([...cleaned])); } catch { /* noop */ }
163
+ return cleaned;
164
+ }
165
+ return prev;
166
+ });
167
+ }, [breakpointRuns]);
168
+
169
+ // State to trigger re-renders for staleness checks
170
+ const [, setStalenessTick] = useState(0);
171
+
172
+ // Detect resolved breakpoints: runs that were previously in the list but are now gone
173
+ useEffect(() => {
174
+ const currentIds = new Set(breakpointRuns.map((bp) => bp.runId));
175
+ const now = Date.now();
176
+
177
+ const newlyResolved: ResolvedEntry[] = [];
178
+ for (const [runId, bp] of prevRunIdsRef.current) {
179
+ if (!currentIds.has(runId)) {
180
+ newlyResolved.push({ bp, resolvedAt: now });
181
+ // Clean up first-seen tracking for resolved breakpoints
182
+ firstSeenRef.current.delete(runId);
183
+ }
184
+ }
185
+
186
+ if (newlyResolved.length > 0) {
187
+ setResolvedEntries((prev) => [...prev, ...newlyResolved]);
188
+ }
189
+
190
+ // Record first-seen time for new breakpoints
191
+ for (const bp of breakpointRuns) {
192
+ if (!firstSeenRef.current.has(bp.runId)) {
193
+ firstSeenRef.current.set(bp.runId, now);
194
+ }
195
+ }
196
+
197
+ // Update prev ref
198
+ prevRunIdsRef.current = new Map(breakpointRuns.map((bp) => [bp.runId, bp]));
199
+ }, [breakpointRuns]);
200
+
201
+ // Periodic tick to re-evaluate staleness (every 10s while breakpoints are active)
202
+ useEffect(() => {
203
+ if (breakpointRuns.length === 0) return;
204
+
205
+ const timer = setInterval(() => {
206
+ setStalenessTick((t) => t + 1);
207
+ }, 10000);
208
+
209
+ return () => clearInterval(timer);
210
+ }, [breakpointRuns.length]);
211
+
212
+ // Helper: check if a breakpoint has been shown for longer than the staleness threshold
213
+ const isStale = useCallback((runId: string): boolean => {
214
+ const firstSeen = firstSeenRef.current.get(runId);
215
+ if (!firstSeen) return false;
216
+ return Date.now() - firstSeen > STALENESS_THRESHOLD_MS;
217
+ }, []);
218
+
219
+ // Auto-cleanup expired resolved entries
220
+ useEffect(() => {
221
+ if (resolvedEntries.length === 0) return;
222
+
223
+ const timer = setInterval(() => {
224
+ const now = Date.now();
225
+ setResolvedEntries((prev) =>
226
+ prev.filter((entry) => now - entry.resolvedAt < RESOLVED_DISPLAY_MS)
227
+ );
228
+ }, 1000);
229
+
230
+ return () => clearInterval(timer);
231
+ }, [resolvedEntries.length]);
232
+
233
+ const visibleRuns = breakpointRuns.filter((bp) => !dismissedIds.has(bp.runId));
234
+ const hasWaiting = visibleRuns.length > 0;
235
+ const hasResolved = resolvedEntries.length > 0;
236
+
237
+ if (!hasWaiting && !hasResolved) return null;
238
+
239
+ return (
240
+ <div role="alert" aria-live="assertive" aria-atomic="true" className="flex flex-col gap-2 mb-6" data-testid="breakpoint-banner">
241
+ {/* Active breakpoints waiting */}
242
+ {visibleRuns.map((bp) => {
243
+ const stale = isStale(bp.runId);
244
+ return (
245
+ <BreakpointBannerItem
246
+ key={bp.runId}
247
+ bp={bp}
248
+ stale={stale}
249
+ onDismiss={() => dismissBreakpoint(bp.runId)}
250
+ />
251
+ );
252
+ })}
253
+
254
+ {/* Recently resolved breakpoints — green transient display */}
255
+ {resolvedEntries.map((entry) => (
256
+ <Link
257
+ key={`resolved-${entry.bp.runId}`}
258
+ href={`/runs/${entry.bp.runId}`}
259
+ className={cn(
260
+ "group relative flex items-center gap-3 px-4 py-3 rounded-lg",
261
+ "bg-success-muted border border-success/30",
262
+ "shadow-glow-success",
263
+ "hover:border-success/50",
264
+ "transition-colors cursor-pointer"
265
+ )}
266
+ >
267
+ <div className="relative shrink-0">
268
+ <CheckCircle2 className="h-5 w-5 text-success" />
269
+ </div>
270
+ <div className="flex-1 min-w-0">
271
+ <div className="flex items-center gap-2 mb-0.5">
272
+ <CheckCircle2 className="h-3 w-3 text-success shrink-0" />
273
+ <span className="text-xs font-bold text-success uppercase tracking-wider">
274
+ Approved
275
+ </span>
276
+ <span className="text-xs text-foreground-muted font-medium">
277
+ {entry.bp.projectName}
278
+ </span>
279
+ <span className="font-mono text-xs text-info">
280
+ {entry.bp.runId.slice(0, 8)}
281
+ </span>
282
+ </div>
283
+ <p className="text-sm text-foreground-muted truncate">
284
+ {entry.bp.breakpointQuestion}
285
+ </p>
286
+ </div>
287
+ </Link>
288
+ ))}
289
+
290
+ {/* Summary count when multiple waiting breakpoints */}
291
+ {visibleRuns.length > 1 && (
292
+ <div className="flex items-center gap-2 px-3 py-1">
293
+ <Hand className="h-3.5 w-3.5 text-warning" />
294
+ <span className="text-xs font-semibold text-warning">
295
+ {visibleRuns.length} approvals pending
296
+ </span>
297
+ </div>
298
+ )}
299
+ </div>
300
+ );
301
+ }
@@ -0,0 +1,88 @@
1
+ "use client";
2
+ import { RefreshCw, Inbox } from "lucide-react";
3
+ import { cn } from "@/lib/cn";
4
+ import type { CatchUpState } from "@/hooks/use-batched-updates";
5
+
6
+ /** Lightweight snapshot of dashboard KPIs shown inside the catch-up banner. */
7
+ export interface CatchUpSummary {
8
+ failedRuns: number;
9
+ completedRuns: number;
10
+ pendingBreakpoints: number;
11
+ }
12
+
13
+ export interface CatchUpBannerProps {
14
+ catchUp: CatchUpState;
15
+ /** Optional summary metrics to give the user quick context about what happened. */
16
+ summary?: CatchUpSummary;
17
+ }
18
+
19
+ /**
20
+ * Builds a concise, human-readable summary string from dashboard metrics.
21
+ * Returns null when there is nothing noteworthy to report.
22
+ */
23
+ function buildSummaryText(summary: CatchUpSummary | undefined): string | null {
24
+ if (!summary) return null;
25
+ const parts: string[] = [];
26
+ if (summary.failedRuns > 0) {
27
+ parts.push(`${summary.failedRuns} failed`);
28
+ }
29
+ if (summary.pendingBreakpoints > 0) {
30
+ parts.push(`${summary.pendingBreakpoints} awaiting input`);
31
+ }
32
+ if (summary.completedRuns > 0) {
33
+ parts.push(`${summary.completedRuns} completed`);
34
+ }
35
+ if (parts.length === 0) return null;
36
+ return parts.join(", ");
37
+ }
38
+
39
+ /**
40
+ * Subtle notification shown when the dashboard detects a burst of SSE updates
41
+ * (catch-up mode). Displays the number of buffered updates, an optional
42
+ * summary of what happened, and a "refresh now" button to immediately apply
43
+ * all pending changes.
44
+ */
45
+ export function CatchUpBanner({ catchUp, summary }: CatchUpBannerProps) {
46
+ if (!catchUp.active) return null;
47
+
48
+ const summaryText = buildSummaryText(summary);
49
+
50
+ return (
51
+ <div
52
+ data-testid="catch-up-banner"
53
+ className={cn(
54
+ "flex items-center gap-3 px-4 py-2.5 mb-4 rounded-lg",
55
+ "bg-info-muted border border-info/20",
56
+ "animate-in fade-in slide-in-from-top-2 duration-300"
57
+ )}
58
+ >
59
+ <div className="rounded-md p-1.5 bg-info/10">
60
+ <Inbox className="h-4 w-4 text-info" />
61
+ </div>
62
+ <div className="flex-1 min-w-0">
63
+ <p className="text-sm text-foreground">
64
+ <span className="font-semibold tabular-nums">{catchUp.bufferedCount}</span>
65
+ {" "}runs updated while you were away
66
+ </p>
67
+ {summaryText && (
68
+ <p data-testid="catch-up-summary" className="text-xs text-foreground-muted mt-0.5 truncate">
69
+ {summaryText}
70
+ </p>
71
+ )}
72
+ </div>
73
+ <button
74
+ onClick={catchUp.flush}
75
+ data-testid="catch-up-refresh-btn"
76
+ className={cn(
77
+ "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-semibold",
78
+ "bg-info/10 border border-info/20 text-info",
79
+ "hover:bg-info/20 transition-colors",
80
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-info/50"
81
+ )}
82
+ >
83
+ <RefreshCw className="h-3 w-3" />
84
+ Refresh now
85
+ </button>
86
+ </div>
87
+ );
88
+ }
@@ -0,0 +1,174 @@
1
+ "use client";
2
+ import { useMemo } from "react";
3
+ import { CheckCircle2, AlertTriangle, XCircle, X } from "lucide-react";
4
+ import { cn } from "@/lib/cn";
5
+ import type { RunStatus } from "@/types";
6
+
7
+ export interface ExecutiveSummaryMetrics {
8
+ totalProjects: number;
9
+ activeRuns: number;
10
+ failedRuns: number;
11
+ completedRuns: number;
12
+ staleRuns: number;
13
+ pendingBreakpoints: number;
14
+ }
15
+
16
+ type SeverityLevel = "healthy" | "amber" | "red";
17
+
18
+ interface SummaryIssue {
19
+ text: string;
20
+ filter: RunStatus | "stale" | null;
21
+ }
22
+
23
+ interface SummaryResult {
24
+ severity: SeverityLevel;
25
+ issues: SummaryIssue[];
26
+ icon: React.ReactNode;
27
+ }
28
+
29
+ function deriveSummary(m: ExecutiveSummaryMetrics): SummaryResult {
30
+ const issues: SummaryIssue[] = [];
31
+ let severity: SeverityLevel = "healthy";
32
+
33
+ // Red-level issues
34
+ if (m.failedRuns > 0) {
35
+ issues.push({
36
+ text: `${m.failedRuns} run${m.failedRuns !== 1 ? "s" : ""} failing`,
37
+ filter: "failed",
38
+ });
39
+ severity = "red";
40
+ }
41
+
42
+ // Amber-level issues
43
+ if (m.pendingBreakpoints > 0) {
44
+ issues.push({
45
+ text: `${m.pendingBreakpoints} approval${m.pendingBreakpoints !== 1 ? "s" : ""} need${m.pendingBreakpoints === 1 ? "s" : ""} your attention`,
46
+ filter: "waiting",
47
+ });
48
+ if (severity !== "red") severity = "amber";
49
+ }
50
+
51
+ if (m.staleRuns > 0) {
52
+ issues.push({
53
+ text: `${m.staleRuns} stale run${m.staleRuns !== 1 ? "s" : ""}`,
54
+ filter: "stale",
55
+ });
56
+ if (severity !== "red") severity = "amber";
57
+ }
58
+
59
+ // Healthy
60
+ if (issues.length === 0) {
61
+ const projectLabel = m.totalProjects === 1 ? "project" : "projects";
62
+ const text =
63
+ m.activeRuns > 0
64
+ ? `All ${m.totalProjects} ${projectLabel} healthy \u2014 ${m.activeRuns} run${m.activeRuns !== 1 ? "s" : ""} in progress`
65
+ : `All ${m.totalProjects} ${projectLabel} healthy`;
66
+ return {
67
+ severity: "healthy",
68
+ issues: [{ text, filter: null }],
69
+ icon: <CheckCircle2 className="h-4 w-4 shrink-0" aria-hidden="true" />,
70
+ };
71
+ }
72
+
73
+ const icon =
74
+ severity === "red" ? (
75
+ <XCircle className="h-4 w-4 shrink-0" aria-hidden="true" />
76
+ ) : (
77
+ <AlertTriangle className="h-4 w-4 shrink-0" aria-hidden="true" />
78
+ );
79
+
80
+ return { severity, issues, icon };
81
+ }
82
+
83
+ const severityStyles: Record<
84
+ SeverityLevel,
85
+ { container: string; text: string; iconColor: string }
86
+ > = {
87
+ healthy: {
88
+ container:
89
+ "border-success/25 bg-success-muted shadow-neon-glow-success-sm",
90
+ text: "text-success",
91
+ iconColor: "text-success",
92
+ },
93
+ amber: {
94
+ container:
95
+ "border-warning/25 bg-warning-muted shadow-neon-glow-warning-sm",
96
+ text: "text-warning",
97
+ iconColor: "text-warning",
98
+ },
99
+ red: {
100
+ container: "border-error/25 bg-error-muted shadow-neon-glow-error-sm",
101
+ text: "text-error",
102
+ iconColor: "text-error",
103
+ },
104
+ };
105
+
106
+ interface ExecutiveSummaryBannerProps {
107
+ metrics: ExecutiveSummaryMetrics;
108
+ onFilterChange?: (filter: RunStatus | "stale") => void;
109
+ dismissed?: boolean;
110
+ onDismiss?: () => void;
111
+ }
112
+
113
+ export function ExecutiveSummaryBanner({
114
+ metrics,
115
+ onFilterChange,
116
+ dismissed,
117
+ onDismiss,
118
+ }: ExecutiveSummaryBannerProps) {
119
+ const summary = useMemo(() => deriveSummary(metrics), [metrics]);
120
+ const styles = severityStyles[summary.severity];
121
+
122
+ if (dismissed) return null;
123
+
124
+ return (
125
+ <div
126
+ role="status"
127
+ aria-live="polite"
128
+ aria-atomic="true"
129
+ data-testid="executive-summary-banner"
130
+ className={cn(
131
+ "flex items-center gap-2.5 rounded-xl border px-4 py-3 mb-6 shadow-sm transition-all duration-300",
132
+ styles.container
133
+ )}
134
+ >
135
+ <span className={styles.iconColor}>{summary.icon}</span>
136
+ <p
137
+ className={cn(
138
+ "text-sm font-medium leading-snug flex-1",
139
+ styles.text
140
+ )}
141
+ >
142
+ {summary.issues.map((issue, i) => (
143
+ <span key={i}>
144
+ {i > 0 && ", "}
145
+ {issue.filter && onFilterChange ? (
146
+ <button
147
+ onClick={() => onFilterChange(issue.filter!)}
148
+ className="underline decoration-dotted underline-offset-2 hover:decoration-solid transition-all"
149
+ >
150
+ {issue.text}
151
+ </button>
152
+ ) : (
153
+ issue.text
154
+ )}
155
+ </span>
156
+ ))}
157
+ </p>
158
+ {summary.severity !== "healthy" && onDismiss && (
159
+ <button
160
+ data-testid="executive-summary-dismiss"
161
+ onClick={onDismiss}
162
+ className={cn(
163
+ "rounded-md p-1.5 min-h-[44px] min-w-[44px] flex items-center justify-center transition-colors",
164
+ styles.iconColor,
165
+ "opacity-60 hover:opacity-100"
166
+ )}
167
+ aria-label="Dismiss banner"
168
+ >
169
+ <X className="h-3.5 w-3.5" />
170
+ </button>
171
+ )}
172
+ </div>
173
+ );
174
+ }