@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,260 @@
1
+ "use client";
2
+ import { useState, useMemo, useRef, useEffect, useCallback, memo } from "react";
3
+ import { ScrollArea } from "@/components/ui/scroll-area";
4
+ import { EventItem } from "./event-item";
5
+ import { cn } from "@/lib/cn";
6
+ import { formatDuration } from "@/lib/utils";
7
+ import { ChevronUp } from "lucide-react";
8
+ import type { JournalEvent, EventType } from "@/types";
9
+
10
+ const filterOptions: { label: string; value: EventType | "all" }[] = [
11
+ { label: "All", value: "all" },
12
+ { label: "Tasks", value: "EFFECT_REQUESTED" },
13
+ { label: "Results", value: "EFFECT_RESOLVED" },
14
+ { label: "Errors", value: "RUN_FAILED" },
15
+ ];
16
+
17
+ const EVENTS_PER_PAGE = 20;
18
+
19
+ /** Group consecutive same-type events into summary rows */
20
+ interface EventGroup {
21
+ type: "single";
22
+ event: JournalEvent;
23
+ }
24
+ interface EventGroupCollapsed {
25
+ type: "group";
26
+ eventType: string;
27
+ events: JournalEvent[];
28
+ count: number;
29
+ }
30
+ type GroupedEntry = EventGroup | EventGroupCollapsed;
31
+
32
+ function groupConsecutiveEvents(events: JournalEvent[]): GroupedEntry[] {
33
+ if (events.length === 0) return [];
34
+
35
+ const result: GroupedEntry[] = [];
36
+ let i = 0;
37
+
38
+ while (i < events.length) {
39
+ let j = i + 1;
40
+ // Count consecutive events of same type
41
+ while (j < events.length && events[j].type === events[i].type) {
42
+ j++;
43
+ }
44
+ const count = j - i;
45
+ if (count >= 3) {
46
+ // Group 3+ consecutive same-type events
47
+ result.push({
48
+ type: "group",
49
+ eventType: events[i].type,
50
+ events: events.slice(i, j),
51
+ count,
52
+ });
53
+ } else {
54
+ // Show individually
55
+ for (let k = i; k < j; k++) {
56
+ result.push({ type: "single", event: events[k] });
57
+ }
58
+ }
59
+ i = j;
60
+ }
61
+
62
+ return result;
63
+ }
64
+
65
+ interface EventStreamProps {
66
+ events: JournalEvent[];
67
+ onEventClick?: (event: JournalEvent) => void;
68
+ }
69
+
70
+ export const EventStream = memo(function EventStream({ events, onEventClick }: EventStreamProps) {
71
+ const [filter, setFilter] = useState<EventType | "all">("all");
72
+ const [visibleCount, setVisibleCount] = useState(EVENTS_PER_PAGE);
73
+ const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
74
+ const scrollRef = useRef<HTMLDivElement>(null);
75
+ const [autoScroll, setAutoScroll] = useState(true);
76
+ const prevEventCount = useRef(events.length);
77
+
78
+ // Summary stats across all events (unfiltered)
79
+ const stats = useMemo(() => {
80
+ let tasks = 0;
81
+ let completed = 0;
82
+ let errors = 0;
83
+ for (const e of events) {
84
+ if (e.type === "EFFECT_REQUESTED") tasks++;
85
+ if (e.type === "EFFECT_RESOLVED") {
86
+ const p = e.payload as Record<string, unknown>;
87
+ if (p.status === "error") errors++;
88
+ else completed++;
89
+ }
90
+ if (e.type === "RUN_FAILED") errors++;
91
+ }
92
+ // Elapsed from first to last event
93
+ let elapsed: number | null = null;
94
+ if (events.length >= 2) {
95
+ const first = new Date(events[0].ts).getTime();
96
+ const last = new Date(events[events.length - 1].ts).getTime();
97
+ elapsed = Math.abs(last - first);
98
+ }
99
+ return { tasks, completed, errors, elapsed };
100
+ }, [events]);
101
+
102
+ const filtered = useMemo(() => {
103
+ return filter === "all" ? events : events.filter((e) => e.type === filter);
104
+ }, [events, filter]);
105
+
106
+ // Show newest first
107
+ const reversed = useMemo(() => [...filtered].reverse(), [filtered]);
108
+
109
+ // Limit visible events
110
+ const visible = useMemo(() => reversed.slice(0, visibleCount), [reversed, visibleCount]);
111
+
112
+ // Group consecutive same-type events
113
+ const grouped = useMemo(() => groupConsecutiveEvents(visible), [visible]);
114
+
115
+ const remainingCount = reversed.length - visibleCount;
116
+
117
+ // Auto-scroll to top (newest) when new events arrive
118
+ useEffect(() => {
119
+ if (events.length > prevEventCount.current && autoScroll && scrollRef.current) {
120
+ scrollRef.current.scrollTop = 0;
121
+ }
122
+ prevEventCount.current = events.length;
123
+ }, [events.length, autoScroll]);
124
+
125
+ // Detect user scroll to pause auto-scroll
126
+ const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
127
+ const target = e.currentTarget;
128
+ // If user scrolled away from top, pause auto-scroll
129
+ setAutoScroll(target.scrollTop < 10);
130
+ }, []);
131
+
132
+ const toggleGroup = useCallback((index: number) => {
133
+ setExpandedGroups((prev) => {
134
+ const next = new Set(prev);
135
+ if (next.has(index)) next.delete(index);
136
+ else next.add(index);
137
+ return next;
138
+ });
139
+ }, []);
140
+
141
+ // Reset visible count when filter changes
142
+ useEffect(() => {
143
+ setVisibleCount(EVENTS_PER_PAGE);
144
+ setExpandedGroups(new Set());
145
+ }, [filter]);
146
+
147
+ return (
148
+ <div data-testid="event-stream" className="flex flex-col h-full">
149
+ <div className="shrink-0 border-b border-border p-3 bg-background-secondary/30">
150
+ <div className="flex items-center justify-between mb-2">
151
+ <h3 className="text-xs font-medium text-foreground-muted uppercase tracking-wider">Event Stream</h3>
152
+ <span className="text-xs leading-tight text-secondary font-mono tabular-nums">
153
+ {filtered.length} events
154
+ </span>
155
+ </div>
156
+ <div className="flex items-center gap-1">
157
+ {filterOptions.map((f) => (
158
+ <button
159
+ key={f.value}
160
+ onClick={() => setFilter(f.value)}
161
+ className={cn(
162
+ "rounded-md px-2.5 py-1 min-h-[44px] text-xs leading-tight font-medium transition-all duration-200",
163
+ filter === f.value
164
+ ? "bg-primary/15 text-primary border border-primary/25 shadow-event-filter-active"
165
+ : "text-foreground-muted hover:text-foreground-secondary hover:bg-muted/50 border border-transparent"
166
+ )}
167
+ >
168
+ {f.label}
169
+ </button>
170
+ ))}
171
+ </div>
172
+ {/* Summary stats bar */}
173
+ {events.length > 0 && (
174
+ <div className="flex items-center gap-2 mt-2 text-xs leading-tight font-mono text-foreground-muted tabular-nums">
175
+ <span>Tasks: <span className="text-foreground-secondary">{stats.tasks}</span></span>
176
+ <span className="text-border">|</span>
177
+ <span>Completed: <span className="text-success">{stats.completed}</span></span>
178
+ <span className="text-border">|</span>
179
+ <span>Errors: <span className={stats.errors > 0 ? "text-error" : "text-foreground-secondary"}>{stats.errors}</span></span>
180
+ {stats.elapsed != null && stats.elapsed > 0 && (
181
+ <>
182
+ <span className="text-border">|</span>
183
+ <span>Elapsed: <span className="text-secondary/80">{formatDuration(stats.elapsed)}</span></span>
184
+ </>
185
+ )}
186
+ </div>
187
+ )}
188
+ </div>
189
+ <ScrollArea className="flex-1" ref={scrollRef} onScrollCapture={handleScroll}>
190
+ <div className="py-1">
191
+ {grouped.map((entry, idx) => {
192
+ if (entry.type === "single") {
193
+ return (
194
+ <EventItem
195
+ key={entry.event.id || entry.event.seq}
196
+ event={entry.event}
197
+ onClick={() => onEventClick?.(entry.event)}
198
+ />
199
+ );
200
+ }
201
+
202
+ // Collapsed group
203
+ const isExpanded = expandedGroups.has(idx);
204
+ const config = typeLabel(entry.eventType);
205
+ return (
206
+ <div key={`group-${idx}`}>
207
+ <button
208
+ onClick={() => toggleGroup(idx)}
209
+ className="w-full text-left px-3 py-1.5 hover:bg-background-secondary rounded transition-colors flex items-center gap-2"
210
+ >
211
+ <span className={cn(
212
+ "inline-block h-1.5 w-1.5 rounded-full shrink-0",
213
+ config.dotColor
214
+ )} />
215
+ <span className="text-xs leading-tight text-foreground-muted font-medium tabular-nums">
216
+ {entry.count}x
217
+ </span>
218
+ <span className="text-xs text-foreground-secondary">{config.label}</span>
219
+ <ChevronUp className={cn(
220
+ "h-3 w-3 text-foreground-muted ml-auto transition-transform",
221
+ !isExpanded && "rotate-180"
222
+ )} />
223
+ </button>
224
+ {isExpanded && entry.events.map((event) => (
225
+ <EventItem
226
+ key={event.id || event.seq}
227
+ event={event}
228
+ onClick={() => onEventClick?.(event)}
229
+ />
230
+ ))}
231
+ </div>
232
+ );
233
+ })}
234
+ {remainingCount > 0 && (
235
+ <button
236
+ onClick={() => setVisibleCount((v) => v + EVENTS_PER_PAGE)}
237
+ className="w-full py-2 text-xs leading-tight text-foreground-muted hover:text-primary hover:bg-background-secondary hover:shadow-neon-glow-primary-xs transition-all"
238
+ >
239
+ Show {Math.min(remainingCount, EVENTS_PER_PAGE)} more ({remainingCount} remaining)
240
+ </button>
241
+ )}
242
+ {reversed.length === 0 && (
243
+ <div className="text-xs text-foreground-muted text-center py-8">No events yet</div>
244
+ )}
245
+ </div>
246
+ </ScrollArea>
247
+ </div>
248
+ );
249
+ });
250
+
251
+ function typeLabel(type: string): { label: string; dotColor: string } {
252
+ switch (type) {
253
+ case "EFFECT_REQUESTED": return { label: "Requested", dotColor: "bg-foreground-muted" };
254
+ case "EFFECT_RESOLVED": return { label: "Resolved", dotColor: "bg-success" };
255
+ case "RUN_COMPLETED": return { label: "Completed", dotColor: "bg-success" };
256
+ case "RUN_FAILED": return { label: "Failed", dotColor: "bg-error" };
257
+ case "RUN_CREATED": return { label: "Created", dotColor: "bg-primary" };
258
+ default: return { label: type, dotColor: "bg-foreground-muted" };
259
+ }
260
+ }
File without changes
@@ -0,0 +1,287 @@
1
+ import { render, screen, setupUser } from '@/test/test-utils';
2
+ import { NotificationPanel } from '../notification-panel';
3
+ import type { AppNotification } from '@/hooks/use-notifications';
4
+
5
+ // Mock next/navigation
6
+ const mockPush = vi.fn();
7
+ vi.mock('next/navigation', () => ({
8
+ useRouter: () => ({
9
+ push: mockPush,
10
+ replace: vi.fn(),
11
+ back: vi.fn(),
12
+ forward: vi.fn(),
13
+ refresh: vi.fn(),
14
+ prefetch: vi.fn(),
15
+ }),
16
+ }));
17
+
18
+ function makeNotification(overrides: Partial<AppNotification> = {}): AppNotification {
19
+ return {
20
+ id: overrides.id ?? 'notif-1',
21
+ title: overrides.title ?? 'Test Title',
22
+ body: overrides.body ?? 'Test body message',
23
+ type: overrides.type ?? 'info',
24
+ timestamp: overrides.timestamp ?? Date.now(),
25
+ href: overrides.href,
26
+ persistent: overrides.persistent,
27
+ };
28
+ }
29
+
30
+ describe('NotificationPanel', () => {
31
+ const defaultProps = {
32
+ open: true,
33
+ notifications: [] as AppNotification[],
34
+ onDismiss: vi.fn(),
35
+ onClose: vi.fn(),
36
+ };
37
+
38
+ beforeEach(() => {
39
+ vi.clearAllMocks();
40
+ });
41
+
42
+ // -----------------------------------------------------------------------
43
+ // Empty state
44
+ // -----------------------------------------------------------------------
45
+ it('shows "No notifications" when list is empty', () => {
46
+ render(<NotificationPanel {...defaultProps} />);
47
+
48
+ expect(screen.getByText('No notifications')).toBeInTheDocument();
49
+ });
50
+
51
+ // -----------------------------------------------------------------------
52
+ // Renders notifications
53
+ // -----------------------------------------------------------------------
54
+ it('renders a list of notifications', () => {
55
+ const notifications = [
56
+ makeNotification({ id: 'n1', title: 'Run Started', body: 'Run abc started', type: 'info' }),
57
+ makeNotification({ id: 'n2', title: 'Run Failed', body: 'Run xyz failed', type: 'error' }),
58
+ ];
59
+
60
+ render(<NotificationPanel {...defaultProps} notifications={notifications} />);
61
+
62
+ expect(screen.getByText('Run Started')).toBeInTheDocument();
63
+ expect(screen.getByText('Run abc started')).toBeInTheDocument();
64
+ expect(screen.getByText('Run Failed')).toBeInTheDocument();
65
+ expect(screen.getByText('Run xyz failed')).toBeInTheDocument();
66
+ });
67
+
68
+ it('displays the notification count', () => {
69
+ const notifications = [
70
+ makeNotification({ id: 'n1' }),
71
+ makeNotification({ id: 'n2' }),
72
+ ];
73
+
74
+ render(<NotificationPanel {...defaultProps} notifications={notifications} />);
75
+
76
+ expect(screen.getByText('(2)')).toBeInTheDocument();
77
+ });
78
+
79
+ it('does not display a count badge when there are no notifications', () => {
80
+ render(<NotificationPanel {...defaultProps} />);
81
+
82
+ expect(screen.queryByText(/\(\d+\)/)).not.toBeInTheDocument();
83
+ });
84
+
85
+ // -----------------------------------------------------------------------
86
+ // Heading
87
+ // -----------------------------------------------------------------------
88
+ it('renders the "Notifications" heading', () => {
89
+ render(<NotificationPanel {...defaultProps} />);
90
+
91
+ expect(screen.getByText('Notifications')).toBeInTheDocument();
92
+ });
93
+
94
+ // -----------------------------------------------------------------------
95
+ // Dismiss button
96
+ // -----------------------------------------------------------------------
97
+ it('calls onDismiss with the notification id when dismiss button is clicked', async () => {
98
+ const user = setupUser();
99
+ const onDismiss = vi.fn();
100
+ const notifications = [
101
+ makeNotification({ id: 'notif-42', title: 'My Alert' }),
102
+ ];
103
+
104
+ render(
105
+ <NotificationPanel
106
+ open={true}
107
+ notifications={notifications}
108
+ onDismiss={onDismiss}
109
+ onClose={vi.fn()}
110
+ />,
111
+ );
112
+
113
+ // The dismiss button is the small X inside the notification card.
114
+ // There are two X icons: one for close panel, one for dismiss notification.
115
+ // The dismiss X is inside the notification card next to the text.
116
+ const allXIcons = screen.getAllByTestId('icon-X');
117
+ // The last X is the dismiss button for the notification (inside the card)
118
+ const dismissButton = allXIcons[allXIcons.length - 1].closest('button')!;
119
+ await user.click(dismissButton);
120
+
121
+ expect(onDismiss).toHaveBeenCalledWith('notif-42');
122
+ });
123
+
124
+ // -----------------------------------------------------------------------
125
+ // Close button
126
+ // -----------------------------------------------------------------------
127
+ it('calls onClose when the panel close button is clicked', async () => {
128
+ const user = setupUser();
129
+ const onClose = vi.fn();
130
+
131
+ render(
132
+ <NotificationPanel
133
+ open={true}
134
+ notifications={[]}
135
+ onDismiss={vi.fn()}
136
+ onClose={onClose}
137
+ />,
138
+ );
139
+
140
+ // The close button at the top of the panel
141
+ const closeButton = screen.getAllByTestId('icon-X')[0].closest('button')!;
142
+ await user.click(closeButton);
143
+
144
+ expect(onClose).toHaveBeenCalledTimes(1);
145
+ });
146
+
147
+ // -----------------------------------------------------------------------
148
+ // Clicking notification with href navigates
149
+ // -----------------------------------------------------------------------
150
+ it('navigates and dismisses when a notification with href is clicked', async () => {
151
+ const user = setupUser();
152
+ const onDismiss = vi.fn();
153
+ const onClose = vi.fn();
154
+ const notifications = [
155
+ makeNotification({ id: 'n1', title: 'Go Here', href: '/runs/abc' }),
156
+ ];
157
+
158
+ render(
159
+ <NotificationPanel
160
+ open={true}
161
+ notifications={notifications}
162
+ onDismiss={onDismiss}
163
+ onClose={onClose}
164
+ />,
165
+ );
166
+
167
+ await user.click(screen.getByText('Go Here'));
168
+
169
+ expect(mockPush).toHaveBeenCalledWith('/runs/abc');
170
+ expect(onDismiss).toHaveBeenCalledWith('n1');
171
+ expect(onClose).toHaveBeenCalled();
172
+ });
173
+
174
+ it('does not navigate when a notification without href is clicked', async () => {
175
+ const user = setupUser();
176
+ const onDismiss = vi.fn();
177
+ const notifications = [
178
+ makeNotification({ id: 'n1', title: 'No Link' }),
179
+ ];
180
+
181
+ render(
182
+ <NotificationPanel
183
+ open={true}
184
+ notifications={notifications}
185
+ onDismiss={onDismiss}
186
+ onClose={vi.fn()}
187
+ />,
188
+ );
189
+
190
+ await user.click(screen.getByText('No Link'));
191
+
192
+ expect(mockPush).not.toHaveBeenCalled();
193
+ expect(onDismiss).not.toHaveBeenCalled();
194
+ });
195
+
196
+ // -----------------------------------------------------------------------
197
+ // Timestamp display
198
+ // -----------------------------------------------------------------------
199
+ it('displays "just now" for very recent notifications', () => {
200
+ const notifications = [
201
+ makeNotification({ id: 'n1', timestamp: Date.now() - 5000 }),
202
+ ];
203
+
204
+ render(<NotificationPanel {...defaultProps} notifications={notifications} />);
205
+
206
+ expect(screen.getByText('just now')).toBeInTheDocument();
207
+ });
208
+
209
+ it('displays minutes ago for older notifications', () => {
210
+ const notifications = [
211
+ makeNotification({ id: 'n1', timestamp: Date.now() - 5 * 60 * 1000 }),
212
+ ];
213
+
214
+ render(<NotificationPanel {...defaultProps} notifications={notifications} />);
215
+
216
+ expect(screen.getByText('5m ago')).toBeInTheDocument();
217
+ });
218
+
219
+ // -----------------------------------------------------------------------
220
+ // Persistent (pinned) notification
221
+ // -----------------------------------------------------------------------
222
+ it('shows pin icon for persistent notifications', () => {
223
+ const persistentNotif = makeNotification({ id: 'n-pin', persistent: true });
224
+ render(<NotificationPanel {...defaultProps} notifications={[persistentNotif]} />);
225
+ expect(screen.getByTitle('Pinned — won\'t auto-dismiss')).toBeInTheDocument();
226
+ expect(screen.getByText('· Pinned')).toBeInTheDocument();
227
+ });
228
+
229
+ it('does not show pin icon for non-persistent notifications', () => {
230
+ const notif = makeNotification({ id: 'n-nopin' });
231
+ render(<NotificationPanel {...defaultProps} notifications={[notif]} />);
232
+ expect(screen.queryByTitle('Pinned — won\'t auto-dismiss')).not.toBeInTheDocument();
233
+ expect(screen.queryByText('· Pinned')).not.toBeInTheDocument();
234
+ });
235
+
236
+ // -----------------------------------------------------------------------
237
+ // All notification types render icons
238
+ // -----------------------------------------------------------------------
239
+ it('renders icons for all notification types', () => {
240
+ const notifications = [
241
+ makeNotification({ id: 'n1', type: 'success' }),
242
+ makeNotification({ id: 'n2', type: 'error' }),
243
+ makeNotification({ id: 'n3', type: 'warning' }),
244
+ makeNotification({ id: 'n4', type: 'info' }),
245
+ ];
246
+
247
+ render(<NotificationPanel {...defaultProps} notifications={notifications} />);
248
+
249
+ expect(screen.getByTestId('icon-CheckCircle2')).toBeInTheDocument();
250
+ expect(screen.getByTestId('icon-XCircle')).toBeInTheDocument();
251
+ expect(screen.getByTestId('icon-AlertTriangle')).toBeInTheDocument();
252
+ expect(screen.getByTestId('icon-Info')).toBeInTheDocument();
253
+ });
254
+
255
+ // -----------------------------------------------------------------------
256
+ // Persistent notification shows Pin icon
257
+ // -----------------------------------------------------------------------
258
+ it('shows a Pin icon for persistent notifications', () => {
259
+ const notifications = [
260
+ makeNotification({ id: 'n1', title: 'Breakpoint', persistent: true }),
261
+ ];
262
+
263
+ render(<NotificationPanel {...defaultProps} notifications={notifications} />);
264
+
265
+ expect(screen.getByTestId('icon-Pin')).toBeInTheDocument();
266
+ expect(screen.getByTitle('Pinned — won\'t auto-dismiss')).toBeInTheDocument();
267
+ });
268
+
269
+ it('does not show a Pin icon for non-persistent notifications', () => {
270
+ const notifications = [
271
+ makeNotification({ id: 'n1', title: 'Normal' }),
272
+ ];
273
+
274
+ render(<NotificationPanel {...defaultProps} notifications={notifications} />);
275
+
276
+ expect(screen.queryByTestId('icon-Pin')).not.toBeInTheDocument();
277
+ });
278
+
279
+ // -----------------------------------------------------------------------
280
+ // Does not render when closed
281
+ // -----------------------------------------------------------------------
282
+ it('does not render content when open is false', () => {
283
+ render(<NotificationPanel {...defaultProps} open={false} />);
284
+
285
+ expect(screen.queryByText('Notifications')).not.toBeInTheDocument();
286
+ });
287
+ });