@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,45 @@
1
+ "use client";
2
+ import { useEffect, useRef } from "react";
3
+
4
+ export interface Shortcut {
5
+ key: string;
6
+ ctrl?: boolean;
7
+ shift?: boolean;
8
+ action: () => void;
9
+ description: string;
10
+ }
11
+
12
+ export function useKeyboard(shortcuts: Shortcut[]) {
13
+ const shortcutsRef = useRef(shortcuts);
14
+ shortcutsRef.current = shortcuts;
15
+
16
+ useEffect(() => {
17
+ const handler = (e: KeyboardEvent) => {
18
+ // Don't fire in input/textarea/select
19
+ const target = e.target as HTMLElement;
20
+ if (
21
+ target.tagName === "INPUT" ||
22
+ target.tagName === "TEXTAREA" ||
23
+ target.tagName === "SELECT" ||
24
+ target.isContentEditable
25
+ ) {
26
+ return;
27
+ }
28
+
29
+ for (const shortcut of shortcutsRef.current) {
30
+ if (
31
+ e.key === shortcut.key &&
32
+ !!e.ctrlKey === !!shortcut.ctrl &&
33
+ !!e.shiftKey === !!shortcut.shift
34
+ ) {
35
+ e.preventDefault();
36
+ shortcut.action();
37
+ return;
38
+ }
39
+ }
40
+ };
41
+
42
+ window.addEventListener("keydown", handler);
43
+ return () => window.removeEventListener("keydown", handler);
44
+ }, []);
45
+ }
@@ -0,0 +1,82 @@
1
+ "use client";
2
+ import { useState, useCallback, useRef, useEffect } from "react";
3
+
4
+ export interface AppNotification {
5
+ id: string;
6
+ title: string;
7
+ body: string;
8
+ type: "success" | "error" | "warning" | "info";
9
+ timestamp: number;
10
+ href?: string;
11
+ /** When true the notification will not auto-dismiss and must be closed manually or resolved externally. */
12
+ persistent?: boolean;
13
+ }
14
+
15
+ export function useNotifications() {
16
+ const [notifications, setNotifications] = useState<AppNotification[]>([]);
17
+ const [permission, setPermission] =
18
+ useState<NotificationPermission>("default");
19
+ const counterRef = useRef(0);
20
+
21
+ useEffect(() => {
22
+ if (typeof window !== "undefined" && "Notification" in window) {
23
+ setPermission(Notification.permission);
24
+ }
25
+ }, []);
26
+
27
+ const requestPermission = useCallback(async () => {
28
+ if (typeof window === "undefined" || !("Notification" in window)) return;
29
+ const result = await Notification.requestPermission();
30
+ setPermission(result);
31
+ }, []);
32
+
33
+ const notify = useCallback(
34
+ (
35
+ title: string,
36
+ body: string,
37
+ type: AppNotification["type"] = "info",
38
+ options?: { href?: string; persistent?: boolean },
39
+ ) => {
40
+ const id = `notif-${++counterRef.current}-${Date.now()}`;
41
+
42
+ // In-app toast
43
+ const notification: AppNotification = {
44
+ id,
45
+ title,
46
+ body,
47
+ type,
48
+ timestamp: Date.now(),
49
+ href: options?.href,
50
+ persistent: options?.persistent,
51
+ };
52
+ setNotifications((prev) => [...prev, notification]);
53
+
54
+ // Auto-dismiss after 5 seconds (skip for persistent notifications like breakpoints)
55
+ if (!options?.persistent) {
56
+ setTimeout(() => {
57
+ setNotifications((prev) => prev.filter((n) => n.id !== id));
58
+ }, 5000);
59
+ }
60
+
61
+ // Browser notification when tab is hidden
62
+ if (
63
+ permission === "granted" &&
64
+ typeof window !== "undefined" &&
65
+ document.hidden
66
+ ) {
67
+ try {
68
+ new Notification(title, { body, tag: id });
69
+ } catch {
70
+ // Silent fail for environments that don't support Notification constructor
71
+ }
72
+ }
73
+ },
74
+ [permission],
75
+ );
76
+
77
+ const dismiss = useCallback((id: string) => {
78
+ setNotifications((prev) => prev.filter((n) => n.id !== id));
79
+ }, []);
80
+
81
+ return { notifications, notify, dismiss, requestPermission, permission };
82
+ }
@@ -0,0 +1,60 @@
1
+ "use client";
2
+ import { useState, useCallback, useLayoutEffect, useEffect } from "react";
3
+
4
+ const NAMESPACE = "observer:";
5
+
6
+ // useLayoutEffect on client (runs before paint → no flash),
7
+ // useEffect on server (suppresses Next.js SSR warning).
8
+ const useIsomorphicLayoutEffect =
9
+ typeof window !== "undefined" ? useLayoutEffect : useEffect;
10
+
11
+ /**
12
+ * Custom hook that wraps useState with localStorage persistence.
13
+ * Values are serialized with JSON.stringify/parse and namespaced
14
+ * under the "observer:" prefix to avoid collisions.
15
+ *
16
+ * Hydration-safe: first render uses defaultValue (matching SSR),
17
+ * then useLayoutEffect reads localStorage before the browser paints
18
+ * so the persisted value appears with no visible flash.
19
+ */
20
+ export function usePersistedState<T>(
21
+ key: string,
22
+ defaultValue: T
23
+ ): [T, (value: T | ((prev: T) => T)) => void] {
24
+ const prefixedKey = key.startsWith(NAMESPACE) ? key : `${NAMESPACE}${key}`;
25
+
26
+ // Always start with defaultValue to match SSR output (hydration-safe).
27
+ const [state, setState] = useState<T>(defaultValue);
28
+
29
+ // Read localStorage before paint — avoids both hydration mismatch and flash.
30
+ useIsomorphicLayoutEffect(() => {
31
+ try {
32
+ const stored = window.localStorage.getItem(prefixedKey);
33
+ if (stored !== null) {
34
+ const parsed = JSON.parse(stored) as T;
35
+ setState(parsed);
36
+ }
37
+ } catch {
38
+ // localStorage unavailable — keep default
39
+ }
40
+ }, [prefixedKey]);
41
+
42
+ const setPersistedState = useCallback(
43
+ (value: T | ((prev: T) => T)) => {
44
+ setState((prev) => {
45
+ const next = typeof value === "function" ? (value as (prev: T) => T)(prev) : value;
46
+ try {
47
+ if (typeof window !== "undefined") {
48
+ window.localStorage.setItem(prefixedKey, JSON.stringify(next));
49
+ }
50
+ } catch {
51
+ // localStorage may be full or blocked — silently ignore
52
+ }
53
+ return next;
54
+ });
55
+ },
56
+ [prefixedKey]
57
+ );
58
+
59
+ return [state, setPersistedState];
60
+ }
@@ -0,0 +1,60 @@
1
+ "use client";
2
+ import { useState, useEffect, useCallback, useRef } from "react";
3
+ import { resilientFetch } from "@/lib/fetcher";
4
+
5
+ interface UsePollingOptions {
6
+ interval?: number;
7
+ enabled?: boolean;
8
+ }
9
+
10
+ export function usePolling<T>(
11
+ url: string,
12
+ options: UsePollingOptions = {}
13
+ ): { data: T | null; loading: boolean; error: string | null; refresh: () => void } {
14
+ const { interval = 2000, enabled = true } = options;
15
+ const [data, setData] = useState<T | null>(null);
16
+ const [loading, setLoading] = useState(enabled && !!url);
17
+ const [error, setError] = useState<string | null>(null);
18
+ const mountedRef = useRef(true);
19
+ const abortRef = useRef<AbortController | null>(null);
20
+
21
+ const fetchData = useCallback(async () => {
22
+ if (!url) return;
23
+ abortRef.current?.abort();
24
+ abortRef.current = new AbortController();
25
+ const result = await resilientFetch<T>(url, { signal: abortRef.current.signal });
26
+ if (!result.ok) {
27
+ if (result.error.isAborted) return;
28
+ if (mountedRef.current) {
29
+ setError(result.error.message);
30
+ setLoading(false);
31
+ }
32
+ return;
33
+ }
34
+ if (mountedRef.current) {
35
+ setData(result.data);
36
+ setError(null);
37
+ setLoading(false);
38
+ }
39
+ }, [url]);
40
+
41
+ useEffect(() => {
42
+ mountedRef.current = true;
43
+ if (!enabled || !url) {
44
+ setLoading(false);
45
+ return;
46
+ }
47
+
48
+ setLoading(true);
49
+ fetchData();
50
+ const id = setInterval(fetchData, interval);
51
+
52
+ return () => {
53
+ mountedRef.current = false;
54
+ abortRef.current?.abort();
55
+ clearInterval(id);
56
+ };
57
+ }, [fetchData, interval, enabled, url]);
58
+
59
+ return { data, loading, error, refresh: fetchData };
60
+ }
@@ -0,0 +1,51 @@
1
+ 'use client';
2
+ import { useSmartPolling } from './use-smart-polling';
3
+ import { Run } from '@/types';
4
+
5
+ interface ProjectRunsResponse {
6
+ runs: Run[];
7
+ totalCount: number;
8
+ project: string;
9
+ }
10
+
11
+ interface UseProjectRunsOptions {
12
+ limit?: number;
13
+ offset?: number;
14
+ search?: string;
15
+ status?: string;
16
+ sort?: 'status' | 'activity';
17
+ enabled?: boolean;
18
+ }
19
+
20
+ export function useProjectRuns(
21
+ projectName: string,
22
+ options: UseProjectRunsOptions = {}
23
+ ) {
24
+ const { limit = 10, offset = 0, search = '', status = '', sort = 'status', enabled = true } = options;
25
+ const params = new URLSearchParams();
26
+ params.set('project', projectName);
27
+ params.set('limit', String(limit));
28
+ params.set('offset', String(offset));
29
+ if (search) params.set('search', search);
30
+ if (status) params.set('status', status);
31
+ if (sort && sort !== 'status') params.set('sort', sort);
32
+
33
+ const url = `/api/runs?${params.toString()}`;
34
+
35
+ const { data, loading, error, refresh } = useSmartPolling<ProjectRunsResponse>(
36
+ url,
37
+ {
38
+ interval: 5000,
39
+ sseFilter: () => true, // Any run update could affect this project
40
+ enabled
41
+ }
42
+ );
43
+
44
+ return {
45
+ runs: enabled && data ? data.runs : [],
46
+ totalCount: enabled && data ? data.totalCount : 0,
47
+ loading,
48
+ error,
49
+ refresh
50
+ };
51
+ }
@@ -0,0 +1,26 @@
1
+ 'use client';
2
+ import { useSmartPolling } from './use-smart-polling';
3
+ import { ProjectSummary } from '@/types';
4
+
5
+ interface ProjectsResponse {
6
+ projects: ProjectSummary[];
7
+ recentCompletionWindowMs?: number;
8
+ }
9
+
10
+ export function useProjects(interval: number = 5000, suppressSseRefetch: boolean = false) {
11
+ const { data, loading, error, refresh } = useSmartPolling<ProjectsResponse>(
12
+ '/api/runs?mode=projects',
13
+ {
14
+ interval,
15
+ sseFilter: (event) => event.type === 'update' || event.type === 'new-run',
16
+ suppressSseRefetch,
17
+ }
18
+ );
19
+ return {
20
+ projects: data?.projects || [],
21
+ recentCompletionWindowMs: data?.recentCompletionWindowMs ?? 14400000,
22
+ loading,
23
+ error,
24
+ refresh
25
+ };
26
+ }
@@ -0,0 +1,207 @@
1
+ "use client";
2
+ import { useState, useMemo, useCallback, useRef } from "react";
3
+ import { useProjects } from "./use-projects";
4
+ import { useBatchedUpdates, type CatchUpState } from "./use-batched-updates";
5
+ import { usePersistedState } from "./use-persisted-state";
6
+ import type { RunStatus, ProjectSummary, BreakpointRunInfo } from "@/types";
7
+ import type { ExecutiveSummaryMetrics } from "@/components/dashboard/executive-summary-banner";
8
+
9
+ /** Aggregated KPI metrics across all projects. */
10
+ export interface DashboardMetrics {
11
+ totalRuns: number;
12
+ activeRuns: number;
13
+ completedRuns: number;
14
+ failedRuns: number;
15
+ staleRuns: number;
16
+ totalTasks: number;
17
+ completedTasks: number;
18
+ }
19
+
20
+ export type DashboardSortMode = "status" | "activity";
21
+ export type DashboardStatusFilter = RunStatus | "all" | "stale";
22
+
23
+ export interface UseRunDashboardReturn {
24
+ // Data
25
+ projects: ProjectSummary[];
26
+ loading: boolean;
27
+ error: string | null | undefined;
28
+ metrics: DashboardMetrics;
29
+ allBreakpointRuns: BreakpointRunInfo[];
30
+ summaryMetrics: ExecutiveSummaryMetrics;
31
+ bannerFingerprint: string;
32
+ bannerDismissed: boolean;
33
+ filterCounts: Record<DashboardStatusFilter, number>;
34
+ filteredProjects: ProjectSummary[];
35
+ activeProjects: ProjectSummary[];
36
+ historyProjects: ProjectSummary[];
37
+
38
+ // State
39
+ statusFilter: DashboardStatusFilter;
40
+ sortMode: DashboardSortMode;
41
+ historyCollapsed: boolean;
42
+ cardStatusFilter: RunStatus | "all";
43
+ hasStaleRuns: boolean;
44
+
45
+ // Catch-up mode
46
+ catchUp: CatchUpState;
47
+
48
+ // Actions
49
+ setStatusFilter: (value: DashboardStatusFilter) => void;
50
+ setSortMode: (value: DashboardSortMode | ((prev: DashboardSortMode) => DashboardSortMode)) => void;
51
+ setHistoryCollapsed: (value: boolean | ((prev: boolean) => boolean)) => void;
52
+ setDismissedFingerprint: (value: string | null | ((prev: string | null) => string | null)) => void;
53
+ toggleMetricFilter: (filter: DashboardStatusFilter) => void;
54
+ handleHideProject: (projectName: string) => void;
55
+ }
56
+
57
+ export function useRunDashboard(): UseRunDashboardReturn {
58
+ // Use a ref to bridge the circular dependency between useBatchedUpdates.onFlush
59
+ // and the refresh function returned by useProjects.
60
+ const refreshRef = useRef<() => void>(() => {});
61
+
62
+ // Monitor SSE event rate and activate catch-up mode during bursts.
63
+ // When catch-up is active, useProjects suppresses SSE-triggered refetches
64
+ // so the UI stays calm until the user clicks "refresh now" or the burst subsides.
65
+ const sseFilter = useCallback(
66
+ (event: { type: string }) => event.type === "update" || event.type === "new-run",
67
+ []
68
+ );
69
+ const catchUp = useBatchedUpdates({
70
+ sseFilter,
71
+ onFlush: () => refreshRef.current(),
72
+ });
73
+
74
+ const { projects, recentCompletionWindowMs, loading, error, refresh } = useProjects(
75
+ 5000,
76
+ catchUp.active
77
+ );
78
+ refreshRef.current = refresh;
79
+
80
+ const [statusFilter, setStatusFilter] = useState<DashboardStatusFilter>("all");
81
+ const [sortMode, setSortMode] = usePersistedState<DashboardSortMode>("observer:sort-mode", "status");
82
+ const [dismissedFingerprint, setDismissedFingerprint] = usePersistedState<string | null>("banner-dismissed-fingerprint", null);
83
+
84
+ // Toggle filter from metric tile: clicking active filter clears it
85
+ const toggleMetricFilter = useCallback((filter: DashboardStatusFilter) => {
86
+ setStatusFilter((prev) => (prev === filter ? "all" : filter));
87
+ }, []);
88
+
89
+ const handleHideProject = useCallback((_projectName: string) => {
90
+ refresh();
91
+ }, [refresh]);
92
+
93
+ // Aggregate metrics across all projects
94
+ const metrics = useMemo<DashboardMetrics>(() => {
95
+ const totalRuns = projects.reduce((s, p) => s + p.totalRuns, 0);
96
+ const activeRuns = projects.reduce((s, p) => s + p.activeRuns, 0);
97
+ const completedRuns = projects.reduce((s, p) => s + p.completedRuns, 0);
98
+ const failedRuns = projects.reduce((s, p) => s + p.failedRuns, 0);
99
+ const staleRuns = projects.reduce((s, p) => s + p.staleRuns, 0);
100
+ const totalTasks = projects.reduce((s, p) => s + p.totalTasks, 0);
101
+ const completedTasks = projects.reduce((s, p) => s + p.completedTasksAggregate, 0);
102
+ return { totalRuns, activeRuns, completedRuns, failedRuns, staleRuns, totalTasks, completedTasks };
103
+ }, [projects]);
104
+
105
+ // Collect all breakpoint runs across all projects
106
+ const allBreakpointRuns = useMemo<BreakpointRunInfo[]>(() => {
107
+ return projects.flatMap((p) => p.breakpointRuns ?? []);
108
+ }, [projects]);
109
+
110
+ // Executive summary metrics for the banner
111
+ const summaryMetrics = useMemo<ExecutiveSummaryMetrics>(() => ({
112
+ totalProjects: projects.length,
113
+ activeRuns: metrics.activeRuns,
114
+ failedRuns: metrics.failedRuns,
115
+ completedRuns: metrics.completedRuns,
116
+ staleRuns: metrics.staleRuns,
117
+ pendingBreakpoints: projects.reduce((s, p) => s + p.pendingBreakpoints, 0),
118
+ }), [projects, metrics]);
119
+
120
+ // Fingerprint for banner dismiss
121
+ const bannerFingerprint = `${summaryMetrics.failedRuns}-${summaryMetrics.staleRuns}-${summaryMetrics.pendingBreakpoints}`;
122
+ const bannerDismissed = dismissedFingerprint === bannerFingerprint;
123
+
124
+ const filterCounts = useMemo(() => {
125
+ return {
126
+ all: metrics.totalRuns,
127
+ waiting: metrics.activeRuns,
128
+ stale: metrics.staleRuns,
129
+ completed: metrics.completedRuns,
130
+ failed: metrics.failedRuns,
131
+ pending: 0,
132
+ } as Record<DashboardStatusFilter, number>;
133
+ }, [metrics]);
134
+
135
+ // Filter projects by status counts
136
+ const filteredProjects = useMemo(() => {
137
+ if (statusFilter === "all") return projects;
138
+ if (statusFilter === "stale") return projects.filter((p) => p.staleRuns > 0);
139
+ return projects.filter((project) => {
140
+ if (statusFilter === "waiting") return project.activeRuns > 0;
141
+ if (statusFilter === "completed") return project.completedRuns > 0;
142
+ if (statusFilter === "failed") return project.failedRuns > 0;
143
+ return false;
144
+ });
145
+ }, [projects, statusFilter]);
146
+
147
+ // Determine the status filter to pass to ProjectHealthCard
148
+ const cardStatusFilter: RunStatus | "all" = statusFilter === "stale" ? "all" : statusFilter;
149
+
150
+ // Split filtered projects into sections based on sort mode
151
+ const { activeProjects, historyProjects } = useMemo(() => {
152
+ const now = Date.now();
153
+ if (sortMode === "activity") {
154
+ const twentyFourHours = 24 * 60 * 60 * 1000;
155
+ const recent = filteredProjects.filter((p) =>
156
+ now - new Date(p.latestUpdate).getTime() < twentyFourHours
157
+ );
158
+ const earlier = filteredProjects.filter((p) =>
159
+ now - new Date(p.latestUpdate).getTime() >= twentyFourHours
160
+ );
161
+ return { activeProjects: recent, historyProjects: earlier };
162
+ }
163
+ const active = filteredProjects.filter((p) =>
164
+ p.activeRuns > 0 || p.staleRuns > 0 ||
165
+ (now - new Date(p.latestUpdate).getTime() < recentCompletionWindowMs)
166
+ );
167
+ const history = filteredProjects.filter((p) =>
168
+ p.activeRuns === 0 && p.staleRuns === 0 &&
169
+ (now - new Date(p.latestUpdate).getTime() >= recentCompletionWindowMs)
170
+ );
171
+ return { activeProjects: active, historyProjects: history };
172
+ }, [filteredProjects, recentCompletionWindowMs, sortMode]);
173
+
174
+ const [historyCollapsed, setHistoryCollapsed] = usePersistedState(
175
+ "observer:history-collapsed",
176
+ historyProjects.length > 5
177
+ );
178
+
179
+ const hasStaleRuns = metrics.staleRuns > 0;
180
+
181
+ return {
182
+ projects,
183
+ loading,
184
+ error,
185
+ metrics,
186
+ allBreakpointRuns,
187
+ summaryMetrics,
188
+ bannerFingerprint,
189
+ bannerDismissed,
190
+ filterCounts,
191
+ filteredProjects,
192
+ activeProjects,
193
+ historyProjects,
194
+ statusFilter,
195
+ sortMode,
196
+ historyCollapsed,
197
+ cardStatusFilter,
198
+ hasStaleRuns,
199
+ catchUp,
200
+ setStatusFilter,
201
+ setSortMode,
202
+ setHistoryCollapsed,
203
+ setDismissedFingerprint,
204
+ toggleMetricFilter,
205
+ handleHideProject,
206
+ };
207
+ }
@@ -0,0 +1,77 @@
1
+ "use client";
2
+ import { useMemo, useRef } from "react";
3
+ import { useSmartPolling } from "./use-smart-polling";
4
+ import type { RunDetailResponse, TaskDetailResponse, RunStatus } from "@/types";
5
+
6
+ // Poll intervals by run status:
7
+ // - Active/waiting runs change frequently, poll every 3s
8
+ // - Completed/failed runs are static, poll every 30s (just in case of late events)
9
+ const POLL_ACTIVE = 3000;
10
+ const POLL_COMPLETED = 30000;
11
+
12
+ function getIntervalForStatus(status: RunStatus | undefined): number {
13
+ if (!status) return POLL_ACTIVE;
14
+ return status === "completed" || status === "failed"
15
+ ? POLL_COMPLETED
16
+ : POLL_ACTIVE;
17
+ }
18
+
19
+ export function useRunDetail(runId: string, intervalOverride?: number) {
20
+ // Track last known run status to adapt poll interval.
21
+ // useRef avoids triggering re-render; the interval change takes effect
22
+ // on the next useSmartPolling dependency cycle.
23
+ const lastStatusRef = useRef<RunStatus | undefined>(undefined);
24
+
25
+ const adaptiveInterval = intervalOverride ?? getIntervalForStatus(lastStatusRef.current);
26
+
27
+ const { data, loading, error, refresh } = useSmartPolling<RunDetailResponse>(
28
+ `/api/runs/${runId}?maxEvents=50`,
29
+ {
30
+ interval: adaptiveInterval,
31
+ sseFilter: (event) =>
32
+ event.runId === runId || (event.runIds?.includes(runId) ?? false)
33
+ }
34
+ );
35
+
36
+ const run = data?.run || null;
37
+
38
+ // Update status ref so next render picks up the adaptive interval.
39
+ // When status transitions (e.g. "waiting" -> "completed"), the interval
40
+ // changes from 3s to 30s, and useSmartPolling restarts its timer.
41
+ if (run && run.status !== lastStatusRef.current) {
42
+ lastStatusRef.current = run.status;
43
+ }
44
+
45
+ // Detect if any breakpoint tasks are waiting for approval
46
+ const hasBreakpointWaiting = useMemo(() => {
47
+ if (!run) return false;
48
+ return run.tasks.some(
49
+ (t) => t.kind === "breakpoint" && t.status === "requested"
50
+ );
51
+ }, [run]);
52
+
53
+ return {
54
+ run,
55
+ loading,
56
+ error,
57
+ refresh,
58
+ hasBreakpointWaiting,
59
+ };
60
+ }
61
+
62
+ export function useTaskDetail(runId: string, effectId: string | null) {
63
+ const { data, loading, error } = useSmartPolling<TaskDetailResponse>(
64
+ effectId ? `/api/runs/${runId}/tasks/${effectId}` : "",
65
+ {
66
+ enabled: !!effectId,
67
+ interval: 5000,
68
+ sseFilter: (event) =>
69
+ event.runId === runId || (event.runIds?.includes(runId) ?? false),
70
+ }
71
+ );
72
+ return {
73
+ task: data?.task || null,
74
+ loading,
75
+ error,
76
+ };
77
+ }