@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,323 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useRef, useCallback } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { Search, X, ArrowRight } from "lucide-react";
6
+ import { cn } from "@/lib/cn";
7
+ import { StatusBadge } from "@/components/shared/status-badge";
8
+ import { friendlyProcessName, formatShortId } from "@/lib/utils";
9
+ import type { Run } from "@/types";
10
+
11
+ interface SearchResult {
12
+ runs: Run[];
13
+ totalCount: number;
14
+ }
15
+
16
+ export function GlobalSearch() {
17
+ const router = useRouter();
18
+ const [query, setQuery] = useState("");
19
+ const [debouncedQuery, setDebouncedQuery] = useState("");
20
+ const [results, setResults] = useState<Run[]>([]);
21
+ const [isOpen, setIsOpen] = useState(false);
22
+ const [isLoading, setIsLoading] = useState(false);
23
+ const [selectedIndex, setSelectedIndex] = useState(-1);
24
+ const [hasSearched, setHasSearched] = useState(false);
25
+
26
+ const inputRef = useRef<HTMLInputElement>(null);
27
+ const containerRef = useRef<HTMLDivElement>(null);
28
+ const listRef = useRef<HTMLUListElement>(null);
29
+
30
+ // Debounce the search query
31
+ useEffect(() => {
32
+ const timer = setTimeout(() => {
33
+ setDebouncedQuery(query.trim());
34
+ }, 300);
35
+ return () => clearTimeout(timer);
36
+ }, [query]);
37
+
38
+ // Fetch results when debounced query changes
39
+ useEffect(() => {
40
+ if (!debouncedQuery) {
41
+ setResults([]);
42
+ setHasSearched(false);
43
+ setSelectedIndex(-1);
44
+ return;
45
+ }
46
+
47
+ let cancelled = false;
48
+
49
+ async function fetchResults() {
50
+ setIsLoading(true);
51
+ try {
52
+ const params = new URLSearchParams({
53
+ search: debouncedQuery,
54
+ limit: "10",
55
+ });
56
+ const res = await fetch(`/api/runs?${params}`);
57
+ if (!res.ok) throw new Error("Search failed");
58
+ const data: SearchResult = await res.json();
59
+ if (!cancelled) {
60
+ setResults(data.runs ?? []);
61
+ setHasSearched(true);
62
+ setSelectedIndex(-1);
63
+ }
64
+ } catch {
65
+ if (!cancelled) {
66
+ setResults([]);
67
+ setHasSearched(true);
68
+ }
69
+ } finally {
70
+ if (!cancelled) {
71
+ setIsLoading(false);
72
+ }
73
+ }
74
+ }
75
+
76
+ fetchResults();
77
+ return () => {
78
+ cancelled = true;
79
+ };
80
+ }, [debouncedQuery]);
81
+
82
+ // Navigate to a run
83
+ const navigateToRun = useCallback(
84
+ (runId: string) => {
85
+ setIsOpen(false);
86
+ setQuery("");
87
+ setResults([]);
88
+ setHasSearched(false);
89
+ router.push(`/runs/${runId}`);
90
+ },
91
+ [router]
92
+ );
93
+
94
+ // Keyboard navigation
95
+ const handleKeyDown = useCallback(
96
+ (e: React.KeyboardEvent) => {
97
+ if (!isOpen) return;
98
+
99
+ switch (e.key) {
100
+ case "ArrowDown":
101
+ e.preventDefault();
102
+ setSelectedIndex((prev) =>
103
+ prev < results.length - 1 ? prev + 1 : 0
104
+ );
105
+ break;
106
+ case "ArrowUp":
107
+ e.preventDefault();
108
+ setSelectedIndex((prev) =>
109
+ prev > 0 ? prev - 1 : results.length - 1
110
+ );
111
+ break;
112
+ case "Enter":
113
+ e.preventDefault();
114
+ if (selectedIndex >= 0 && selectedIndex < results.length) {
115
+ navigateToRun(results[selectedIndex].runId);
116
+ }
117
+ break;
118
+ case "Escape":
119
+ e.preventDefault();
120
+ setIsOpen(false);
121
+ inputRef.current?.blur();
122
+ break;
123
+ }
124
+ },
125
+ [isOpen, results, selectedIndex, navigateToRun]
126
+ );
127
+
128
+ // Scroll selected item into view
129
+ useEffect(() => {
130
+ if (selectedIndex >= 0 && listRef.current) {
131
+ const items = listRef.current.querySelectorAll("[data-search-item]");
132
+ items[selectedIndex]?.scrollIntoView({ block: "nearest" });
133
+ }
134
+ }, [selectedIndex]);
135
+
136
+ // Cmd+K / Ctrl+K and "/" global shortcut
137
+ useEffect(() => {
138
+ const handler = (e: KeyboardEvent) => {
139
+ if ((e.metaKey || e.ctrlKey) && e.key === "k") {
140
+ e.preventDefault();
141
+ inputRef.current?.focus();
142
+ setIsOpen(true);
143
+ }
144
+ // "/" key to focus search (skip if already in an input)
145
+ if (
146
+ e.key === "/" &&
147
+ !e.metaKey && !e.ctrlKey && !e.altKey &&
148
+ !(e.target instanceof HTMLInputElement) &&
149
+ !(e.target instanceof HTMLTextAreaElement) &&
150
+ !(e.target instanceof HTMLSelectElement) &&
151
+ !(e.target as HTMLElement)?.isContentEditable
152
+ ) {
153
+ e.preventDefault();
154
+ inputRef.current?.focus();
155
+ setIsOpen(true);
156
+ }
157
+ };
158
+ window.addEventListener("keydown", handler);
159
+ return () => window.removeEventListener("keydown", handler);
160
+ }, []);
161
+
162
+ // Close dropdown on outside click
163
+ useEffect(() => {
164
+ const handler = (e: MouseEvent) => {
165
+ if (
166
+ containerRef.current &&
167
+ !containerRef.current.contains(e.target as Node)
168
+ ) {
169
+ setIsOpen(false);
170
+ }
171
+ };
172
+ document.addEventListener("mousedown", handler);
173
+ return () => document.removeEventListener("mousedown", handler);
174
+ }, []);
175
+
176
+ const showDropdown = isOpen && (query.trim().length > 0);
177
+
178
+ return (
179
+ <div ref={containerRef} className="relative w-full max-w-xl mx-auto mb-6">
180
+ {/* Search input */}
181
+ <div className="relative">
182
+ <Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-foreground-muted pointer-events-none" />
183
+ <input
184
+ ref={inputRef}
185
+ type="text"
186
+ value={query}
187
+ onChange={(e) => {
188
+ setQuery(e.target.value);
189
+ setIsOpen(true);
190
+ }}
191
+ onFocus={() => {
192
+ if (query.trim()) setIsOpen(true);
193
+ }}
194
+ onKeyDown={handleKeyDown}
195
+ placeholder="Search runs by ID, process name, or task title..."
196
+ className={cn(
197
+ "w-full rounded-lg border border-border bg-card/80 backdrop-blur-sm",
198
+ "pl-10 pr-20 py-2.5 text-sm text-foreground",
199
+ "placeholder:text-foreground-muted/60",
200
+ "focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/30",
201
+ "focus:shadow-neon-glow-primary-focus transition-all"
202
+ )}
203
+ data-testid="global-search-input"
204
+ />
205
+ <div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1.5">
206
+ {query && (
207
+ <button
208
+ onClick={() => {
209
+ setQuery("");
210
+ setResults([]);
211
+ setHasSearched(false);
212
+ setIsOpen(false);
213
+ inputRef.current?.focus();
214
+ }}
215
+ className="rounded-md p-2 min-h-[44px] min-w-[44px] flex items-center justify-center text-foreground-muted hover:text-foreground-secondary transition-colors"
216
+ aria-label="Clear search"
217
+ >
218
+ <X className="h-3.5 w-3.5" />
219
+ </button>
220
+ )}
221
+ <kbd className="hidden sm:inline-flex items-center gap-0.5 rounded border border-border bg-background-secondary px-1.5 py-0.5 text-xs font-medium text-foreground-muted">
222
+ <span className="text-xs">{typeof navigator !== "undefined" && /Mac/i.test(navigator.userAgent) ? "\u2318" : "Ctrl"}</span>
223
+ <span>K</span>
224
+ </kbd>
225
+ </div>
226
+ </div>
227
+
228
+ {/* Dropdown results */}
229
+ {showDropdown && (
230
+ <div
231
+ className={cn(
232
+ "absolute z-50 top-full left-0 right-0 mt-1.5",
233
+ "rounded-lg border border-border bg-card/95 backdrop-blur-md shadow-xl",
234
+ "overflow-hidden"
235
+ )}
236
+ data-testid="global-search-dropdown"
237
+ >
238
+ {isLoading && (
239
+ <div className="flex items-center gap-2 px-4 py-3 text-xs text-foreground-muted">
240
+ <div className="h-3 w-3 rounded-full border-2 border-primary/40 border-t-primary animate-spin" />
241
+ Searching...
242
+ </div>
243
+ )}
244
+
245
+ {!isLoading && hasSearched && results.length === 0 && (
246
+ <div className="px-4 py-6 text-center">
247
+ <Search className="h-5 w-5 text-foreground-muted/30 mx-auto mb-2" />
248
+ <p className="text-xs text-foreground-muted">
249
+ No runs found for &ldquo;{debouncedQuery}&rdquo;
250
+ </p>
251
+ </div>
252
+ )}
253
+
254
+ {!isLoading && results.length > 0 && (
255
+ <ul ref={listRef} className="max-h-80 overflow-y-auto py-1" role="listbox">
256
+ {results.map((run, index) => (
257
+ <li
258
+ key={run.runId}
259
+ data-search-item
260
+ role="option"
261
+ aria-selected={index === selectedIndex}
262
+ onClick={() => navigateToRun(run.runId)}
263
+ className={cn(
264
+ "flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors",
265
+ index === selectedIndex
266
+ ? "bg-primary/10"
267
+ : "hover:bg-background-secondary/60"
268
+ )}
269
+ >
270
+ {/* Status dot */}
271
+ <span
272
+ className={cn(
273
+ "h-2 w-2 rounded-full shrink-0",
274
+ run.status === "completed"
275
+ ? "bg-success"
276
+ : run.status === "failed"
277
+ ? "bg-error"
278
+ : run.status === "waiting" || run.status === "pending"
279
+ ? "bg-warning"
280
+ : "bg-foreground-muted"
281
+ )}
282
+ />
283
+ {/* Info */}
284
+ <div className="flex-1 min-w-0">
285
+ <div className="flex items-center gap-2">
286
+ <span className="text-sm font-medium text-foreground truncate">
287
+ {friendlyProcessName(run.processId)}
288
+ </span>
289
+ <StatusBadge
290
+ status={run.status}
291
+ waitingKind={run.waitingKind}
292
+ isStale={run.isStale}
293
+ />
294
+ </div>
295
+ <div className="flex items-center gap-2 mt-0.5">
296
+ {run.projectName && (
297
+ <span className="text-xs text-foreground-muted">
298
+ {run.projectName}
299
+ </span>
300
+ )}
301
+ <span className="font-mono text-xs text-info">
302
+ {formatShortId(run.runId, 8)}
303
+ </span>
304
+ </div>
305
+ </div>
306
+ {/* Navigate arrow */}
307
+ <ArrowRight
308
+ className={cn(
309
+ "h-3.5 w-3.5 shrink-0 transition-colors",
310
+ index === selectedIndex
311
+ ? "text-primary"
312
+ : "text-foreground-muted/40"
313
+ )}
314
+ />
315
+ </li>
316
+ ))}
317
+ </ul>
318
+ )}
319
+ </div>
320
+ )}
321
+ </div>
322
+ );
323
+ }
@@ -0,0 +1,140 @@
1
+ "use client";
2
+ import {
3
+ Layers,
4
+ Activity,
5
+ CheckCircle2,
6
+ AlertCircle,
7
+ Pause,
8
+ } from "lucide-react";
9
+ import { cn } from "@/lib/cn";
10
+ import { useAnimatedNumber } from "@/hooks/use-animated-number";
11
+ import type { DashboardMetrics, DashboardStatusFilter } from "@/hooks/use-run-dashboard";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // MetricTile (internal)
15
+ // ---------------------------------------------------------------------------
16
+
17
+ interface MetricTileProps {
18
+ label: string;
19
+ value: number;
20
+ icon: React.ReactNode;
21
+ color: "primary" | "success" | "warning" | "error" | "muted";
22
+ pulse?: boolean;
23
+ testId?: string;
24
+ active?: boolean;
25
+ onClick?: () => void;
26
+ }
27
+
28
+ const colorMap: Record<MetricTileProps["color"], { text: string; bg: string; glow: string; borderL: string; ring: string }> = {
29
+ primary: { text: "text-primary", bg: "bg-primary/10", glow: "shadow-neon-glow-primary-xs", borderL: "", ring: "ring-primary/50" },
30
+ success: { text: "text-success", bg: "bg-success/10", glow: "shadow-neon-glow-success-sm", borderL: "border-l-success/60", ring: "ring-success/50" },
31
+ warning: { text: "text-warning", bg: "bg-warning/10", glow: "shadow-neon-glow-warning-sm", borderL: "border-l-warning/60", ring: "ring-warning/50" },
32
+ error: { text: "text-error", bg: "bg-error/10", glow: "shadow-neon-glow-error-sm", borderL: "border-l-error/60", ring: "ring-error/50" },
33
+ muted: { text: "text-zinc-500", bg: "bg-zinc-500/10", glow: "", borderL: "border-l-zinc-500/60", ring: "ring-zinc-500/50" },
34
+ };
35
+
36
+ function MetricTile({ label, value, icon, color, pulse, testId, active, onClick }: MetricTileProps) {
37
+ const c = colorMap[color];
38
+ const isClickable = !!onClick;
39
+ // Animate the number so jumps like 48->91 feel smooth instead of jarring
40
+ const displayValue = useAnimatedNumber(value);
41
+ return (
42
+ <div
43
+ data-testid={testId}
44
+ role={isClickable ? "button" : undefined}
45
+ aria-pressed={isClickable ? !!active : undefined}
46
+ tabIndex={isClickable ? 0 : undefined}
47
+ onClick={onClick}
48
+ onKeyDown={isClickable ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick?.(); } } : undefined}
49
+ className={cn(
50
+ "rounded-xl border border-border bg-card/90 p-3 flex items-center gap-3 transition-all shadow-sm",
51
+ value > 0 && color !== "primary" && "border-l-2",
52
+ value > 0 && color !== "primary" && c.borderL,
53
+ c.glow,
54
+ isClickable && "cursor-pointer hover:bg-background-secondary/65 hover:shadow-md",
55
+ active && "ring-2",
56
+ active && c.ring,
57
+ )}
58
+ >
59
+ <div className={cn("rounded-md p-2", c.bg)}>
60
+ <span className={cn(c.text, pulse && "animate-pulse-dot")}>{icon}</span>
61
+ </div>
62
+ <div>
63
+ <p className={cn("text-lg font-bold tabular-nums leading-none mb-0.5", c.text)}>
64
+ {displayValue}
65
+ </p>
66
+ <p className="text-[11px] leading-tight text-foreground-muted uppercase tracking-[0.12em] font-medium">
67
+ {label}
68
+ </p>
69
+ </div>
70
+ </div>
71
+ );
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // KpiGrid (public)
76
+ // ---------------------------------------------------------------------------
77
+
78
+ export interface KpiGridProps {
79
+ metrics: DashboardMetrics;
80
+ statusFilter: DashboardStatusFilter;
81
+ hasStaleRuns: boolean;
82
+ onToggleFilter: (filter: DashboardStatusFilter) => void;
83
+ }
84
+
85
+ export function KpiGrid({ metrics, statusFilter, hasStaleRuns, onToggleFilter }: KpiGridProps) {
86
+ const kpiCols = hasStaleRuns ? "grid-cols-2 sm:grid-cols-5" : "grid-cols-2 sm:grid-cols-4";
87
+
88
+ return (
89
+ <div data-testid="kpi-grid" aria-live="polite" aria-label="Key metrics" className={cn("grid gap-3 mb-6", kpiCols)}>
90
+ <MetricTile
91
+ label="Total Runs"
92
+ value={metrics.totalRuns}
93
+ icon={<Layers className="h-4 w-4" />}
94
+ color="primary"
95
+ testId="metric-tile-total-runs"
96
+ onClick={() => onToggleFilter("all")}
97
+ active={statusFilter === "all"}
98
+ />
99
+ <MetricTile
100
+ label="In Progress"
101
+ value={metrics.activeRuns}
102
+ icon={<Activity className="h-4 w-4" />}
103
+ color="warning"
104
+ pulse={metrics.activeRuns > 0}
105
+ testId="metric-tile-active"
106
+ onClick={() => onToggleFilter("waiting")}
107
+ active={statusFilter === "waiting"}
108
+ />
109
+ {hasStaleRuns && (
110
+ <MetricTile
111
+ label="Stale"
112
+ value={metrics.staleRuns}
113
+ icon={<Pause className="h-4 w-4" />}
114
+ color="muted"
115
+ testId="metric-tile-stale"
116
+ onClick={() => onToggleFilter("stale")}
117
+ active={statusFilter === "stale"}
118
+ />
119
+ )}
120
+ <MetricTile
121
+ label="Completed"
122
+ value={metrics.completedRuns}
123
+ icon={<CheckCircle2 className="h-4 w-4" />}
124
+ color="success"
125
+ testId="metric-tile-completed"
126
+ onClick={() => onToggleFilter("completed")}
127
+ active={statusFilter === "completed"}
128
+ />
129
+ <MetricTile
130
+ label="Failed"
131
+ value={metrics.failedRuns}
132
+ icon={<AlertCircle className="h-4 w-4" />}
133
+ color="error"
134
+ testId="metric-tile-failed"
135
+ onClick={() => onToggleFilter("failed")}
136
+ active={statusFilter === "failed"}
137
+ />
138
+ </div>
139
+ );
140
+ }
@@ -0,0 +1,100 @@
1
+ 'use client';
2
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
3
+ import { cn } from '@/lib/cn';
4
+
5
+ interface PaginationControlsProps {
6
+ currentPage: number;
7
+ totalItems: number;
8
+ itemsPerPage: number;
9
+ onPageChange: (page: number) => void;
10
+ className?: string;
11
+ }
12
+
13
+ export function PaginationControls({
14
+ currentPage,
15
+ totalItems,
16
+ itemsPerPage,
17
+ onPageChange,
18
+ className
19
+ }: PaginationControlsProps) {
20
+ const totalPages = Math.ceil(totalItems / itemsPerPage);
21
+ const startItem = currentPage * itemsPerPage + 1;
22
+ const endItem = Math.min((currentPage + 1) * itemsPerPage, totalItems);
23
+
24
+ const canGoPrev = currentPage > 0;
25
+ const canGoNext = currentPage < totalPages - 1;
26
+
27
+ if (totalItems === 0) {
28
+ return null;
29
+ }
30
+
31
+ // Build visible page numbers: show first, last, current, and neighbors
32
+ const pageNumbers: (number | 'ellipsis')[] = [];
33
+ if (totalPages <= 5) {
34
+ for (let i = 0; i < totalPages; i++) pageNumbers.push(i);
35
+ } else {
36
+ pageNumbers.push(0);
37
+ if (currentPage > 2) pageNumbers.push('ellipsis');
38
+ for (let i = Math.max(1, currentPage - 1); i <= Math.min(totalPages - 2, currentPage + 1); i++) {
39
+ pageNumbers.push(i);
40
+ }
41
+ if (currentPage < totalPages - 3) pageNumbers.push('ellipsis');
42
+ pageNumbers.push(totalPages - 1);
43
+ }
44
+
45
+ return (
46
+ <div className={cn('flex items-center justify-between border-t border-border pt-3', className)}>
47
+ <span className="text-xs text-foreground-muted tabular-nums">
48
+ {startItem}–{endItem} of {totalItems}
49
+ </span>
50
+ <div className="flex items-center gap-0.5">
51
+ <button
52
+ onClick={() => onPageChange(currentPage - 1)}
53
+ disabled={!canGoPrev}
54
+ className={cn(
55
+ 'inline-flex h-11 w-11 items-center justify-center rounded-md text-xs transition-all',
56
+ canGoPrev
57
+ ? 'text-foreground hover:bg-primary-muted hover:text-primary hover:shadow-neon-glow-primary-xs cursor-pointer'
58
+ : 'text-foreground-muted cursor-not-allowed opacity-40'
59
+ )}
60
+ aria-label="Previous page"
61
+ >
62
+ <ChevronLeft className="h-4 w-4" />
63
+ </button>
64
+ {pageNumbers.map((p, idx) =>
65
+ p === 'ellipsis' ? (
66
+ <span key={`ellipsis-${idx}`} className="inline-flex h-11 w-5 items-center justify-center text-xs text-foreground-muted">
67
+ ...
68
+ </span>
69
+ ) : (
70
+ <button
71
+ key={p}
72
+ onClick={() => onPageChange(p)}
73
+ className={cn(
74
+ 'inline-flex h-11 min-w-[44px] items-center justify-center rounded-md px-1.5 text-xs font-medium tabular-nums transition-all',
75
+ p === currentPage
76
+ ? 'text-primary bg-primary/10'
77
+ : 'text-foreground-muted hover:bg-background-secondary hover:text-foreground-secondary cursor-pointer'
78
+ )}
79
+ >
80
+ {p + 1}
81
+ </button>
82
+ )
83
+ )}
84
+ <button
85
+ onClick={() => onPageChange(currentPage + 1)}
86
+ disabled={!canGoNext}
87
+ className={cn(
88
+ 'inline-flex h-11 w-11 items-center justify-center rounded-md text-xs transition-all',
89
+ canGoNext
90
+ ? 'text-foreground hover:bg-primary-muted hover:text-primary hover:shadow-neon-glow-primary-xs cursor-pointer'
91
+ : 'text-foreground-muted cursor-not-allowed opacity-40'
92
+ )}
93
+ aria-label="Next page"
94
+ >
95
+ <ChevronRight className="h-4 w-4" />
96
+ </button>
97
+ </div>
98
+ </div>
99
+ );
100
+ }
@@ -0,0 +1,72 @@
1
+ "use client";
2
+ import { useState } from "react";
3
+ import {
4
+ Accordion,
5
+ AccordionItem,
6
+ AccordionTrigger,
7
+ AccordionContent,
8
+ } from "@/components/ui/accordion";
9
+ import { ProjectSectionHeader } from "./project-section-header";
10
+ import { ProjectSection } from "./project-section";
11
+ import type { ProjectSummary } from "@/types";
12
+
13
+ interface ProjectAccordionProps {
14
+ projects: ProjectSummary[];
15
+ statusFilter?: string;
16
+ className?: string;
17
+ }
18
+
19
+ export function ProjectAccordion({ projects, statusFilter, className }: ProjectAccordionProps) {
20
+ // Default-open projects that have active runs
21
+ const defaultOpen = projects
22
+ .filter((p) => p.activeRuns > 0)
23
+ .map((p) => p.projectName);
24
+
25
+ // Track which projects are expanded
26
+ const [expandedProjects, setExpandedProjects] = useState<string[]>(defaultOpen);
27
+
28
+ if (projects.length === 0) {
29
+ return (
30
+ <div className="text-sm text-foreground-muted text-center py-8">
31
+ No projects found
32
+ </div>
33
+ );
34
+ }
35
+
36
+ return (
37
+ <Accordion
38
+ type="multiple"
39
+ defaultValue={defaultOpen}
40
+ value={expandedProjects}
41
+ onValueChange={(value) => setExpandedProjects(Array.isArray(value) ? value : [value])}
42
+ className={className}
43
+ >
44
+ {projects.map((project) => {
45
+ const isExpanded = expandedProjects.includes(project.projectName);
46
+ return (
47
+ <AccordionItem key={project.projectName} value={project.projectName}>
48
+ <AccordionTrigger className="px-2 hover:bg-primary-muted/30 rounded-md transition-colors">
49
+ <ProjectSectionHeader
50
+ projectName={project.projectName}
51
+ activeRuns={project.activeRuns}
52
+ completedRuns={project.completedRuns}
53
+ failedRuns={project.failedRuns}
54
+ totalRuns={project.totalRuns}
55
+ latestUpdate={project.latestUpdate}
56
+ />
57
+ </AccordionTrigger>
58
+ <AccordionContent className="px-2">
59
+ <ProjectSection
60
+ projectName={project.projectName}
61
+ runs={[]}
62
+ defaultExpanded
63
+ statusFilter={statusFilter}
64
+ enabled={isExpanded}
65
+ />
66
+ </AccordionContent>
67
+ </AccordionItem>
68
+ );
69
+ })}
70
+ </Accordion>
71
+ );
72
+ }