@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,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
|
+
});
|