@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,509 @@
1
+ "use client";
2
+ import { useEffect, useState, useCallback, useRef } from "react";
3
+ import * as Dialog from "@radix-ui/react-dialog";
4
+ import {
5
+ X,
6
+ Settings,
7
+ FolderOpen,
8
+ Timer,
9
+ Palette,
10
+ Plus,
11
+ Trash2,
12
+ Loader2,
13
+ Check,
14
+ CalendarDays,
15
+ Eye,
16
+ EyeOff,
17
+ } from "lucide-react";
18
+ import { cn } from "@/lib/cn";
19
+ import { resilientFetch } from "@/lib/fetcher";
20
+ import { useTheme } from "@/components/shared/theme-provider";
21
+
22
+ interface WatchSource {
23
+ path: string;
24
+ depth: number;
25
+ label?: string;
26
+ }
27
+
28
+ interface ConfigData {
29
+ sources: WatchSource[];
30
+ port: number;
31
+ pollInterval: number;
32
+ theme: "dark" | "light";
33
+ retentionDays: number;
34
+ hiddenProjects: string[];
35
+ }
36
+
37
+ interface SettingsModalProps {
38
+ open: boolean;
39
+ onClose: () => void;
40
+ }
41
+
42
+ export function SettingsModal({ open, onClose }: SettingsModalProps) {
43
+ const { theme: currentTheme, toggle: toggleTheme } = useTheme();
44
+
45
+ // Server config (fetched on open)
46
+ const [serverConfig, setServerConfig] = useState<ConfigData | null>(null);
47
+ const [fetchError, setFetchError] = useState<string | null>(null);
48
+ const [fetchLoading, setFetchLoading] = useState(false);
49
+
50
+ // Editable state
51
+ const [sources, setSources] = useState<WatchSource[]>([]);
52
+ const [pollInterval, setPollInterval] = useState(2000);
53
+ const [selectedTheme, setSelectedTheme] = useState<"dark" | "light">("dark");
54
+ const [retentionDays, setRetentionDays] = useState(30);
55
+ const [hiddenProjects, setHiddenProjects] = useState<string[]>([]);
56
+
57
+ // Discovered project names (fetched from API)
58
+ const [allProjectNames, setAllProjectNames] = useState<string[]>([]);
59
+
60
+ // Save state
61
+ const [saving, setSaving] = useState(false);
62
+ const [saveResult, setSaveResult] = useState<"success" | "error" | null>(null);
63
+ const [saveError, setSaveError] = useState<string | null>(null);
64
+
65
+ // Abort controllers for config fetch and save
66
+ const fetchAbortRef = useRef<AbortController | null>(null);
67
+ const saveAbortRef = useRef<AbortController | null>(null);
68
+
69
+ // Fetch config on open
70
+ useEffect(() => {
71
+ if (!open) return;
72
+ setFetchLoading(true);
73
+ setFetchError(null);
74
+ setSaveResult(null);
75
+ setSaveError(null);
76
+
77
+ fetchAbortRef.current?.abort();
78
+ fetchAbortRef.current = new AbortController();
79
+
80
+ const signal = fetchAbortRef.current.signal;
81
+
82
+ // Fetch config and project names in parallel
83
+ Promise.all([
84
+ resilientFetch<ConfigData>("/api/config", { signal }),
85
+ resilientFetch<{ projects: { projectName: string }[] }>("/api/runs?mode=projects", { signal }),
86
+ ])
87
+ .then(([configResult, projectsResult]) => {
88
+ if (!configResult.ok) {
89
+ if (configResult.error.isAborted) return;
90
+ setFetchError(configResult.error.message);
91
+ return;
92
+ }
93
+ const data = configResult.data;
94
+ setServerConfig(data);
95
+ setSources(data.sources.map((s) => ({ ...s })));
96
+ setPollInterval(data.pollInterval);
97
+ setSelectedTheme(data.theme);
98
+ setRetentionDays(data.retentionDays);
99
+ setHiddenProjects(data.hiddenProjects ?? []);
100
+
101
+ // Build full project list: visible projects from API + currently hidden projects from config
102
+ const visibleNames = projectsResult.ok
103
+ ? projectsResult.data.projects.map((p) => p.projectName)
104
+ : [];
105
+ const hiddenNames = data.hiddenProjects ?? [];
106
+ const combined = Array.from(new Set([...visibleNames, ...hiddenNames])).sort();
107
+ setAllProjectNames(combined);
108
+ })
109
+ .finally(() => setFetchLoading(false));
110
+
111
+ return () => {
112
+ fetchAbortRef.current?.abort();
113
+ saveAbortRef.current?.abort();
114
+ };
115
+ }, [open]);
116
+
117
+ // Source row handlers
118
+ const updateSource = useCallback(
119
+ (index: number, field: keyof WatchSource, value: string | number) => {
120
+ setSources((prev) =>
121
+ prev.map((s, i) => (i === index ? { ...s, [field]: value } : s))
122
+ );
123
+ },
124
+ []
125
+ );
126
+
127
+ const removeSource = useCallback((index: number) => {
128
+ setSources((prev) => prev.filter((_, i) => i !== index));
129
+ }, []);
130
+
131
+ const addSource = useCallback(() => {
132
+ setSources((prev) => [...prev, { path: "", depth: 2 }]);
133
+ }, []);
134
+
135
+ // Cancel - revert to fetched config
136
+ const handleCancel = useCallback(() => {
137
+ if (serverConfig) {
138
+ setSources(serverConfig.sources.map((s) => ({ ...s })));
139
+ setPollInterval(serverConfig.pollInterval);
140
+ setSelectedTheme(serverConfig.theme);
141
+ setRetentionDays(serverConfig.retentionDays);
142
+ setHiddenProjects(serverConfig.hiddenProjects ?? []);
143
+ }
144
+ setSaveResult(null);
145
+ setSaveError(null);
146
+ }, [serverConfig]);
147
+
148
+ // Save
149
+ const handleSave = useCallback(async () => {
150
+ setSaving(true);
151
+ setSaveResult(null);
152
+ setSaveError(null);
153
+
154
+ saveAbortRef.current?.abort();
155
+ saveAbortRef.current = new AbortController();
156
+
157
+ const result = await resilientFetch<ConfigData>("/api/config", {
158
+ method: "POST",
159
+ headers: { "Content-Type": "application/json" },
160
+ body: JSON.stringify({
161
+ sources: sources.filter((s) => s.path.trim()),
162
+ pollInterval,
163
+ theme: selectedTheme,
164
+ retentionDays,
165
+ hiddenProjects,
166
+ }),
167
+ signal: saveAbortRef.current.signal,
168
+ });
169
+
170
+ if (!result.ok) {
171
+ if (result.error.isAborted) return;
172
+ setSaveResult("error");
173
+ setSaveError(result.error.message);
174
+ setSaving(false);
175
+ return;
176
+ }
177
+
178
+ const saved = result.data;
179
+ setServerConfig(saved);
180
+ setSources(saved.sources.map((s) => ({ ...s })));
181
+ setPollInterval(saved.pollInterval);
182
+ setSelectedTheme(saved.theme);
183
+ setRetentionDays(saved.retentionDays);
184
+ setHiddenProjects(saved.hiddenProjects ?? []);
185
+ setSaveResult("success");
186
+
187
+ // Apply theme change locally if it changed
188
+ if (saved.theme !== currentTheme) {
189
+ toggleTheme();
190
+ }
191
+
192
+ // Auto-dismiss success after 2s
193
+ setTimeout(() => setSaveResult(null), 2000);
194
+ setSaving(false);
195
+ }, [sources, pollInterval, selectedTheme, retentionDays, hiddenProjects, currentTheme, toggleTheme]);
196
+
197
+ const hasChanges =
198
+ serverConfig &&
199
+ (JSON.stringify(sources) !==
200
+ JSON.stringify(serverConfig.sources) ||
201
+ pollInterval !== serverConfig.pollInterval ||
202
+ selectedTheme !== serverConfig.theme ||
203
+ retentionDays !== serverConfig.retentionDays ||
204
+ JSON.stringify(hiddenProjects.slice().sort()) !==
205
+ JSON.stringify((serverConfig.hiddenProjects ?? []).slice().sort()));
206
+
207
+ return (
208
+ <Dialog.Root open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose(); }}>
209
+ <Dialog.Portal>
210
+ <Dialog.Overlay className="fixed inset-0 z-50 bg-black/60" />
211
+ <Dialog.Content
212
+ className="fixed inset-0 z-50 flex items-center justify-center p-4"
213
+ data-testid="settings-modal"
214
+ >
215
+ <div className="relative z-50 rounded-lg border border-border bg-card shadow-xl w-full max-w-lg max-h-[80vh] flex flex-col">
216
+ {/* Header */}
217
+ <div className="flex items-center justify-between p-4 border-b border-border">
218
+ <div className="flex items-center gap-2">
219
+ <Settings className="h-4 w-4 text-foreground-muted" />
220
+ <Dialog.Title className="text-sm font-medium text-foreground">Settings</Dialog.Title>
221
+ </div>
222
+ <Dialog.Close asChild>
223
+ <button
224
+ className="rounded-md p-2 min-h-[44px] min-w-[44px] flex items-center justify-center text-foreground-muted hover:text-foreground transition-colors"
225
+ >
226
+ <X className="h-4 w-4" />
227
+ </button>
228
+ </Dialog.Close>
229
+ </div>
230
+
231
+ {/* Body */}
232
+ <Dialog.Description asChild>
233
+ <div className="flex-1 overflow-y-auto p-4">
234
+ {fetchLoading ? (
235
+ <div className="flex items-center justify-center py-12 text-foreground-muted">
236
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
237
+ <p className="text-sm">Loading configuration...</p>
238
+ </div>
239
+ ) : fetchError ? (
240
+ <div className="rounded-lg border border-error/20 bg-error-muted p-3 text-sm text-error">
241
+ Failed to load config: {fetchError}
242
+ </div>
243
+ ) : serverConfig ? (
244
+ <div className="space-y-5">
245
+ {/* Watch Sources */}
246
+ <section>
247
+ <div className="flex items-center gap-2 mb-2">
248
+ <FolderOpen className="h-4 w-4 text-foreground-muted" />
249
+ <span className="text-xs font-medium text-foreground-secondary">
250
+ Watch Sources
251
+ </span>
252
+ </div>
253
+ <div className="space-y-2">
254
+ {sources.map((source, i) => (
255
+ <div
256
+ key={i}
257
+ className="rounded-md border border-border bg-background p-2.5 space-y-2"
258
+ >
259
+ <div className="flex items-start gap-2">
260
+ <div className="flex-1">
261
+ <label className="text-xs uppercase tracking-wider text-foreground-muted mb-1 block">
262
+ Path
263
+ </label>
264
+ <input
265
+ type="text"
266
+ value={source.path}
267
+ onChange={(e) =>
268
+ updateSource(i, "path", e.target.value)
269
+ }
270
+ placeholder="/path/to/projects"
271
+ className="w-full rounded-md border border-border bg-background-secondary px-2.5 py-1.5 font-mono text-xs text-foreground placeholder:text-foreground-muted/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
272
+ />
273
+ </div>
274
+ <button
275
+ onClick={() => removeSource(i)}
276
+ className="mt-4 rounded-md p-2 min-h-[44px] min-w-[44px] flex items-center justify-center text-foreground-muted hover:text-error hover:bg-error/10 transition-colors"
277
+ title="Remove source"
278
+ >
279
+ <Trash2 className="h-3.5 w-3.5" />
280
+ </button>
281
+ </div>
282
+ <div className="flex items-center gap-3">
283
+ <div className="w-20">
284
+ <label className="text-xs uppercase tracking-wider text-foreground-muted mb-1 block">
285
+ Depth
286
+ </label>
287
+ <input
288
+ type="number"
289
+ value={source.depth}
290
+ onChange={(e) =>
291
+ updateSource(
292
+ i,
293
+ "depth",
294
+ Math.max(0, Math.min(10, parseInt(e.target.value) || 0))
295
+ )
296
+ }
297
+ min={0}
298
+ max={10}
299
+ className="w-full rounded-md border border-border bg-background-secondary px-2.5 py-1.5 font-mono text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary/50"
300
+ />
301
+ </div>
302
+ </div>
303
+ </div>
304
+ ))}
305
+ </div>
306
+ <button
307
+ onClick={addSource}
308
+ className="mt-2 flex items-center gap-1.5 rounded-md px-2.5 py-1.5 min-h-[44px] text-xs text-foreground-muted hover:text-foreground hover:bg-background-secondary transition-colors"
309
+ >
310
+ <Plus className="h-3.5 w-3.5" />
311
+ Add Source
312
+ </button>
313
+ </section>
314
+
315
+ {/* Poll Interval */}
316
+ <section>
317
+ <div className="flex items-center gap-2 mb-2">
318
+ <Timer className="h-4 w-4 text-foreground-muted" />
319
+ <span className="text-xs font-medium text-foreground-secondary">
320
+ Poll Interval
321
+ </span>
322
+ </div>
323
+ <div className="flex items-center gap-2">
324
+ <input
325
+ type="number"
326
+ value={pollInterval}
327
+ onChange={(e) =>
328
+ setPollInterval(
329
+ Math.max(500, parseInt(e.target.value) || 500)
330
+ )
331
+ }
332
+ min={500}
333
+ step={500}
334
+ className="w-28 rounded-md border border-border bg-background-secondary px-2.5 py-1.5 font-mono text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary/50"
335
+ />
336
+ <span className="text-xs text-foreground-muted">ms</span>
337
+ </div>
338
+ </section>
339
+
340
+ {/* Theme */}
341
+ <section>
342
+ <div className="flex items-center gap-2 mb-2">
343
+ <Palette className="h-4 w-4 text-foreground-muted" />
344
+ <span className="text-xs font-medium text-foreground-secondary">
345
+ Theme
346
+ </span>
347
+ </div>
348
+ <div className="flex rounded-md border border-border overflow-hidden">
349
+ {(["dark", "light"] as const).map((t) => (
350
+ <button
351
+ key={t}
352
+ onClick={() => setSelectedTheme(t)}
353
+ className={cn(
354
+ "flex-1 px-4 py-1.5 min-h-[44px] text-xs font-medium transition-colors capitalize",
355
+ selectedTheme === t
356
+ ? "bg-primary/15 text-primary"
357
+ : "bg-background-secondary text-foreground-muted hover:text-foreground"
358
+ )}
359
+ >
360
+ {t}
361
+ </button>
362
+ ))}
363
+ </div>
364
+ </section>
365
+
366
+ {/* Retention Window */}
367
+ <section>
368
+ <div className="flex items-center gap-2 mb-2">
369
+ <CalendarDays className="h-4 w-4 text-foreground-muted" />
370
+ <span className="text-xs font-medium text-foreground-secondary">
371
+ Run Retention
372
+ </span>
373
+ </div>
374
+ <div className="flex items-center gap-2">
375
+ <span className="text-xs text-foreground-muted">Show runs from the last</span>
376
+ <input
377
+ type="number"
378
+ value={retentionDays}
379
+ onChange={(e) =>
380
+ setRetentionDays(
381
+ Math.max(1, Math.min(365, parseInt(e.target.value) || 30))
382
+ )
383
+ }
384
+ min={1}
385
+ max={365}
386
+ className="w-20 rounded-md border border-border bg-background-secondary px-2.5 py-1.5 font-mono text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary/50"
387
+ />
388
+ <span className="text-xs text-foreground-muted">days</span>
389
+ </div>
390
+ <p className="text-xs text-foreground-muted mt-1.5">
391
+ Older completed/failed runs are hidden from the dashboard. Active runs are always shown.
392
+ </p>
393
+ </section>
394
+
395
+ {/* Project Visibility */}
396
+ {allProjectNames.length > 0 && (
397
+ <section>
398
+ <div className="flex items-center gap-2 mb-2">
399
+ <Eye className="h-4 w-4 text-foreground-muted" />
400
+ <span className="text-xs font-medium text-foreground-secondary">
401
+ Project Visibility
402
+ </span>
403
+ </div>
404
+ <p className="text-xs text-foreground-muted mb-2">
405
+ Hidden projects will not appear on the dashboard.
406
+ </p>
407
+ <div className="space-y-1">
408
+ {allProjectNames.map((name) => {
409
+ const isHidden = hiddenProjects.includes(name);
410
+ return (
411
+ <div
412
+ key={name}
413
+ className="flex items-center justify-between rounded-md border border-border bg-background px-2.5 py-1.5"
414
+ >
415
+ <span className={cn(
416
+ "text-xs font-mono truncate",
417
+ isHidden ? "text-foreground-muted line-through" : "text-foreground"
418
+ )}>
419
+ {name}
420
+ </span>
421
+ <button
422
+ onClick={() => {
423
+ setHiddenProjects((prev) =>
424
+ isHidden
425
+ ? prev.filter((p) => p !== name)
426
+ : [...prev, name]
427
+ );
428
+ }}
429
+ className={cn(
430
+ "rounded-md p-2 min-h-[44px] min-w-[44px] flex items-center justify-center transition-colors",
431
+ isHidden
432
+ ? "text-foreground-muted hover:text-foreground hover:bg-background-secondary"
433
+ : "text-foreground-secondary hover:text-foreground-muted hover:bg-background-secondary"
434
+ )}
435
+ title={isHidden ? "Show project" : "Hide project"}
436
+ >
437
+ {isHidden ? (
438
+ <EyeOff className="h-3.5 w-3.5" />
439
+ ) : (
440
+ <Eye className="h-3.5 w-3.5" />
441
+ )}
442
+ </button>
443
+ </div>
444
+ );
445
+ })}
446
+ </div>
447
+ </section>
448
+ )}
449
+
450
+ {/* Save result feedback */}
451
+ {saveResult === "success" && (
452
+ <div className="flex items-center gap-2 rounded-md border border-success/20 bg-success/5 px-3 py-2 text-xs text-success">
453
+ <Check className="h-3.5 w-3.5" />
454
+ Settings saved successfully
455
+ </div>
456
+ )}
457
+ {saveResult === "error" && (
458
+ <div className="rounded-md border border-error/20 bg-error/5 px-3 py-2 text-xs text-error">
459
+ {saveError || "Failed to save settings"}
460
+ </div>
461
+ )}
462
+ </div>
463
+ ) : null}
464
+ </div>
465
+ </Dialog.Description>
466
+
467
+ {/* Footer */}
468
+ {serverConfig && (
469
+ <div className="flex items-center justify-between border-t border-border px-4 py-3">
470
+ <p className="text-xs text-foreground-muted">
471
+ Config file: <span className="font-mono">~/.a5c/observer.json</span>
472
+ </p>
473
+ <div className="flex items-center gap-2">
474
+ <button
475
+ onClick={handleCancel}
476
+ disabled={saving}
477
+ className="rounded-md px-3 py-1.5 min-h-[44px] text-xs text-foreground-muted hover:text-foreground hover:bg-background-secondary transition-colors disabled:opacity-50"
478
+ >
479
+ Cancel
480
+ </button>
481
+ <button
482
+ onClick={handleSave}
483
+ disabled={saving || !hasChanges}
484
+ className={cn(
485
+ "rounded-md px-3 py-1.5 min-h-[44px] text-xs font-medium transition-colors",
486
+ hasChanges
487
+ ? "bg-primary text-primary-foreground hover:bg-primary/90"
488
+ : "bg-background-secondary text-foreground-muted cursor-not-allowed",
489
+ saving && "opacity-50"
490
+ )}
491
+ >
492
+ {saving ? (
493
+ <span className="flex items-center gap-1.5">
494
+ <Loader2 className="h-3 w-3 animate-spin" />
495
+ Saving...
496
+ </span>
497
+ ) : (
498
+ "Save"
499
+ )}
500
+ </button>
501
+ </div>
502
+ </div>
503
+ )}
504
+ </div>
505
+ </Dialog.Content>
506
+ </Dialog.Portal>
507
+ </Dialog.Root>
508
+ );
509
+ }
@@ -0,0 +1,113 @@
1
+ "use client";
2
+ import { useState, useEffect } from "react";
3
+ import { usePathname } from "next/navigation";
4
+ import * as Dialog from "@radix-ui/react-dialog";
5
+ import { Kbd } from "./kbd";
6
+ import { X } from "lucide-react";
7
+ import { useKeyboard } from "@/hooks/use-keyboard";
8
+
9
+ interface ShortcutEntry {
10
+ keys: string[];
11
+ description: string;
12
+ context: "global" | "dashboard" | "run-detail";
13
+ }
14
+
15
+ const shortcuts: ShortcutEntry[] = [
16
+ // Global shortcuts (work everywhere)
17
+ { keys: ["?"], description: "Show this help", context: "global" },
18
+ { keys: ["n"], description: "Toggle notifications", context: "global" },
19
+ // Dashboard shortcuts
20
+ { keys: ["/"], description: "Focus search", context: "dashboard" },
21
+ // Run detail shortcuts
22
+ { keys: ["j"], description: "Next item", context: "run-detail" },
23
+ { keys: ["k"], description: "Previous item", context: "run-detail" },
24
+ { keys: ["Enter"], description: "Open selected", context: "run-detail" },
25
+ { keys: ["Esc"], description: "Go back / Close", context: "run-detail" },
26
+ { keys: ["e"], description: "Toggle event stream", context: "run-detail" },
27
+ { keys: ["1"], description: "Agent tab", context: "run-detail" },
28
+ { keys: ["2"], description: "Timing tab", context: "run-detail" },
29
+ { keys: ["3"], description: "Logs tab", context: "run-detail" },
30
+ { keys: ["4"], description: "Data tab", context: "run-detail" },
31
+ { keys: ["5"], description: "Approval tab", context: "run-detail" },
32
+ ];
33
+
34
+ const sectionLabels: Record<string, string> = {
35
+ "global": "Global",
36
+ "dashboard": "Dashboard",
37
+ "run-detail": "Run Detail",
38
+ };
39
+
40
+ export function ShortcutsHelp() {
41
+ const [open, setOpen] = useState(false);
42
+ const pathname = usePathname();
43
+ const isRunDetail = pathname?.startsWith("/runs/") ?? false;
44
+
45
+ useKeyboard([
46
+ { key: "?", action: () => setOpen(true), description: "Show shortcuts help" },
47
+ { key: "Escape", action: () => setOpen(false), description: "Close shortcuts help" },
48
+ ]);
49
+
50
+ // Allow external components to open the shortcuts panel via custom event
51
+ useEffect(() => {
52
+ const handler = () => setOpen(true);
53
+ window.addEventListener("open-shortcuts-help", handler);
54
+ return () => window.removeEventListener("open-shortcuts-help", handler);
55
+ }, []);
56
+
57
+ // Filter shortcuts to show only relevant ones for current page
58
+ const visibleShortcuts = shortcuts.filter((s) => {
59
+ if (s.context === "global") return true;
60
+ if (s.context === "dashboard" && !isRunDetail) return true;
61
+ if (s.context === "run-detail" && isRunDetail) return true;
62
+ return false;
63
+ });
64
+
65
+ // Group by context for display
66
+ const sections = visibleShortcuts.reduce<Record<string, ShortcutEntry[]>>((acc, s) => {
67
+ (acc[s.context] ??= []).push(s);
68
+ return acc;
69
+ }, {});
70
+
71
+ return (
72
+ <Dialog.Root open={open} onOpenChange={setOpen}>
73
+ <Dialog.Portal>
74
+ <Dialog.Overlay className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm" />
75
+ <Dialog.Content
76
+ className="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 rounded-lg border border-[var(--glass-border)] bg-[var(--glass-bg)] backdrop-blur-xl p-6 shadow-glass w-full max-w-md"
77
+ >
78
+ <div className="flex items-center justify-between mb-4">
79
+ <Dialog.Title className="text-sm font-medium text-foreground">Keyboard Shortcuts</Dialog.Title>
80
+ <Dialog.Close asChild>
81
+ <button
82
+ className="rounded-md p-2 min-h-[44px] min-w-[44px] flex items-center justify-center text-foreground-muted hover:text-primary transition-colors"
83
+ >
84
+ <X className="h-4 w-4" />
85
+ </button>
86
+ </Dialog.Close>
87
+ </div>
88
+ <div className="space-y-4">
89
+ {Object.entries(sections).map(([context, items]) => (
90
+ <div key={context}>
91
+ <h3 className="text-xs font-medium text-foreground-muted uppercase tracking-wider mb-2">
92
+ {sectionLabels[context] ?? context}
93
+ </h3>
94
+ <div className="space-y-2">
95
+ {items.map(({ keys, description }) => (
96
+ <div key={description} className="flex items-center justify-between py-1">
97
+ <span className="text-sm text-foreground-secondary">{description}</span>
98
+ <div className="flex items-center gap-1">
99
+ {keys.map((k) => (
100
+ <Kbd key={k}>{k}</Kbd>
101
+ ))}
102
+ </div>
103
+ </div>
104
+ ))}
105
+ </div>
106
+ </div>
107
+ ))}
108
+ </div>
109
+ </Dialog.Content>
110
+ </Dialog.Portal>
111
+ </Dialog.Root>
112
+ );
113
+ }