@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,536 @@
1
+ "use client";
2
+ import { useState, useCallback } from "react";
3
+ import { cn } from "@/lib/cn";
4
+ import { Card } from "@/components/ui/card";
5
+ import { RunCard } from "./run-card";
6
+ import { VirtualizedRunList } from "./virtualized-run-list";
7
+ import { PaginationControls } from "./pagination-controls";
8
+ import { useProjectRuns } from "@/hooks/use-project-runs";
9
+ import { usePersistedState } from "@/hooks/use-persisted-state";
10
+ import { resilientFetch } from "@/lib/fetcher";
11
+ import { formatRelativeTime } from "@/lib/utils";
12
+ import type { ProjectSummary, RunStatus } from "@/types";
13
+ import {
14
+ Activity,
15
+ CheckCircle2,
16
+ AlertCircle,
17
+ Layers,
18
+ ChevronDown,
19
+ ChevronUp,
20
+ Clock,
21
+ Pause,
22
+ History,
23
+ Hand,
24
+ EyeOff,
25
+ Loader2,
26
+ } from "lucide-react";
27
+
28
+ interface ProjectHealthCardProps {
29
+ project: ProjectSummary;
30
+ statusFilter: RunStatus | "all";
31
+ sortMode?: "status" | "activity";
32
+ onHide?: (projectName: string) => void;
33
+ }
34
+
35
+ type HealthStatus = "healthy" | "active" | "stale" | "failing";
36
+
37
+ function getHealthStatus(project: ProjectSummary): HealthStatus {
38
+ if (project.failedRuns > 0) return "failing";
39
+ if (project.activeRuns > 0) return "active";
40
+ if (project.staleRuns > 0) return "stale";
41
+ return "healthy";
42
+ }
43
+
44
+ const healthConfig: Record<
45
+ HealthStatus,
46
+ { dotClass: string; borderClass: string; icon: typeof CheckCircle2; label: string; barColor: string }
47
+ > = {
48
+ healthy: {
49
+ dotClass: "bg-success shadow-[0_0_6px_var(--success)]",
50
+ borderClass: "border-success/20 hover:border-success/40",
51
+ icon: CheckCircle2,
52
+ label: "Healthy",
53
+ barColor: "bg-success",
54
+ },
55
+ active: {
56
+ dotClass: "bg-warning shadow-[0_0_6px_var(--warning)] animate-pulse-dot",
57
+ borderClass: "border-warning/20 hover:border-warning/40",
58
+ icon: Activity,
59
+ label: "Active",
60
+ barColor: "bg-warning",
61
+ },
62
+ stale: {
63
+ dotClass: "bg-zinc-500",
64
+ borderClass: "border-zinc-500/20 hover:border-zinc-500/40",
65
+ icon: Pause,
66
+ label: "Stale",
67
+ barColor: "bg-zinc-500",
68
+ },
69
+ failing: {
70
+ dotClass: "bg-error shadow-[0_0_6px_var(--error)]",
71
+ borderClass: "border-error/20 hover:border-error/40",
72
+ icon: AlertCircle,
73
+ label: "Failing",
74
+ barColor: "bg-error",
75
+ },
76
+ };
77
+
78
+ const PAGE_SIZE = 5;
79
+
80
+ export function ProjectHealthCard({ project, statusFilter, sortMode = "status", onHide }: ProjectHealthCardProps) {
81
+ const [hiding, setHiding] = useState(false);
82
+
83
+ const handleHide = useCallback(async (e: React.MouseEvent) => {
84
+ e.stopPropagation();
85
+ setHiding(true);
86
+
87
+ // First fetch current config to get existing hiddenProjects
88
+ const configResult = await resilientFetch<{ hiddenProjects?: string[]; sources: { path: string; depth: number; label?: string }[]; pollInterval: number; theme: string; retentionDays: number }>("/api/config");
89
+ if (!configResult.ok) {
90
+ setHiding(false);
91
+ return;
92
+ }
93
+
94
+ const currentHidden = configResult.data.hiddenProjects ?? [];
95
+ if (currentHidden.includes(project.projectName)) {
96
+ // Already hidden
97
+ setHiding(false);
98
+ onHide?.(project.projectName);
99
+ return;
100
+ }
101
+
102
+ const newHidden = [...currentHidden, project.projectName];
103
+ const saveResult = await resilientFetch("/api/config", {
104
+ method: "POST",
105
+ headers: { "Content-Type": "application/json" },
106
+ body: JSON.stringify({
107
+ sources: configResult.data.sources,
108
+ pollInterval: configResult.data.pollInterval,
109
+ theme: configResult.data.theme,
110
+ retentionDays: configResult.data.retentionDays,
111
+ hiddenProjects: newHidden,
112
+ }),
113
+ });
114
+
115
+ setHiding(false);
116
+ if (saveResult.ok) {
117
+ onHide?.(project.projectName);
118
+ }
119
+ }, [project.projectName, onHide]);
120
+
121
+ const [expanded, setExpanded] = usePersistedState(
122
+ `observer:project-expanded:${project.projectName}`,
123
+ false
124
+ );
125
+ const [page, setPage] = useState(0);
126
+ const [showCompleted, setShowCompleted] = useState(false);
127
+ const [showFailed, setShowFailed] = useState(false);
128
+ const [localFilter, setLocalFilter] = useState<RunStatus | "all">("all");
129
+
130
+ const health = getHealthStatus(project);
131
+ const config = healthConfig[health];
132
+ const StatusIcon = config.icon;
133
+
134
+ const taskProgress = project.totalTasks > 0
135
+ ? Math.round((project.completedTasksAggregate / project.totalTasks) * 100)
136
+ : 0;
137
+
138
+ // Effective filter: local filter takes precedence when set, otherwise use parent filter
139
+ const effectiveFilter = localFilter !== "all" ? localFilter : statusFilter;
140
+
141
+ const { runs, totalCount, loading } = useProjectRuns(
142
+ project.projectName,
143
+ {
144
+ limit: PAGE_SIZE,
145
+ offset: page * PAGE_SIZE,
146
+ status: effectiveFilter === "all" ? "" : effectiveFilter,
147
+ sort: sortMode,
148
+ enabled: expanded,
149
+ }
150
+ );
151
+
152
+ // Toggle local filter from mini KPI pills: clicking active filter clears it
153
+ const toggleLocalFilter = (filter: RunStatus | "all") => {
154
+ setLocalFilter((prev) => (prev === filter ? "all" : filter));
155
+ setPage(0); // Reset pagination when filter changes
156
+ };
157
+
158
+ return (
159
+ <Card
160
+ data-testid={`project-card-${project.projectName}`}
161
+ className={cn(
162
+ "transition-all duration-200 overflow-hidden card-hover-lift",
163
+ config.borderClass,
164
+ expanded && "ring-1 ring-primary/20"
165
+ )}
166
+ >
167
+ {/* Card header */}
168
+ <button
169
+ onClick={() => setExpanded((v) => !v)}
170
+ className="w-full text-left p-4 hover:bg-background-secondary/30 transition-colors"
171
+ >
172
+ {/* Row 1: Project title — full width, visually dominant */}
173
+ <div className="flex items-center justify-between mb-2">
174
+ <div className="flex items-center gap-2 truncate flex-1">
175
+ <h3 className="text-lg font-semibold text-foreground truncate">
176
+ {project.projectName}
177
+ </h3>
178
+ {project.pendingBreakpoints > 0 && (
179
+ <span className={cn(
180
+ "inline-flex items-center gap-1 rounded-full px-2 py-0.5 shrink-0",
181
+ "bg-warning/15 border border-warning/30",
182
+ "text-xs leading-tight font-bold text-warning",
183
+ "animate-pulse-dot"
184
+ )}>
185
+ <Hand className="h-2.5 w-2.5" />
186
+ {project.pendingBreakpoints} Pending
187
+ </span>
188
+ )}
189
+ </div>
190
+ <div className="flex items-center shrink-0 ml-2 gap-1">
191
+ <span
192
+ role="button"
193
+ tabIndex={0}
194
+ onClick={handleHide}
195
+ onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleHide(e as unknown as React.MouseEvent); } }}
196
+ className={cn(
197
+ "rounded-md p-2 min-h-[44px] min-w-[44px] inline-flex items-center justify-center transition-colors",
198
+ hiding
199
+ ? "text-foreground-muted cursor-wait"
200
+ : "text-foreground-muted/40 hover:text-foreground-muted hover:bg-background-secondary"
201
+ )}
202
+ title="Hide project from dashboard"
203
+ >
204
+ {hiding ? (
205
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
206
+ ) : (
207
+ <EyeOff className="h-3.5 w-3.5" />
208
+ )}
209
+ </span>
210
+ {expanded ? (
211
+ <ChevronUp className="h-4 w-4 text-foreground-muted" />
212
+ ) : (
213
+ <ChevronDown className="h-4 w-4 text-foreground-muted" />
214
+ )}
215
+ </div>
216
+ </div>
217
+
218
+ {/* Row 2: Health status + run count badges */}
219
+ <div className="flex items-center justify-between mb-3">
220
+ <div className="flex items-center gap-3 text-xs text-foreground-muted">
221
+ <span className="inline-flex items-center gap-1.5">
222
+ <span className={cn("h-2.5 w-2.5 rounded-full shrink-0", config.dotClass)} />
223
+ <StatusIcon className="h-3 w-3" />
224
+ {config.label}
225
+ </span>
226
+ <span className="inline-flex items-center gap-1">
227
+ <Layers className="h-3 w-3" />
228
+ {project.totalRuns}
229
+ </span>
230
+ <span className="inline-flex items-center gap-1">
231
+ <Clock className="h-3 w-3" />
232
+ {formatRelativeTime(project.latestUpdate)}
233
+ </span>
234
+ </div>
235
+ {/* Compact status badges with icons */}
236
+ <div className="flex items-center gap-1.5 shrink-0">
237
+ {project.activeRuns > 0 && (
238
+ <span className="inline-flex items-center gap-0.5 rounded-full bg-warning/10 border border-warning/20 px-1.5 py-0.5 text-xs leading-tight font-medium text-warning tabular-nums" title={`${project.activeRuns} active`}>
239
+ <Activity className="h-3 w-3" />
240
+ {project.activeRuns}
241
+ </span>
242
+ )}
243
+ {project.staleRuns > 0 && (
244
+ <span className="inline-flex items-center gap-0.5 rounded-full bg-zinc-500/10 border border-zinc-500/20 px-1.5 py-0.5 text-xs leading-tight font-medium text-zinc-500 tabular-nums" title={`${project.staleRuns} stale`}>
245
+ <Pause className="h-3 w-3" />
246
+ {project.staleRuns}
247
+ </span>
248
+ )}
249
+ {project.completedRuns > 0 && (
250
+ <span className="inline-flex items-center gap-0.5 rounded-full bg-success/10 border border-success/20 px-1.5 py-0.5 text-xs leading-tight font-medium text-success tabular-nums" title={`${project.completedRuns} completed`}>
251
+ <CheckCircle2 className="h-3 w-3" />
252
+ {project.completedRuns}
253
+ </span>
254
+ )}
255
+ {project.failedRuns > 0 && (
256
+ <span className="inline-flex items-center gap-0.5 rounded-full bg-error/10 border border-error/20 px-1.5 py-0.5 text-xs leading-tight font-medium text-error tabular-nums" title={`${project.failedRuns} failed`}>
257
+ <AlertCircle className="h-3 w-3" />
258
+ {project.failedRuns}
259
+ </span>
260
+ )}
261
+ </div>
262
+ </div>
263
+
264
+ {/* Row 3: Mini progress bar — task completion */}
265
+ {project.totalTasks > 0 && (
266
+ <div>
267
+ <div className="flex items-center justify-between mb-1">
268
+ <span className="text-xs leading-tight text-foreground-muted">
269
+ {project.completedTasksAggregate}/{project.totalTasks} tasks
270
+ </span>
271
+ <span className="text-xs leading-tight text-foreground-muted tabular-nums">
272
+ {taskProgress}%
273
+ </span>
274
+ </div>
275
+ <div className="h-1.5 w-full rounded-full bg-background-secondary overflow-hidden">
276
+ <div
277
+ className={cn("h-full rounded-full transition-all duration-500", config.barColor)}
278
+ style={{ width: `${taskProgress}%` }}
279
+ />
280
+ </div>
281
+ </div>
282
+ )}
283
+ </button>
284
+
285
+ {/* Expanded: runs list — split into active and completed */}
286
+ {expanded && (() => {
287
+ const activeRuns = runs.filter((r) => r.status === "waiting" || r.status === "pending" || r.isStale);
288
+ const successRuns = runs.filter((r) => r.status === "completed" && !r.isStale);
289
+ const failedRuns = runs.filter((r) => r.status === "failed" && !r.isStale);
290
+ const hasActiveRuns = activeRuns.length > 0;
291
+ const hasSuccessRuns = successRuns.length > 0;
292
+ const hasFailedRuns = failedRuns.length > 0;
293
+
294
+ return (
295
+ <div className="border-t border-border px-4 pb-4 pt-3">
296
+ {loading && runs.length === 0 ? (
297
+ <div className="flex flex-col gap-2">
298
+ {[1, 2].map((i) => (
299
+ <div
300
+ key={i}
301
+ className="rounded-lg border border-border bg-background p-3 animate-pulse"
302
+ >
303
+ <div className="flex items-center gap-2 mb-2">
304
+ <div className="h-2 w-2 rounded-full bg-foreground-muted/20" />
305
+ <div className="h-3 w-32 rounded bg-foreground-muted/10" />
306
+ </div>
307
+ <div className="h-2 w-full rounded bg-foreground-muted/10" />
308
+ </div>
309
+ ))}
310
+ </div>
311
+ ) : runs.length === 0 ? (
312
+ <p className="text-xs text-foreground-muted text-center py-4">No matching runs</p>
313
+ ) : sortMode === "activity" ? (
314
+ /* ── Activity mode: flat chronological list ── */
315
+ <div className="flex flex-col gap-3">
316
+ {/* Mini KPI Row — clickable to filter runs within this project */}
317
+ <div className={cn("grid gap-2 mb-3", project.staleRuns > 0 ? "grid-cols-4" : "grid-cols-3")}>
318
+ <MiniKpiPill
319
+ icon={<Activity className="h-3.5 w-3.5" />}
320
+ count={project.activeRuns}
321
+ label="Active"
322
+ colorClass="text-warning"
323
+ bgClass="bg-warning/10"
324
+ pulse={project.activeRuns > 0}
325
+ active={localFilter === "waiting"}
326
+ onClick={(e) => { e.stopPropagation(); toggleLocalFilter("waiting"); }}
327
+ />
328
+ {project.staleRuns > 0 && (
329
+ <MiniKpiPill
330
+ icon={<Pause className="h-3.5 w-3.5" />}
331
+ count={project.staleRuns}
332
+ label="Stale"
333
+ colorClass="text-zinc-500"
334
+ bgClass="bg-zinc-500/10"
335
+ active={localFilter === "waiting"}
336
+ onClick={(e) => { e.stopPropagation(); toggleLocalFilter("waiting"); }}
337
+ />
338
+ )}
339
+ <MiniKpiPill
340
+ icon={<CheckCircle2 className="h-3.5 w-3.5" />}
341
+ count={project.completedRuns}
342
+ label="Completed"
343
+ colorClass="text-success"
344
+ bgClass="bg-success/10"
345
+ active={localFilter === "completed"}
346
+ onClick={(e) => { e.stopPropagation(); toggleLocalFilter("completed"); }}
347
+ />
348
+ <MiniKpiPill
349
+ icon={<AlertCircle className="h-3.5 w-3.5" />}
350
+ count={project.failedRuns}
351
+ label="Failed"
352
+ colorClass="text-error"
353
+ bgClass="bg-error/10"
354
+ active={localFilter === "failed"}
355
+ onClick={(e) => { e.stopPropagation(); toggleLocalFilter("failed"); }}
356
+ />
357
+ </div>
358
+
359
+ {/* Flat chronological run list — all runs in one timeline */}
360
+ <div className="flex items-center gap-2 mb-1">
361
+ <Clock className="h-3.5 w-3.5 text-primary" />
362
+ <span className="text-xs font-semibold text-foreground">Timeline</span>
363
+ <span className="rounded-full bg-primary/10 border border-primary/20 px-2 py-px text-xs font-semibold text-primary tabular-nums">
364
+ {runs.length}
365
+ </span>
366
+ </div>
367
+ <VirtualizedRunList
368
+ runs={runs}
369
+ maxHeight={500}
370
+ renderItem={(run) => (
371
+ <div className="relative">
372
+ <RunCard run={run} />
373
+ {/* Relative time overlay label */}
374
+ <span className="absolute top-2 right-2 inline-flex items-center gap-1 rounded-full bg-background/80 backdrop-blur-sm border border-border px-2 py-0.5 text-xs text-foreground-muted tabular-nums pointer-events-none z-10">
375
+ <Clock className="h-2.5 w-2.5" />
376
+ {formatRelativeTime(run.updatedAt)}
377
+ </span>
378
+ </div>
379
+ )}
380
+ />
381
+ </div>
382
+ ) : (
383
+ /* ── Status mode: grouped sections (original behavior) ── */
384
+ <div className="flex flex-col gap-3">
385
+ {/* Mini KPI Row — clickable to filter runs within this project */}
386
+ <div className={cn("grid gap-2 mb-3", project.staleRuns > 0 ? "grid-cols-4" : "grid-cols-3")}>
387
+ <MiniKpiPill
388
+ icon={<Activity className="h-3.5 w-3.5" />}
389
+ count={project.activeRuns}
390
+ label="Active"
391
+ colorClass="text-warning"
392
+ bgClass="bg-warning/10"
393
+ pulse={project.activeRuns > 0}
394
+ active={localFilter === "waiting"}
395
+ onClick={(e) => { e.stopPropagation(); toggleLocalFilter("waiting"); }}
396
+ />
397
+ {project.staleRuns > 0 && (
398
+ <MiniKpiPill
399
+ icon={<Pause className="h-3.5 w-3.5" />}
400
+ count={project.staleRuns}
401
+ label="Stale"
402
+ colorClass="text-zinc-500"
403
+ bgClass="bg-zinc-500/10"
404
+ active={localFilter === "waiting"}
405
+ onClick={(e) => { e.stopPropagation(); toggleLocalFilter("waiting"); }}
406
+ />
407
+ )}
408
+ <MiniKpiPill
409
+ icon={<CheckCircle2 className="h-3.5 w-3.5" />}
410
+ count={project.completedRuns}
411
+ label="Completed"
412
+ colorClass="text-success"
413
+ bgClass="bg-success/10"
414
+ active={localFilter === "completed"}
415
+ onClick={(e) => { e.stopPropagation(); toggleLocalFilter("completed"); }}
416
+ />
417
+ <MiniKpiPill
418
+ icon={<AlertCircle className="h-3.5 w-3.5" />}
419
+ count={project.failedRuns}
420
+ label="Failed"
421
+ colorClass="text-error"
422
+ bgClass="bg-error/10"
423
+ active={localFilter === "failed"}
424
+ onClick={(e) => { e.stopPropagation(); toggleLocalFilter("failed"); }}
425
+ />
426
+ </div>
427
+
428
+ {/* Active runs — always visible with section header */}
429
+ {hasActiveRuns && (
430
+ <div className="mb-2">
431
+ <div className="flex items-center gap-2 mb-2">
432
+ <Activity className="h-3.5 w-3.5 text-warning animate-pulse-dot" />
433
+ <span className="text-xs font-semibold text-foreground">In Progress</span>
434
+ <span className="rounded-full bg-warning/10 border border-warning/20 px-2 py-px text-xs font-semibold text-warning tabular-nums">
435
+ {activeRuns.length}
436
+ </span>
437
+ </div>
438
+ <VirtualizedRunList runs={activeRuns} maxHeight={500} />
439
+ </div>
440
+ )}
441
+
442
+ {/* Failed runs — collapsible section */}
443
+ {hasFailedRuns && (
444
+ <div>
445
+ <button
446
+ onClick={(e) => { e.stopPropagation(); setShowFailed((v) => !v); }}
447
+ className="flex items-center gap-2 py-1.5 text-xs group w-fit"
448
+ >
449
+ <AlertCircle className="h-3.5 w-3.5 text-error/70" />
450
+ <span className="font-semibold text-error/80 group-hover:text-error transition-colors">Failed Runs</span>
451
+ <span className="rounded-full bg-error/10 border border-error/20 px-2 py-px text-xs font-semibold text-error tabular-nums">
452
+ {failedRuns.length}
453
+ </span>
454
+ {showFailed ? (
455
+ <ChevronUp className="h-3 w-3 text-error/40 group-hover:text-error/60" />
456
+ ) : (
457
+ <ChevronDown className="h-3 w-3 text-error/40 group-hover:text-error/60" />
458
+ )}
459
+ </button>
460
+ {showFailed && (
461
+ <div className="mt-1 opacity-70">
462
+ <VirtualizedRunList runs={failedRuns} maxHeight={400} />
463
+ </div>
464
+ )}
465
+ </div>
466
+ )}
467
+
468
+ {/* Completed runs — collapsible section */}
469
+ {hasSuccessRuns && (
470
+ <div>
471
+ <button
472
+ onClick={(e) => { e.stopPropagation(); setShowCompleted((v) => !v); }}
473
+ className="flex items-center gap-2 py-1.5 text-xs group w-fit"
474
+ >
475
+ <History className="h-3.5 w-3.5 text-foreground-muted/70" />
476
+ <span className="font-semibold text-foreground-muted group-hover:text-foreground-secondary transition-colors">Completed History</span>
477
+ <span className="rounded-full bg-background-secondary border border-border px-2 py-px text-xs font-semibold text-foreground-muted tabular-nums">
478
+ {successRuns.length}
479
+ </span>
480
+ {showCompleted ? (
481
+ <ChevronUp className="h-3 w-3 text-foreground-muted/60 group-hover:text-foreground-muted" />
482
+ ) : (
483
+ <ChevronDown className="h-3 w-3 text-foreground-muted/60 group-hover:text-foreground-muted" />
484
+ )}
485
+ </button>
486
+ {showCompleted && (
487
+ <div className="mt-1 opacity-60">
488
+ <VirtualizedRunList runs={successRuns} maxHeight={400} />
489
+ </div>
490
+ )}
491
+ </div>
492
+ )}
493
+ </div>
494
+ )}
495
+ {totalCount > PAGE_SIZE && (
496
+ <PaginationControls
497
+ currentPage={page}
498
+ totalItems={totalCount}
499
+ itemsPerPage={PAGE_SIZE}
500
+ onPageChange={setPage}
501
+ className="mt-3"
502
+ />
503
+ )}
504
+ </div>
505
+ );
506
+ })()}
507
+ </Card>
508
+ );
509
+ }
510
+
511
+ function MiniKpiPill({ icon, count, label, colorClass, bgClass, pulse, active, onClick }: {
512
+ icon: React.ReactNode; count: number; label: string; colorClass: string; bgClass: string; pulse?: boolean; active?: boolean; onClick?: (e: React.MouseEvent) => void;
513
+ }) {
514
+ const isClickable = !!onClick;
515
+ return (
516
+ <div
517
+ role={isClickable ? "button" : undefined}
518
+ tabIndex={isClickable ? 0 : undefined}
519
+ onClick={onClick}
520
+ onKeyDown={isClickable ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick?.(e as unknown as React.MouseEvent); } } : undefined}
521
+ className={cn(
522
+ "rounded-md px-2.5 py-1.5 flex items-center gap-2 transition-all",
523
+ bgClass,
524
+ isClickable && "cursor-pointer hover:opacity-80",
525
+ active && "ring-2 ring-offset-1 ring-offset-card",
526
+ active && colorClass.replace("text-", "ring-").replace(/\/\d+$/, "/50"),
527
+ )}
528
+ >
529
+ <span className={cn(colorClass, pulse && "animate-pulse")}>{icon}</span>
530
+ <div>
531
+ <p className={cn("text-sm font-bold tabular-nums leading-none", colorClass)}>{count}</p>
532
+ <p className="text-xs leading-tight text-foreground-muted uppercase tracking-wider">{label}</p>
533
+ </div>
534
+ </div>
535
+ );
536
+ }