oh-my-opencode-dashboard 0.0.1 → 0.0.2
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/README.md +2 -0
- package/dist/assets/index-RAZRO3YN.css +1 -0
- package/dist/assets/index-Vi32E82S.js +40 -0
- package/dist/index.html +3 -3
- package/package.json +1 -1
- package/src/App.tsx +358 -9
- package/src/app-version.d.ts +1 -0
- package/src/ingest/boulder.ts +44 -0
- package/src/ingest/timeseries.test.ts +491 -0
- package/src/ingest/timeseries.ts +289 -0
- package/src/server/api.test.ts +17 -0
- package/src/server/dashboard.test.ts +43 -0
- package/src/server/dashboard.ts +13 -1
- package/src/sound.test.ts +47 -0
- package/src/sound.ts +12 -1
- package/src/styles.css +201 -0
- package/vite.config.ts +24 -1
- package/dashboard-ui.png +0 -0
- package/dist/assets/index-D6OVzN1o.css +0 -1
- package/dist/assets/index-SEmwze_4.js +0 -40
package/src/App.tsx
CHANGED
|
@@ -2,6 +2,11 @@ import * as React from "react";
|
|
|
2
2
|
import { computeWaitingDing } from "./ding-policy";
|
|
3
3
|
import { playDing, unlockAudio } from "./sound";
|
|
4
4
|
|
|
5
|
+
const APP_VERSION =
|
|
6
|
+
typeof __APP_VERSION__ === "string" && __APP_VERSION__.trim().length > 0 ? __APP_VERSION__ : "0.0.0";
|
|
7
|
+
|
|
8
|
+
const APP_TITLE = `Agent Dashboard (v${APP_VERSION})`;
|
|
9
|
+
|
|
5
10
|
type BackgroundTask = {
|
|
6
11
|
id: string;
|
|
7
12
|
description: string;
|
|
@@ -13,6 +18,31 @@ type BackgroundTask = {
|
|
|
13
18
|
timeline: string;
|
|
14
19
|
};
|
|
15
20
|
|
|
21
|
+
type TimeSeriesTone = "muted" | "teal" | "red" | "green";
|
|
22
|
+
|
|
23
|
+
type TimeSeriesSeriesId =
|
|
24
|
+
| "overall-main"
|
|
25
|
+
| "agent:sisyphus"
|
|
26
|
+
| "agent:prometheus"
|
|
27
|
+
| "agent:atlas"
|
|
28
|
+
| "background-total";
|
|
29
|
+
|
|
30
|
+
type TimeSeriesSeries = {
|
|
31
|
+
id: TimeSeriesSeriesId;
|
|
32
|
+
label: string;
|
|
33
|
+
tone: TimeSeriesTone;
|
|
34
|
+
values: number[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type TimeSeries = {
|
|
38
|
+
windowMs: number;
|
|
39
|
+
buckets: number;
|
|
40
|
+
bucketMs: number;
|
|
41
|
+
anchorMs: number;
|
|
42
|
+
serverNowMs: number;
|
|
43
|
+
series: TimeSeriesSeries[];
|
|
44
|
+
};
|
|
45
|
+
|
|
16
46
|
type DashboardPayload = {
|
|
17
47
|
mainSession: {
|
|
18
48
|
agent: string;
|
|
@@ -27,11 +57,121 @@ type DashboardPayload = {
|
|
|
27
57
|
total: number;
|
|
28
58
|
path: string;
|
|
29
59
|
statusPill: string;
|
|
60
|
+
steps?: Array<{ checked: boolean; text: string }>;
|
|
30
61
|
};
|
|
31
62
|
backgroundTasks: BackgroundTask[];
|
|
63
|
+
timeSeries: TimeSeries;
|
|
32
64
|
raw: unknown;
|
|
33
65
|
};
|
|
34
66
|
|
|
67
|
+
const TIME_SERIES_DEFAULT_WINDOW_MS = 300_000;
|
|
68
|
+
const TIME_SERIES_DEFAULT_BUCKET_MS = 2_000;
|
|
69
|
+
const TIME_SERIES_DEFAULT_BUCKETS = Math.floor(TIME_SERIES_DEFAULT_WINDOW_MS / TIME_SERIES_DEFAULT_BUCKET_MS);
|
|
70
|
+
|
|
71
|
+
const TIME_SERIES_SERIES_DEFS: Array<Pick<TimeSeriesSeries, "id" | "label" | "tone">> = [
|
|
72
|
+
{ id: "overall-main", label: "Overall", tone: "muted" },
|
|
73
|
+
{ id: "agent:sisyphus", label: "Sisyphus", tone: "teal" },
|
|
74
|
+
{ id: "agent:prometheus", label: "Prometheus", tone: "red" },
|
|
75
|
+
{ id: "agent:atlas", label: "Atlas", tone: "green" },
|
|
76
|
+
{ id: "background-total", label: "Background tasks (total)", tone: "muted" },
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
function toFiniteNumber(value: unknown): number | null {
|
|
80
|
+
if (typeof value !== "number") return null;
|
|
81
|
+
if (!Number.isFinite(value)) return null;
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function toNonNegativeCount(value: unknown): number {
|
|
86
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
87
|
+
if (!Number.isFinite(n)) return 0;
|
|
88
|
+
return Math.max(0, Math.floor(n));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function makeZeroTimeSeries(opts: {
|
|
92
|
+
windowMs?: number;
|
|
93
|
+
buckets?: number;
|
|
94
|
+
bucketMs?: number;
|
|
95
|
+
anchorMs?: number;
|
|
96
|
+
serverNowMs?: number;
|
|
97
|
+
}): TimeSeries {
|
|
98
|
+
const windowMs = Math.max(1, Math.floor(opts.windowMs ?? TIME_SERIES_DEFAULT_WINDOW_MS));
|
|
99
|
+
const bucketMs = Math.max(1, Math.floor(opts.bucketMs ?? TIME_SERIES_DEFAULT_BUCKET_MS));
|
|
100
|
+
const derivedBuckets = Math.floor(windowMs / bucketMs);
|
|
101
|
+
const buckets = Math.max(
|
|
102
|
+
1,
|
|
103
|
+
Math.floor(opts.buckets ?? (derivedBuckets > 0 ? derivedBuckets : TIME_SERIES_DEFAULT_BUCKETS))
|
|
104
|
+
);
|
|
105
|
+
const serverNowMs = Math.floor(opts.serverNowMs ?? 0);
|
|
106
|
+
const anchorMs = Math.floor(opts.anchorMs ?? 0);
|
|
107
|
+
|
|
108
|
+
const values = new Array<number>(buckets).fill(0);
|
|
109
|
+
return {
|
|
110
|
+
windowMs,
|
|
111
|
+
buckets,
|
|
112
|
+
bucketMs,
|
|
113
|
+
anchorMs,
|
|
114
|
+
serverNowMs,
|
|
115
|
+
series: TIME_SERIES_SERIES_DEFS.map((def) => ({ ...def, values: values.slice() })),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizeTimeSeries(input: unknown, nowMsFallback: number): TimeSeries {
|
|
120
|
+
if (!input || typeof input !== "object") {
|
|
121
|
+
const bucketMs = TIME_SERIES_DEFAULT_BUCKET_MS;
|
|
122
|
+
const serverNowMs = nowMsFallback;
|
|
123
|
+
const anchorMs = Math.floor(serverNowMs / bucketMs) * bucketMs;
|
|
124
|
+
return makeZeroTimeSeries({
|
|
125
|
+
windowMs: TIME_SERIES_DEFAULT_WINDOW_MS,
|
|
126
|
+
bucketMs,
|
|
127
|
+
buckets: TIME_SERIES_DEFAULT_BUCKETS,
|
|
128
|
+
anchorMs,
|
|
129
|
+
serverNowMs,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const rec = input as Record<string, unknown>;
|
|
134
|
+
|
|
135
|
+
const windowMsRaw = toFiniteNumber(rec.windowMs ?? rec.window_ms);
|
|
136
|
+
const bucketsRaw = toFiniteNumber(rec.buckets);
|
|
137
|
+
const bucketMsRaw = toFiniteNumber(rec.bucketMs ?? rec.bucket_ms);
|
|
138
|
+
const bucketMs = Math.max(1, Math.floor(bucketMsRaw ?? TIME_SERIES_DEFAULT_BUCKET_MS));
|
|
139
|
+
|
|
140
|
+
const bucketsFromWindow = windowMsRaw && bucketMs ? Math.floor(windowMsRaw / bucketMs) : null;
|
|
141
|
+
const buckets = Math.max(1, Math.floor(bucketsRaw ?? bucketsFromWindow ?? TIME_SERIES_DEFAULT_BUCKETS));
|
|
142
|
+
|
|
143
|
+
const windowMs = Math.max(1, Math.floor(windowMsRaw ?? buckets * bucketMs));
|
|
144
|
+
|
|
145
|
+
const serverNowRaw = toFiniteNumber(rec.serverNowMs ?? rec.server_now_ms);
|
|
146
|
+
const serverNowMs = Math.floor(serverNowRaw ?? nowMsFallback);
|
|
147
|
+
|
|
148
|
+
const anchorRaw = toFiniteNumber(rec.anchorMs ?? rec.anchor_ms);
|
|
149
|
+
const anchorMs = Math.floor(anchorRaw ?? Math.floor(serverNowMs / bucketMs) * bucketMs);
|
|
150
|
+
|
|
151
|
+
const seriesInput = rec.series;
|
|
152
|
+
const byId = new Map<string, unknown>();
|
|
153
|
+
if (Array.isArray(seriesInput)) {
|
|
154
|
+
for (const s of seriesInput) {
|
|
155
|
+
if (!s || typeof s !== "object") continue;
|
|
156
|
+
const srec = s as Record<string, unknown>;
|
|
157
|
+
const id = typeof srec.id === "string" ? srec.id : typeof srec.key === "string" ? srec.key : null;
|
|
158
|
+
if (!id) continue;
|
|
159
|
+
if (!byId.has(id)) byId.set(id, srec);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const series: TimeSeriesSeries[] = TIME_SERIES_SERIES_DEFS.map((def) => {
|
|
164
|
+
const found = byId.get(def.id);
|
|
165
|
+
const valuesRaw = found && typeof found === "object" ? (found as Record<string, unknown>).values : null;
|
|
166
|
+
const parsed = Array.isArray(valuesRaw) ? valuesRaw.map(toNonNegativeCount) : [];
|
|
167
|
+
const trimmed = parsed.length > buckets ? parsed.slice(parsed.length - buckets) : parsed;
|
|
168
|
+
const padded = trimmed.length < buckets ? trimmed.concat(new Array<number>(buckets - trimmed.length).fill(0)) : trimmed;
|
|
169
|
+
return { ...def, values: padded };
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
return { windowMs, buckets, bucketMs, anchorMs, serverNowMs, series };
|
|
173
|
+
}
|
|
174
|
+
|
|
35
175
|
const FALLBACK_DATA: DashboardPayload = {
|
|
36
176
|
mainSession: {
|
|
37
177
|
agent: "sisyphus",
|
|
@@ -59,6 +199,13 @@ const FALLBACK_DATA: DashboardPayload = {
|
|
|
59
199
|
timeline: "2026-01-01T00:00:00Z: 2m",
|
|
60
200
|
},
|
|
61
201
|
],
|
|
202
|
+
timeSeries: makeZeroTimeSeries({
|
|
203
|
+
windowMs: TIME_SERIES_DEFAULT_WINDOW_MS,
|
|
204
|
+
bucketMs: TIME_SERIES_DEFAULT_BUCKET_MS,
|
|
205
|
+
buckets: TIME_SERIES_DEFAULT_BUCKETS,
|
|
206
|
+
anchorMs: 0,
|
|
207
|
+
serverNowMs: 0,
|
|
208
|
+
}),
|
|
62
209
|
raw: {
|
|
63
210
|
ok: false,
|
|
64
211
|
hint: "API not reachable yet. Using placeholder data.",
|
|
@@ -93,6 +240,21 @@ function statusTone(status: string): "teal" | "sand" | "red" {
|
|
|
93
240
|
return "sand";
|
|
94
241
|
}
|
|
95
242
|
|
|
243
|
+
function maxCount(values: number[]): number {
|
|
244
|
+
let max = 0;
|
|
245
|
+
for (const v of values) {
|
|
246
|
+
if (typeof v !== "number") continue;
|
|
247
|
+
if (!Number.isFinite(v)) continue;
|
|
248
|
+
if (v > max) max = v;
|
|
249
|
+
}
|
|
250
|
+
return max;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function barHeight(value: number, max: number, chartHeight: number): number {
|
|
254
|
+
if (!max || value <= 0) return 0;
|
|
255
|
+
return Math.max(1, Math.round((value / max) * chartHeight));
|
|
256
|
+
}
|
|
257
|
+
|
|
96
258
|
async function safeFetchJson(url: string): Promise<unknown> {
|
|
97
259
|
const controller = new AbortController();
|
|
98
260
|
const t = window.setTimeout(() => controller.abort(), 1600);
|
|
@@ -144,6 +306,8 @@ function toDashboardPayload(json: unknown): DashboardPayload {
|
|
|
144
306
|
const completed = Number(plan.completed ?? plan.done ?? 0) || 0;
|
|
145
307
|
const total = Number(plan.total ?? plan.count ?? 0) || 0;
|
|
146
308
|
|
|
309
|
+
const timeSeries = normalizeTimeSeries(anyJson.timeSeries, Date.now());
|
|
310
|
+
|
|
147
311
|
return {
|
|
148
312
|
mainSession: {
|
|
149
313
|
agent: String(main.agent ?? FALLBACK_DATA.mainSession.agent),
|
|
@@ -160,6 +324,7 @@ function toDashboardPayload(json: unknown): DashboardPayload {
|
|
|
160
324
|
statusPill: String(plan.statusPill ?? plan.status ?? FALLBACK_DATA.planProgress.statusPill),
|
|
161
325
|
},
|
|
162
326
|
backgroundTasks,
|
|
327
|
+
timeSeries,
|
|
163
328
|
raw: json,
|
|
164
329
|
};
|
|
165
330
|
}
|
|
@@ -171,6 +336,7 @@ export default function App() {
|
|
|
171
336
|
const [copyState, setCopyState] = React.useState<"idle" | "ok" | "err">("idle");
|
|
172
337
|
const [soundEnabled, setSoundEnabled] = React.useState(false);
|
|
173
338
|
const [soundUnlocked, setSoundUnlocked] = React.useState(false);
|
|
339
|
+
const [planOpen, setPlanOpen] = React.useState(false);
|
|
174
340
|
const [errorHint, setErrorHint] = React.useState<string | null>(null);
|
|
175
341
|
|
|
176
342
|
const timerRef = React.useRef<number | null>(null);
|
|
@@ -186,6 +352,11 @@ export default function App() {
|
|
|
186
352
|
return window.location.origin;
|
|
187
353
|
}, []);
|
|
188
354
|
|
|
355
|
+
React.useEffect(() => {
|
|
356
|
+
if (typeof document === "undefined") return;
|
|
357
|
+
document.title = APP_TITLE;
|
|
358
|
+
}, []);
|
|
359
|
+
|
|
189
360
|
React.useEffect(() => {
|
|
190
361
|
soundEnabledRef.current = soundEnabled;
|
|
191
362
|
}, [soundEnabled]);
|
|
@@ -231,7 +402,7 @@ export default function App() {
|
|
|
231
402
|
return hasSession && idle && noTool;
|
|
232
403
|
}
|
|
233
404
|
|
|
234
|
-
function maybePlayDings(next: DashboardPayload) {
|
|
405
|
+
function maybePlayDings(prev: DashboardPayload | null, next: DashboardPayload) {
|
|
235
406
|
if (!soundEnabledRef.current) return;
|
|
236
407
|
if (!hadSuccessRef.current) return;
|
|
237
408
|
|
|
@@ -258,6 +429,12 @@ export default function App() {
|
|
|
258
429
|
}
|
|
259
430
|
}
|
|
260
431
|
|
|
432
|
+
const tool = String(next.mainSession.currentTool ?? "").trim().toLowerCase();
|
|
433
|
+
const prevTool = String(prev?.mainSession.currentTool ?? "").trim().toLowerCase();
|
|
434
|
+
if (tool === "question" && prevTool !== "question") {
|
|
435
|
+
void playDing("question");
|
|
436
|
+
}
|
|
437
|
+
|
|
261
438
|
const waitingDecision = computeWaitingDing({
|
|
262
439
|
prev: {
|
|
263
440
|
prevWaiting,
|
|
@@ -299,10 +476,12 @@ export default function App() {
|
|
|
299
476
|
hadSuccessRef.current = true;
|
|
300
477
|
setConnected(true);
|
|
301
478
|
setErrorHint(null);
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
479
|
+
const next = toDashboardPayload(json);
|
|
480
|
+
setData((prev) => {
|
|
481
|
+
maybePlayDings(prev, next);
|
|
482
|
+
return next;
|
|
483
|
+
});
|
|
484
|
+
setLastUpdate(Date.now());
|
|
306
485
|
} catch (err) {
|
|
307
486
|
if (!alive) return;
|
|
308
487
|
nextConnected = false;
|
|
@@ -351,6 +530,23 @@ export default function App() {
|
|
|
351
530
|
const liveLabel = connected ? "Live" : "Disconnected";
|
|
352
531
|
const liveTone = connected ? "teal" : "sand";
|
|
353
532
|
|
|
533
|
+
const timeSeriesById = React.useMemo(() => {
|
|
534
|
+
const map = new Map<TimeSeriesSeriesId, TimeSeriesSeries>();
|
|
535
|
+
for (const s of data.timeSeries.series) {
|
|
536
|
+
if (s && typeof s.id === "string") {
|
|
537
|
+
map.set(s.id, s);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return map;
|
|
541
|
+
}, [data.timeSeries.series]);
|
|
542
|
+
|
|
543
|
+
const buckets = Math.max(1, data.timeSeries.buckets);
|
|
544
|
+
const bucketMs = Math.max(1, data.timeSeries.bucketMs);
|
|
545
|
+
const viewBox = `0 0 ${buckets} 28`;
|
|
546
|
+
const minuteStep = Math.max(1, Math.round(60_000 / bucketMs));
|
|
547
|
+
|
|
548
|
+
const overallValues = timeSeriesById.get("overall-main")?.values ?? [];
|
|
549
|
+
|
|
354
550
|
return (
|
|
355
551
|
<div className="page">
|
|
356
552
|
<div className="container">
|
|
@@ -358,7 +554,7 @@ export default function App() {
|
|
|
358
554
|
<div className="brand">
|
|
359
555
|
<div className="brandMark" aria-hidden="true" />
|
|
360
556
|
<div className="brandText">
|
|
361
|
-
<h1>
|
|
557
|
+
<h1>{APP_TITLE}</h1>
|
|
362
558
|
<p>
|
|
363
559
|
Live view (no prompts or tool arguments rendered).
|
|
364
560
|
{!connected && errorHint ? <span className="hint"> - {errorHint}</span> : null}
|
|
@@ -395,6 +591,137 @@ export default function App() {
|
|
|
395
591
|
</header>
|
|
396
592
|
|
|
397
593
|
<main className="stack">
|
|
594
|
+
<section className="timeSeries">
|
|
595
|
+
<div className="timeSeriesHeader">
|
|
596
|
+
<h2 className="timeSeriesTitle">Time-series activity</h2>
|
|
597
|
+
<p className="timeSeriesSub">Last 5 minutes</p>
|
|
598
|
+
</div>
|
|
599
|
+
|
|
600
|
+
<div className="timeSeriesAxisTop" aria-hidden="true">
|
|
601
|
+
<div />
|
|
602
|
+
<div className="timeSeriesAxisTopLabels">
|
|
603
|
+
<span className="timeSeriesAxisTopLabel">-5m</span>
|
|
604
|
+
<span className="timeSeriesAxisTopLabel">-4m</span>
|
|
605
|
+
<span className="timeSeriesAxisTopLabel">-3m</span>
|
|
606
|
+
<span className="timeSeriesAxisTopLabel">-1m</span>
|
|
607
|
+
</div>
|
|
608
|
+
</div>
|
|
609
|
+
|
|
610
|
+
<div className="timeSeriesRows">
|
|
611
|
+
{(
|
|
612
|
+
[
|
|
613
|
+
{
|
|
614
|
+
label: "Sisyphus",
|
|
615
|
+
tone: "teal" as const,
|
|
616
|
+
overlayId: "agent:sisyphus" as const,
|
|
617
|
+
baseline: false,
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
label: "Prometheus",
|
|
621
|
+
tone: "red" as const,
|
|
622
|
+
overlayId: "agent:prometheus" as const,
|
|
623
|
+
baseline: false,
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
label: "Atlas",
|
|
627
|
+
tone: "green" as const,
|
|
628
|
+
overlayId: "agent:atlas" as const,
|
|
629
|
+
baseline: false,
|
|
630
|
+
},
|
|
631
|
+
{
|
|
632
|
+
label: "background tasks (total)",
|
|
633
|
+
tone: "muted" as const,
|
|
634
|
+
overlayId: "background-total" as const,
|
|
635
|
+
baseline: false,
|
|
636
|
+
},
|
|
637
|
+
] as const
|
|
638
|
+
).map((row) => {
|
|
639
|
+
const H = 28;
|
|
640
|
+
const padTop = 2;
|
|
641
|
+
const padBottom = 2;
|
|
642
|
+
const chartHeight = H - padTop - padBottom;
|
|
643
|
+
const baselineY = H - padBottom;
|
|
644
|
+
const barW = 0.85;
|
|
645
|
+
const barInset = (1 - barW) / 2;
|
|
646
|
+
|
|
647
|
+
const overlayValues = timeSeriesById.get(row.overlayId)?.values ?? [];
|
|
648
|
+
const baselineMax = row.baseline ? maxCount(overallValues) : 0;
|
|
649
|
+
const overlayMax = maxCount(overlayValues);
|
|
650
|
+
const scaleMax = Math.max(1, row.baseline ? Math.max(baselineMax, overlayMax) : overlayMax || 1);
|
|
651
|
+
|
|
652
|
+
return (
|
|
653
|
+
<div key={row.overlayId} className="timeSeriesRow" data-tone={row.tone}>
|
|
654
|
+
<div className="timeSeriesRowLabel">{row.label}</div>
|
|
655
|
+
<div className="timeSeriesSvgWrap">
|
|
656
|
+
<svg className="timeSeriesSvg" viewBox={viewBox} preserveAspectRatio="none" aria-hidden="true">
|
|
657
|
+
{Array.from({ length: Math.floor(buckets / minuteStep) + 1 }, (_, idx) => {
|
|
658
|
+
const x = idx * minuteStep;
|
|
659
|
+
if (x < 0 || x > buckets) return null;
|
|
660
|
+
return (
|
|
661
|
+
<line
|
|
662
|
+
key={`g-${idx}`}
|
|
663
|
+
className="timeSeriesGridline"
|
|
664
|
+
x1={x}
|
|
665
|
+
x2={x}
|
|
666
|
+
y1={0}
|
|
667
|
+
y2={H}
|
|
668
|
+
/>
|
|
669
|
+
);
|
|
670
|
+
})}
|
|
671
|
+
|
|
672
|
+
{row.baseline
|
|
673
|
+
? overallValues.slice(0, buckets).map((v, i) => {
|
|
674
|
+
const h = barHeight(v ?? 0, scaleMax, chartHeight);
|
|
675
|
+
if (!h) return null;
|
|
676
|
+
const barX = i + barInset;
|
|
677
|
+
return (
|
|
678
|
+
<rect
|
|
679
|
+
key={`b-${i}`}
|
|
680
|
+
className="timeSeriesBarBaseline"
|
|
681
|
+
x={barX}
|
|
682
|
+
y={baselineY - h}
|
|
683
|
+
width={barW}
|
|
684
|
+
height={h}
|
|
685
|
+
/>
|
|
686
|
+
);
|
|
687
|
+
})
|
|
688
|
+
: null}
|
|
689
|
+
|
|
690
|
+
{overlayValues.slice(0, buckets).map((v, i) => {
|
|
691
|
+
const h = barHeight(v ?? 0, scaleMax, chartHeight);
|
|
692
|
+
if (!h) return null;
|
|
693
|
+
const barX = i + barInset;
|
|
694
|
+
return (
|
|
695
|
+
<rect
|
|
696
|
+
key={`o-${i}`}
|
|
697
|
+
className="timeSeriesBar"
|
|
698
|
+
x={barX}
|
|
699
|
+
y={baselineY - h}
|
|
700
|
+
width={barW}
|
|
701
|
+
height={h}
|
|
702
|
+
/>
|
|
703
|
+
);
|
|
704
|
+
})}
|
|
705
|
+
</svg>
|
|
706
|
+
</div>
|
|
707
|
+
</div>
|
|
708
|
+
);
|
|
709
|
+
})}
|
|
710
|
+
</div>
|
|
711
|
+
|
|
712
|
+
<div className="timeSeriesAxisBottom" aria-hidden="true">
|
|
713
|
+
<div />
|
|
714
|
+
<div className="timeSeriesAxisBottomLabels">
|
|
715
|
+
<span className="timeSeriesAxisBottomLabel">-5m</span>
|
|
716
|
+
<span className="timeSeriesAxisBottomLabel">-4m</span>
|
|
717
|
+
<span className="timeSeriesAxisBottomLabel">-3m</span>
|
|
718
|
+
<span className="timeSeriesAxisBottomLabel">-2m</span>
|
|
719
|
+
<span className="timeSeriesAxisBottomLabel">-1m</span>
|
|
720
|
+
<span className="timeSeriesAxisBottomLabel">Now</span>
|
|
721
|
+
</div>
|
|
722
|
+
</div>
|
|
723
|
+
</section>
|
|
724
|
+
|
|
398
725
|
<section className="grid2">
|
|
399
726
|
<article className="card">
|
|
400
727
|
<div className="cardHeader">
|
|
@@ -427,6 +754,16 @@ export default function App() {
|
|
|
427
754
|
<h2>Plan progress</h2>
|
|
428
755
|
<span className={`pill pill-${statusTone(data.planProgress.statusPill)}`}>{data.planProgress.statusPill}</span>
|
|
429
756
|
</div>
|
|
757
|
+
<div className="cardHeader" style={{ marginTop: 8 }}>
|
|
758
|
+
<button
|
|
759
|
+
className="button"
|
|
760
|
+
type="button"
|
|
761
|
+
onClick={() => setPlanOpen((v) => !v)}
|
|
762
|
+
aria-expanded={planOpen}
|
|
763
|
+
>
|
|
764
|
+
{planOpen ? "Hide steps" : "Show steps"}
|
|
765
|
+
</button>
|
|
766
|
+
</div>
|
|
430
767
|
<div className="kv">
|
|
431
768
|
<div className="kvRow">
|
|
432
769
|
<div className="kvKey">NAME</div>
|
|
@@ -442,7 +779,19 @@ export default function App() {
|
|
|
442
779
|
</div>
|
|
443
780
|
</div>
|
|
444
781
|
</div>
|
|
445
|
-
|
|
782
|
+
{planOpen ? (
|
|
783
|
+
<div className="divider" />
|
|
784
|
+
) : null}
|
|
785
|
+
{planOpen ? (
|
|
786
|
+
<div className="mono" style={{ fontSize: 12, lineHeight: 1.5 }}>
|
|
787
|
+
{(data.planProgress.steps ?? []).length > 0
|
|
788
|
+
? (data.planProgress.steps ?? []).map((s, idx) => (
|
|
789
|
+
<div key={`${idx}-${s.checked ? "x" : "_"}-${s.text}`}>[{s.checked ? "x" : " "}] {s.text || "(empty)"}</div>
|
|
790
|
+
))
|
|
791
|
+
: "(no steps detected)"}
|
|
792
|
+
</div>
|
|
793
|
+
) : null}
|
|
794
|
+
<div className="progressWrap">
|
|
446
795
|
<div className="progressTrack">
|
|
447
796
|
<div className="progressFill" style={{ width: `${planPercent}%` }} />
|
|
448
797
|
</div>
|
|
@@ -454,7 +803,7 @@ export default function App() {
|
|
|
454
803
|
<section className="card">
|
|
455
804
|
<div className="cardHeader">
|
|
456
805
|
<h2>Background tasks</h2>
|
|
457
|
-
<span className="badge"
|
|
806
|
+
<span className="badge">
|
|
458
807
|
{data.backgroundTasks.length}
|
|
459
808
|
</span>
|
|
460
809
|
</div>
|
|
@@ -483,7 +832,7 @@ export default function App() {
|
|
|
483
832
|
</td>
|
|
484
833
|
<td className="mono">{t.toolCalls}</td>
|
|
485
834
|
<td className="mono">{t.lastTool}</td>
|
|
486
|
-
<td className="mono muted">{t.timeline || "-"}</td>
|
|
835
|
+
<td className="mono muted">{t.status.toLowerCase() === "queued" ? "-" : t.timeline || "-"}</td>
|
|
487
836
|
</tr>
|
|
488
837
|
))}
|
|
489
838
|
</tbody>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
declare const __APP_VERSION__: string;
|
package/src/ingest/boulder.ts
CHANGED
|
@@ -16,6 +16,11 @@ export type PlanProgress = {
|
|
|
16
16
|
missing: boolean
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export type PlanStep = {
|
|
20
|
+
checked: boolean
|
|
21
|
+
text: string
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
export function readBoulderState(projectRoot: string): BoulderState | null {
|
|
20
25
|
const filePath = assertAllowedPath({
|
|
21
26
|
candidatePath: path.join(projectRoot, ".sisyphus", "boulder.json"),
|
|
@@ -46,6 +51,22 @@ export function getPlanProgressFromMarkdown(content: string): Omit<PlanProgress,
|
|
|
46
51
|
}
|
|
47
52
|
}
|
|
48
53
|
|
|
54
|
+
export function getPlanStepsFromMarkdown(content: string): PlanStep[] {
|
|
55
|
+
const lines = content.split(/\r?\n/)
|
|
56
|
+
const steps: PlanStep[] = []
|
|
57
|
+
|
|
58
|
+
for (const raw of lines) {
|
|
59
|
+
const line = raw.trim()
|
|
60
|
+
const m = line.match(/^[-*]\s*\[(\s|x|X)\]\s*(.*)$/)
|
|
61
|
+
if (!m) continue
|
|
62
|
+
const checked = m[1] === "x" || m[1] === "X"
|
|
63
|
+
const text = (m[2] ?? "").trim()
|
|
64
|
+
steps.push({ checked, text })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return steps
|
|
68
|
+
}
|
|
69
|
+
|
|
49
70
|
export function readPlanProgress(projectRoot: string, planPath: string): PlanProgress {
|
|
50
71
|
let planReal: string
|
|
51
72
|
try {
|
|
@@ -69,3 +90,26 @@ export function readPlanProgress(projectRoot: string, planPath: string): PlanPro
|
|
|
69
90
|
return { total: 0, completed: 0, isComplete: false, missing: true }
|
|
70
91
|
}
|
|
71
92
|
}
|
|
93
|
+
|
|
94
|
+
export function readPlanSteps(projectRoot: string, planPath: string): { missing: boolean; steps: PlanStep[] } {
|
|
95
|
+
let planReal: string
|
|
96
|
+
try {
|
|
97
|
+
planReal = assertAllowedPath({
|
|
98
|
+
candidatePath: planPath,
|
|
99
|
+
allowedRoots: [projectRoot],
|
|
100
|
+
})
|
|
101
|
+
} catch {
|
|
102
|
+
return { missing: true, steps: [] }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!fs.existsSync(planReal)) {
|
|
106
|
+
return { missing: true, steps: [] }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const content = fs.readFileSync(planReal, "utf8")
|
|
111
|
+
return { missing: false, steps: getPlanStepsFromMarkdown(content) }
|
|
112
|
+
} catch {
|
|
113
|
+
return { missing: true, steps: [] }
|
|
114
|
+
}
|
|
115
|
+
}
|