@a5c-ai/babysitter-observer-dashboard 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +490 -0
- package/next.config.mjs +25 -0
- package/package.json +104 -0
- package/postcss.config.mjs +8 -0
- package/src/app/actions/__tests__/approve-breakpoint.test.ts +246 -0
- package/src/app/actions/approve-breakpoint.ts +145 -0
- package/src/app/api/config/route.ts +137 -0
- package/src/app/api/digest/route.ts +45 -0
- package/src/app/api/runs/[runId]/events/route.ts +56 -0
- package/src/app/api/runs/[runId]/route.ts +84 -0
- package/src/app/api/runs/[runId]/tasks/[effectId]/route.ts +44 -0
- package/src/app/api/runs/route.ts +48 -0
- package/src/app/api/stream/route.ts +136 -0
- package/src/app/api/test/route.ts +1 -0
- package/src/app/api/version/route.ts +57 -0
- package/src/app/globals.css +555 -0
- package/src/app/icon.svg +20 -0
- package/src/app/layout.tsx +39 -0
- package/src/app/not-found.tsx +16 -0
- package/src/app/page.tsx +120 -0
- package/src/app/runs/[runId]/page.tsx +279 -0
- package/src/cli.ts +271 -0
- package/src/components/breakpoint/__tests__/breakpoint-approval.test.tsx +212 -0
- package/src/components/breakpoint/__tests__/breakpoint-panel.test.tsx +130 -0
- package/src/components/breakpoint/__tests__/file-preview.test.tsx +313 -0
- package/src/components/breakpoint/breakpoint-approval.tsx +138 -0
- package/src/components/breakpoint/breakpoint-panel.tsx +95 -0
- package/src/components/breakpoint/file-preview.tsx +215 -0
- package/src/components/dashboard/.gitkeep +0 -0
- package/src/components/dashboard/__tests__/breakpoint-banner.test.tsx +177 -0
- package/src/components/dashboard/__tests__/catch-up-banner.test.tsx +141 -0
- package/src/components/dashboard/__tests__/executive-summary-banner.test.tsx +164 -0
- package/src/components/dashboard/__tests__/kpi-grid.test.tsx +101 -0
- package/src/components/dashboard/__tests__/pagination-controls.test.tsx +125 -0
- package/src/components/dashboard/__tests__/project-accordion.test.tsx +97 -0
- package/src/components/dashboard/__tests__/project-list-view.test.tsx +174 -0
- package/src/components/dashboard/__tests__/project-search-input.test.tsx +110 -0
- package/src/components/dashboard/__tests__/project-section-header.test.tsx +91 -0
- package/src/components/dashboard/__tests__/project-section.test.tsx +151 -0
- package/src/components/dashboard/__tests__/run-card.test.tsx +164 -0
- package/src/components/dashboard/__tests__/run-filter-bar.test.tsx +109 -0
- package/src/components/dashboard/__tests__/run-list.test.tsx +123 -0
- package/src/components/dashboard/__tests__/search-filter.test.tsx +150 -0
- package/src/components/dashboard/__tests__/virtualized-run-list.test.tsx +179 -0
- package/src/components/dashboard/breakpoint-banner.tsx +301 -0
- package/src/components/dashboard/catch-up-banner.tsx +88 -0
- package/src/components/dashboard/executive-summary-banner.tsx +174 -0
- package/src/components/dashboard/global-search.tsx +323 -0
- package/src/components/dashboard/kpi-grid.tsx +140 -0
- package/src/components/dashboard/pagination-controls.tsx +100 -0
- package/src/components/dashboard/project-accordion.tsx +72 -0
- package/src/components/dashboard/project-health-card.tsx +536 -0
- package/src/components/dashboard/project-list-view.tsx +246 -0
- package/src/components/dashboard/project-search-input.tsx +41 -0
- package/src/components/dashboard/project-section-header.tsx +73 -0
- package/src/components/dashboard/project-section.tsx +89 -0
- package/src/components/dashboard/run-card.tsx +218 -0
- package/src/components/dashboard/run-filter-bar.tsx +100 -0
- package/src/components/dashboard/run-list.tsx +77 -0
- package/src/components/dashboard/search-filter.tsx +69 -0
- package/src/components/dashboard/virtualized-run-list.tsx +130 -0
- package/src/components/details/.gitkeep +0 -0
- package/src/components/details/__tests__/agent-panel.test.tsx +236 -0
- package/src/components/details/__tests__/json-tree.test.tsx +347 -0
- package/src/components/details/__tests__/log-viewer.test.tsx +168 -0
- package/src/components/details/__tests__/task-detail.test.tsx +212 -0
- package/src/components/details/__tests__/timing-panel.test.tsx +271 -0
- package/src/components/details/agent-panel.tsx +234 -0
- package/src/components/details/json-tree/categorize.ts +131 -0
- package/src/components/details/json-tree/index.tsx +120 -0
- package/src/components/details/json-tree/json-node.tsx +223 -0
- package/src/components/details/json-tree/smart-summary.tsx +596 -0
- package/src/components/details/json-tree/tree-controls.tsx +47 -0
- package/src/components/details/json-tree.tsx +9 -0
- package/src/components/details/log-viewer.tsx +140 -0
- package/src/components/details/task-detail.tsx +114 -0
- package/src/components/details/timing-panel.tsx +247 -0
- package/src/components/events/.gitkeep +0 -0
- package/src/components/events/__tests__/event-item.test.tsx +211 -0
- package/src/components/events/__tests__/event-stream.test.tsx +225 -0
- package/src/components/events/event-item.tsx +121 -0
- package/src/components/events/event-stream.tsx +260 -0
- package/src/components/notifications/.gitkeep +0 -0
- package/src/components/notifications/__tests__/notification-panel.test.tsx +287 -0
- package/src/components/notifications/__tests__/notification-provider.test.tsx +585 -0
- package/src/components/notifications/__tests__/toast-stack.test.tsx +217 -0
- package/src/components/notifications/notification-panel.tsx +124 -0
- package/src/components/notifications/notification-provider.tsx +175 -0
- package/src/components/notifications/toast-stack.tsx +75 -0
- package/src/components/pipeline/.gitkeep +0 -0
- package/src/components/pipeline/__tests__/parallel-group.test.tsx +88 -0
- package/src/components/pipeline/__tests__/pipeline-view.test.tsx +345 -0
- package/src/components/pipeline/__tests__/step-card.test.tsx +330 -0
- package/src/components/pipeline/parallel-group.tsx +39 -0
- package/src/components/pipeline/pipeline-view.tsx +197 -0
- package/src/components/pipeline/step-card.tsx +166 -0
- package/src/components/providers/event-stream-provider.tsx +29 -0
- package/src/components/providers.tsx +24 -0
- package/src/components/shared/.gitkeep +0 -0
- package/src/components/shared/__tests__/empty-state.test.tsx +49 -0
- package/src/components/shared/__tests__/friendly-id.test.tsx +47 -0
- package/src/components/shared/__tests__/kbd.test.tsx +45 -0
- package/src/components/shared/__tests__/kind-badge.test.tsx +71 -0
- package/src/components/shared/__tests__/metrics-row.test.tsx +74 -0
- package/src/components/shared/__tests__/outcome-banner.test.tsx +71 -0
- package/src/components/shared/__tests__/progress-bar.test.tsx +89 -0
- package/src/components/shared/__tests__/session-pill.test.tsx +62 -0
- package/src/components/shared/__tests__/settings-modal.test.tsx +201 -0
- package/src/components/shared/__tests__/shortcuts-help.test.tsx +103 -0
- package/src/components/shared/__tests__/status-badge.test.tsx +98 -0
- package/src/components/shared/__tests__/theme-provider.test.tsx +100 -0
- package/src/components/shared/__tests__/truncated-id.test.tsx +53 -0
- package/src/components/shared/app-footer.tsx +80 -0
- package/src/components/shared/app-header.tsx +160 -0
- package/src/components/shared/empty-state.tsx +18 -0
- package/src/components/shared/error-boundary.tsx +81 -0
- package/src/components/shared/friendly-id.tsx +48 -0
- package/src/components/shared/kbd.tsx +15 -0
- package/src/components/shared/kind-badge.tsx +51 -0
- package/src/components/shared/metrics-row.tsx +106 -0
- package/src/components/shared/outcome-banner.tsx +56 -0
- package/src/components/shared/progress-bar.tsx +42 -0
- package/src/components/shared/session-pill.tsx +69 -0
- package/src/components/shared/settings-modal.tsx +509 -0
- package/src/components/shared/shortcuts-help.tsx +113 -0
- package/src/components/shared/status-badge.tsx +110 -0
- package/src/components/shared/theme-provider.tsx +46 -0
- package/src/components/shared/truncated-id.tsx +51 -0
- package/src/components/ui/.gitkeep +0 -0
- package/src/components/ui/__tests__/accordion.test.tsx +96 -0
- package/src/components/ui/__tests__/badge.test.tsx +69 -0
- package/src/components/ui/__tests__/button.test.tsx +113 -0
- package/src/components/ui/__tests__/tabs.test.tsx +75 -0
- package/src/components/ui/__tests__/tooltip.test.tsx +90 -0
- package/src/components/ui/accordion.tsx +61 -0
- package/src/components/ui/badge.tsx +25 -0
- package/src/components/ui/button.tsx +40 -0
- package/src/components/ui/card.tsx +21 -0
- package/src/components/ui/scroll-area.tsx +35 -0
- package/src/components/ui/separator.tsx +24 -0
- package/src/components/ui/tabs.tsx +64 -0
- package/src/components/ui/tooltip.tsx +37 -0
- package/src/hooks/.gitkeep +0 -0
- package/src/hooks/__tests__/use-animated-number.test.ts +184 -0
- package/src/hooks/__tests__/use-batched-updates.test.ts +315 -0
- package/src/hooks/__tests__/use-event-stream.test.ts +243 -0
- package/src/hooks/__tests__/use-keyboard.test.ts +217 -0
- package/src/hooks/__tests__/use-notifications.test.ts +230 -0
- package/src/hooks/__tests__/use-polling.test.ts +274 -0
- package/src/hooks/__tests__/use-project-runs.test.ts +163 -0
- package/src/hooks/__tests__/use-projects.test.ts +248 -0
- package/src/hooks/__tests__/use-run-dashboard.test.ts +168 -0
- package/src/hooks/__tests__/use-run-detail.test.ts +273 -0
- package/src/hooks/__tests__/use-smart-polling.test.ts +305 -0
- package/src/hooks/use-animated-number.ts +87 -0
- package/src/hooks/use-batched-updates.ts +150 -0
- package/src/hooks/use-event-stream.ts +150 -0
- package/src/hooks/use-keyboard.ts +45 -0
- package/src/hooks/use-notifications.ts +82 -0
- package/src/hooks/use-persisted-state.ts +60 -0
- package/src/hooks/use-polling.ts +60 -0
- package/src/hooks/use-project-runs.ts +51 -0
- package/src/hooks/use-projects.ts +26 -0
- package/src/hooks/use-run-dashboard.ts +207 -0
- package/src/hooks/use-run-detail.ts +77 -0
- package/src/hooks/use-smart-polling.ts +144 -0
- package/src/lib/.gitkeep +0 -0
- package/src/lib/__tests__/cn.test.ts +69 -0
- package/src/lib/__tests__/config-loader.test.ts +210 -0
- package/src/lib/__tests__/config.test.ts +561 -0
- package/src/lib/__tests__/error-handler.test.ts +143 -0
- package/src/lib/__tests__/fetcher.test.ts +517 -0
- package/src/lib/__tests__/global-registry.test.ts +214 -0
- package/src/lib/__tests__/parser.test.ts +1532 -0
- package/src/lib/__tests__/path-resolver.test.ts +112 -0
- package/src/lib/__tests__/run-cache.test.ts +591 -0
- package/src/lib/__tests__/server-init.test.ts +512 -0
- package/src/lib/__tests__/source-discovery.test.ts +246 -0
- package/src/lib/__tests__/utils.test.ts +160 -0
- package/src/lib/__tests__/watcher.test.ts +227 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/config-loader.ts +195 -0
- package/src/lib/config.ts +20 -0
- package/src/lib/error-handler.ts +76 -0
- package/src/lib/fetcher.ts +394 -0
- package/src/lib/global-registry.ts +117 -0
- package/src/lib/parser.ts +794 -0
- package/src/lib/path-resolver.ts +16 -0
- package/src/lib/run-cache.ts +404 -0
- package/src/lib/server-init.ts +226 -0
- package/src/lib/services/__tests__/run-query-service.test.ts +819 -0
- package/src/lib/services/run-query-service.ts +286 -0
- package/src/lib/source-discovery.ts +216 -0
- package/src/lib/utils.ts +103 -0
- package/src/lib/watcher.ts +265 -0
- package/src/test/fixtures.ts +269 -0
- package/src/test/mocks/handlers.ts +110 -0
- package/src/test/mocks/server.ts +17 -0
- package/src/test/setup.ts +200 -0
- package/src/test/test-utils.tsx +36 -0
- package/src/types/.gitkeep +0 -0
- package/src/types/breakpoint.ts +17 -0
- package/src/types/index.ts +214 -0
- package/tsconfig.json +50 -0
|
@@ -0,0 +1,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
|
+
}
|