oh-my-opencode-dashboard 0.0.3 → 0.0.4
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/dist/assets/index-CZM2MUUs.js +40 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/App.tsx +154 -42
- package/src/app-payload.test.ts +158 -0
- package/src/background-task-timeline.test.ts +32 -0
- package/src/ingest/background-tasks.test.ts +295 -2
- package/src/ingest/background-tasks.ts +67 -28
- package/src/ingest/model.ts +79 -0
- package/src/ingest/session.test.ts +119 -0
- package/src/ingest/session.ts +4 -0
- package/src/server/api.test.ts +2 -2
- package/src/server/dashboard.test.ts +139 -0
- package/src/server/dashboard.ts +38 -3
- package/src/timeseries-stacked.test.ts +261 -0
- package/src/timeseries-stacked.ts +145 -0
- package/dist/assets/index-Cs5xePn_.js +0 -40
package/src/App.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { computeWaitingDing } from "./ding-policy";
|
|
3
3
|
import { playDing, unlockAudio } from "./sound";
|
|
4
|
+
import { computeStackedSegments } from "./timeseries-stacked";
|
|
4
5
|
|
|
5
6
|
const APP_VERSION =
|
|
6
7
|
typeof __APP_VERSION__ === "string" && __APP_VERSION__.trim().length > 0 ? __APP_VERSION__ : "0.0.0";
|
|
@@ -12,6 +13,7 @@ type BackgroundTask = {
|
|
|
12
13
|
description: string;
|
|
13
14
|
subline?: string;
|
|
14
15
|
agent: string;
|
|
16
|
+
lastModel: string;
|
|
15
17
|
status: "queued" | "running" | "done" | "error" | "cancelled" | string;
|
|
16
18
|
toolCalls: number;
|
|
17
19
|
lastTool: string;
|
|
@@ -47,6 +49,7 @@ type DashboardPayload = {
|
|
|
47
49
|
mainSession: {
|
|
48
50
|
agent: string;
|
|
49
51
|
currentTool: string;
|
|
52
|
+
currentModel: string;
|
|
50
53
|
lastUpdatedLabel: string;
|
|
51
54
|
session: string;
|
|
52
55
|
statusPill: string;
|
|
@@ -176,6 +179,7 @@ const FALLBACK_DATA: DashboardPayload = {
|
|
|
176
179
|
mainSession: {
|
|
177
180
|
agent: "sisyphus",
|
|
178
181
|
currentTool: "dashboard_start",
|
|
182
|
+
currentModel: "anthropic/claude-opus-4-5",
|
|
179
183
|
lastUpdatedLabel: "just now",
|
|
180
184
|
session: "qa-session",
|
|
181
185
|
statusPill: "busy",
|
|
@@ -193,6 +197,7 @@ const FALLBACK_DATA: DashboardPayload = {
|
|
|
193
197
|
description: "Explore: find HTTP/SSE patterns",
|
|
194
198
|
subline: "task-1",
|
|
195
199
|
agent: "explore",
|
|
200
|
+
lastModel: "opencode/gpt-5-nano",
|
|
196
201
|
status: "running",
|
|
197
202
|
toolCalls: 3,
|
|
198
203
|
lastTool: "grep",
|
|
@@ -271,6 +276,20 @@ async function safeFetchJson(url: string): Promise<unknown> {
|
|
|
271
276
|
}
|
|
272
277
|
}
|
|
273
278
|
|
|
279
|
+
function toNonEmptyString(value: unknown): string | null {
|
|
280
|
+
if (typeof value !== "string") return null;
|
|
281
|
+
const trimmed = value.trim();
|
|
282
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function formatBackgroundTaskTimelineCell(status: unknown, timeline: unknown): string {
|
|
286
|
+
const s = typeof status === "string" ? status.trim().toLowerCase() : "";
|
|
287
|
+
if (s === "unknown") return "";
|
|
288
|
+
if (s === "queued") return "-";
|
|
289
|
+
|
|
290
|
+
return toNonEmptyString(timeline) ?? "-";
|
|
291
|
+
}
|
|
292
|
+
|
|
274
293
|
function toDashboardPayload(json: unknown): DashboardPayload {
|
|
275
294
|
if (!json || typeof json !== "object") {
|
|
276
295
|
return { ...FALLBACK_DATA, raw: json };
|
|
@@ -282,6 +301,22 @@ function toDashboardPayload(json: unknown): DashboardPayload {
|
|
|
282
301
|
const plan = (anyJson.planProgress ?? anyJson.plan_progress ?? {}) as Record<string, unknown>;
|
|
283
302
|
const tasks = (anyJson.backgroundTasks ?? anyJson.background_tasks ?? []) as unknown;
|
|
284
303
|
|
|
304
|
+
function parsePlanSteps(stepsInput: unknown): Array<{ checked: boolean; text: string }> {
|
|
305
|
+
if (!Array.isArray(stepsInput)) return [];
|
|
306
|
+
|
|
307
|
+
return stepsInput
|
|
308
|
+
.map((step): { checked: boolean; text: string } | null => {
|
|
309
|
+
if (!step || typeof step !== "object") return null;
|
|
310
|
+
|
|
311
|
+
const stepObj = step as Record<string, unknown>;
|
|
312
|
+
const checked = typeof stepObj.checked === "boolean" ? stepObj.checked : false;
|
|
313
|
+
const text = typeof stepObj.text === "string" ? stepObj.text : "";
|
|
314
|
+
|
|
315
|
+
return text.trim().length > 0 ? { checked, text } : null;
|
|
316
|
+
})
|
|
317
|
+
.filter((step): step is { checked: boolean; text: string } => step !== null);
|
|
318
|
+
}
|
|
319
|
+
|
|
285
320
|
const backgroundTasks: BackgroundTask[] = Array.isArray(tasks)
|
|
286
321
|
? tasks.map((t, idx) => {
|
|
287
322
|
const rec = (t ?? {}) as Record<string, unknown>;
|
|
@@ -295,6 +330,7 @@ function toDashboardPayload(json: unknown): DashboardPayload {
|
|
|
295
330
|
? rec.taskId
|
|
296
331
|
: undefined,
|
|
297
332
|
agent: String(rec.agent ?? rec.worker ?? "unknown"),
|
|
333
|
+
lastModel: toNonEmptyString(rec.lastModel ?? rec.last_model) ?? "-",
|
|
298
334
|
status: String(rec.status ?? "queued"),
|
|
299
335
|
toolCalls: Number(rec.toolCalls ?? rec.tool_calls ?? 0) || 0,
|
|
300
336
|
lastTool: String(rec.lastTool ?? rec.last_tool ?? "-") || "-",
|
|
@@ -305,6 +341,7 @@ function toDashboardPayload(json: unknown): DashboardPayload {
|
|
|
305
341
|
|
|
306
342
|
const completed = Number(plan.completed ?? plan.done ?? 0) || 0;
|
|
307
343
|
const total = Number(plan.total ?? plan.count ?? 0) || 0;
|
|
344
|
+
const steps = parsePlanSteps(plan.steps);
|
|
308
345
|
|
|
309
346
|
const timeSeries = normalizeTimeSeries(anyJson.timeSeries, Date.now());
|
|
310
347
|
|
|
@@ -312,6 +349,7 @@ function toDashboardPayload(json: unknown): DashboardPayload {
|
|
|
312
349
|
mainSession: {
|
|
313
350
|
agent: String(main.agent ?? FALLBACK_DATA.mainSession.agent),
|
|
314
351
|
currentTool: String(main.currentTool ?? main.current_tool ?? FALLBACK_DATA.mainSession.currentTool),
|
|
352
|
+
currentModel: toNonEmptyString(main.currentModel ?? main.current_model) ?? "-",
|
|
315
353
|
lastUpdatedLabel: String(main.lastUpdatedLabel ?? main.last_updated ?? "just now"),
|
|
316
354
|
session: String(main.session ?? main.session_id ?? FALLBACK_DATA.mainSession.session),
|
|
317
355
|
statusPill: String(main.statusPill ?? main.status ?? FALLBACK_DATA.mainSession.statusPill),
|
|
@@ -322,6 +360,7 @@ function toDashboardPayload(json: unknown): DashboardPayload {
|
|
|
322
360
|
total,
|
|
323
361
|
path: String(plan.path ?? FALLBACK_DATA.planProgress.path),
|
|
324
362
|
statusPill: String(plan.statusPill ?? plan.status ?? FALLBACK_DATA.planProgress.statusPill),
|
|
363
|
+
steps,
|
|
325
364
|
},
|
|
326
365
|
backgroundTasks,
|
|
327
366
|
timeSeries,
|
|
@@ -329,6 +368,7 @@ function toDashboardPayload(json: unknown): DashboardPayload {
|
|
|
329
368
|
};
|
|
330
369
|
}
|
|
331
370
|
|
|
371
|
+
export { toDashboardPayload };
|
|
332
372
|
export default function App() {
|
|
333
373
|
const [connected, setConnected] = React.useState(false);
|
|
334
374
|
const [data, setData] = React.useState<DashboardPayload>(FALLBACK_DATA);
|
|
@@ -394,15 +434,15 @@ export default function App() {
|
|
|
394
434
|
}
|
|
395
435
|
}
|
|
396
436
|
|
|
397
|
-
|
|
437
|
+
const isWaitingForUser = React.useCallback((payload: DashboardPayload): boolean => {
|
|
398
438
|
const status = payload.mainSession.statusPill.toLowerCase();
|
|
399
439
|
const hasSession = payload.mainSession.session !== "(no session)" && payload.mainSession.session !== "";
|
|
400
440
|
const idle = status.includes("idle");
|
|
401
441
|
const noTool = payload.mainSession.currentTool === "-" || payload.mainSession.currentTool === "";
|
|
402
442
|
return hasSession && idle && noTool;
|
|
403
|
-
}
|
|
443
|
+
}, []);
|
|
404
444
|
|
|
405
|
-
|
|
445
|
+
const maybePlayDings = React.useCallback((prev: DashboardPayload | null, next: DashboardPayload) => {
|
|
406
446
|
if (!soundEnabledRef.current) return;
|
|
407
447
|
if (!hadSuccessRef.current) return;
|
|
408
448
|
|
|
@@ -453,7 +493,7 @@ export default function App() {
|
|
|
453
493
|
lastLeftWaitingAtRef.current = waitingDecision.next.lastLeftWaitingAtMs;
|
|
454
494
|
prevPlanCompletedRef.current = completed;
|
|
455
495
|
prevPlanTotalRef.current = total;
|
|
456
|
-
}
|
|
496
|
+
}, [isWaitingForUser]);
|
|
457
497
|
|
|
458
498
|
const planPercent = React.useMemo(() => {
|
|
459
499
|
if (!data.planProgress.total) return 0;
|
|
@@ -469,23 +509,23 @@ export default function App() {
|
|
|
469
509
|
|
|
470
510
|
async function tick() {
|
|
471
511
|
let nextConnected = false;
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
512
|
+
try {
|
|
513
|
+
const json = await safeFetchJson("/api/dashboard");
|
|
514
|
+
if (!alive) return;
|
|
515
|
+
nextConnected = true;
|
|
516
|
+
hadSuccessRef.current = true;
|
|
517
|
+
setConnected(true);
|
|
518
|
+
setErrorHint(null);
|
|
519
|
+
const next = toDashboardPayload(json);
|
|
520
|
+
setData((prev) => {
|
|
521
|
+
maybePlayDings(prev, next);
|
|
522
|
+
return next;
|
|
523
|
+
});
|
|
524
|
+
setLastUpdate(Date.now());
|
|
525
|
+
} catch (err) {
|
|
526
|
+
if (!alive) return;
|
|
527
|
+
nextConnected = false;
|
|
528
|
+
setConnected(false);
|
|
489
529
|
const msg = err instanceof Error ? err.message : "disconnected";
|
|
490
530
|
setErrorHint(msg);
|
|
491
531
|
setData((prev) => {
|
|
@@ -512,7 +552,7 @@ export default function App() {
|
|
|
512
552
|
alive = false;
|
|
513
553
|
if (timerRef.current) window.clearTimeout(timerRef.current);
|
|
514
554
|
};
|
|
515
|
-
}, []);
|
|
555
|
+
}, [maybePlayDings]);
|
|
516
556
|
|
|
517
557
|
async function onCopyRawJson() {
|
|
518
558
|
setCopyState("idle");
|
|
@@ -544,6 +584,7 @@ export default function App() {
|
|
|
544
584
|
const bucketMs = Math.max(1, data.timeSeries.bucketMs);
|
|
545
585
|
const viewBox = `0 0 ${buckets} 28`;
|
|
546
586
|
const minuteStep = Math.max(1, Math.round(60_000 / bucketMs));
|
|
587
|
+
const bucketStartMs = data.timeSeries.anchorMs - (buckets - 1) * bucketMs;
|
|
547
588
|
|
|
548
589
|
const overallValues = timeSeriesById.get("overall-main")?.values ?? [];
|
|
549
590
|
|
|
@@ -611,24 +652,11 @@ export default function App() {
|
|
|
611
652
|
{(
|
|
612
653
|
[
|
|
613
654
|
{
|
|
614
|
-
|
|
615
|
-
|
|
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,
|
|
655
|
+
kind: "main-agents" as const,
|
|
656
|
+
label: "Main agents" as const,
|
|
630
657
|
},
|
|
631
658
|
{
|
|
659
|
+
kind: "single" as const,
|
|
632
660
|
label: "background tasks (total)",
|
|
633
661
|
tone: "muted" as const,
|
|
634
662
|
overlayId: "background-total" as const,
|
|
@@ -644,6 +672,82 @@ export default function App() {
|
|
|
644
672
|
const barW = 0.85;
|
|
645
673
|
const barInset = (1 - barW) / 2;
|
|
646
674
|
|
|
675
|
+
if (row.kind === "main-agents") {
|
|
676
|
+
const sisyphusValues = timeSeriesById.get("agent:sisyphus")?.values ?? [];
|
|
677
|
+
const prometheusValues = timeSeriesById.get("agent:prometheus")?.values ?? [];
|
|
678
|
+
const atlasValues = timeSeriesById.get("agent:atlas")?.values ?? [];
|
|
679
|
+
|
|
680
|
+
let sumMax = 0;
|
|
681
|
+
for (let i = 0; i < buckets; i++) {
|
|
682
|
+
const rawSis = sisyphusValues[i];
|
|
683
|
+
const rawPro = prometheusValues[i];
|
|
684
|
+
const rawAtl = atlasValues[i];
|
|
685
|
+
const sis = typeof rawSis === "number" && Number.isFinite(rawSis) ? Math.max(0, rawSis) : 0;
|
|
686
|
+
const pro = typeof rawPro === "number" && Number.isFinite(rawPro) ? Math.max(0, rawPro) : 0;
|
|
687
|
+
const atl = typeof rawAtl === "number" && Number.isFinite(rawAtl) ? Math.max(0, rawAtl) : 0;
|
|
688
|
+
const s = sis + pro + atl;
|
|
689
|
+
if (s > sumMax) sumMax = s;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const scaleMax = Math.max(1, sumMax || 1);
|
|
693
|
+
|
|
694
|
+
return (
|
|
695
|
+
<div key="main-agents" className="timeSeriesRow">
|
|
696
|
+
<div className="timeSeriesRowLabel">{row.label}</div>
|
|
697
|
+
<div className="timeSeriesSvgWrap">
|
|
698
|
+
<svg className="timeSeriesSvg" viewBox={viewBox} preserveAspectRatio="none" aria-hidden="true">
|
|
699
|
+
{Array.from({ length: Math.floor(buckets / minuteStep) + 1 }, (_, idx) => {
|
|
700
|
+
const x = idx * minuteStep;
|
|
701
|
+
if (x < 0 || x > buckets) return null;
|
|
702
|
+
return (
|
|
703
|
+
<line
|
|
704
|
+
key={`g-${bucketStartMs + x * bucketMs}`}
|
|
705
|
+
className="timeSeriesGridline"
|
|
706
|
+
x1={x}
|
|
707
|
+
x2={x}
|
|
708
|
+
y1={0}
|
|
709
|
+
y2={H}
|
|
710
|
+
/>
|
|
711
|
+
);
|
|
712
|
+
})}
|
|
713
|
+
|
|
714
|
+
{Array.from({ length: buckets }, (_, i) => {
|
|
715
|
+
const bucketMsAt = bucketStartMs + i * bucketMs;
|
|
716
|
+
const barX = i + barInset;
|
|
717
|
+
const rawSis = sisyphusValues[i];
|
|
718
|
+
const rawPro = prometheusValues[i];
|
|
719
|
+
const rawAtl = atlasValues[i];
|
|
720
|
+
const sis = typeof rawSis === "number" && Number.isFinite(rawSis) ? Math.max(0, rawSis) : 0;
|
|
721
|
+
const pro = typeof rawPro === "number" && Number.isFinite(rawPro) ? Math.max(0, rawPro) : 0;
|
|
722
|
+
const atl = typeof rawAtl === "number" && Number.isFinite(rawAtl) ? Math.max(0, rawAtl) : 0;
|
|
723
|
+
const segments = computeStackedSegments(
|
|
724
|
+
{
|
|
725
|
+
sisyphus: sis,
|
|
726
|
+
prometheus: pro,
|
|
727
|
+
atlas: atl,
|
|
728
|
+
},
|
|
729
|
+
scaleMax,
|
|
730
|
+
chartHeight
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
if (segments.length === 0) return null;
|
|
734
|
+
return segments.map((seg) => (
|
|
735
|
+
<rect
|
|
736
|
+
key={`main-agents-${bucketMsAt}-${seg.tone}`}
|
|
737
|
+
className={`timeSeriesBar timeSeriesBar--${seg.tone}`}
|
|
738
|
+
x={barX}
|
|
739
|
+
y={padTop + seg.y}
|
|
740
|
+
width={barW}
|
|
741
|
+
height={seg.height}
|
|
742
|
+
/>
|
|
743
|
+
));
|
|
744
|
+
})}
|
|
745
|
+
</svg>
|
|
746
|
+
</div>
|
|
747
|
+
</div>
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
|
|
647
751
|
const overlayValues = timeSeriesById.get(row.overlayId)?.values ?? [];
|
|
648
752
|
const baselineMax = row.baseline ? maxCount(overallValues) : 0;
|
|
649
753
|
const overlayMax = maxCount(overlayValues);
|
|
@@ -659,7 +763,7 @@ export default function App() {
|
|
|
659
763
|
if (x < 0 || x > buckets) return null;
|
|
660
764
|
return (
|
|
661
765
|
<line
|
|
662
|
-
key={`g-${
|
|
766
|
+
key={`g-${bucketStartMs + x * bucketMs}`}
|
|
663
767
|
className="timeSeriesGridline"
|
|
664
768
|
x1={x}
|
|
665
769
|
x2={x}
|
|
@@ -674,9 +778,10 @@ export default function App() {
|
|
|
674
778
|
const h = barHeight(v ?? 0, scaleMax, chartHeight);
|
|
675
779
|
if (!h) return null;
|
|
676
780
|
const barX = i + barInset;
|
|
781
|
+
const bucketMsAt = bucketStartMs + i * bucketMs;
|
|
677
782
|
return (
|
|
678
783
|
<rect
|
|
679
|
-
key={`b-${
|
|
784
|
+
key={`b-${bucketMsAt}`}
|
|
680
785
|
className="timeSeriesBarBaseline"
|
|
681
786
|
x={barX}
|
|
682
787
|
y={baselineY - h}
|
|
@@ -691,9 +796,10 @@ export default function App() {
|
|
|
691
796
|
const h = barHeight(v ?? 0, scaleMax, chartHeight);
|
|
692
797
|
if (!h) return null;
|
|
693
798
|
const barX = i + barInset;
|
|
799
|
+
const bucketMsAt = bucketStartMs + i * bucketMs;
|
|
694
800
|
return (
|
|
695
801
|
<rect
|
|
696
|
-
key={
|
|
802
|
+
key={`${row.overlayId}-${bucketMsAt}`}
|
|
697
803
|
className="timeSeriesBar"
|
|
698
804
|
x={barX}
|
|
699
805
|
y={baselineY - h}
|
|
@@ -737,6 +843,10 @@ export default function App() {
|
|
|
737
843
|
<div className="kvKey">CURRENT TOOL</div>
|
|
738
844
|
<div className="kvVal mono">{data.mainSession.currentTool}</div>
|
|
739
845
|
</div>
|
|
846
|
+
<div className="kvRow">
|
|
847
|
+
<div className="kvKey">CURRENT MODEL</div>
|
|
848
|
+
<div className="kvVal mono">{data.mainSession.currentModel}</div>
|
|
849
|
+
</div>
|
|
740
850
|
<div className="kvRow">
|
|
741
851
|
<div className="kvKey">LAST UPDATED</div>
|
|
742
852
|
<div className="kvVal">{data.mainSession.lastUpdatedLabel}</div>
|
|
@@ -813,6 +923,7 @@ export default function App() {
|
|
|
813
923
|
<tr>
|
|
814
924
|
<th>DESCRIPTION</th>
|
|
815
925
|
<th>AGENT</th>
|
|
926
|
+
<th>LAST MODEL</th>
|
|
816
927
|
<th>STATUS</th>
|
|
817
928
|
<th>TOOL CALLS</th>
|
|
818
929
|
<th>LAST TOOL</th>
|
|
@@ -827,12 +938,13 @@ export default function App() {
|
|
|
827
938
|
{t.subline ? <div className="taskSub mono">{t.subline}</div> : null}
|
|
828
939
|
</td>
|
|
829
940
|
<td className="mono">{t.agent}</td>
|
|
941
|
+
<td className="mono">{t.lastModel}</td>
|
|
830
942
|
<td>
|
|
831
943
|
<span className={`pill pill-${statusTone(t.status)}`}>{t.status}</span>
|
|
832
944
|
</td>
|
|
833
945
|
<td className="mono">{t.toolCalls}</td>
|
|
834
946
|
<td className="mono">{t.lastTool}</td>
|
|
835
|
-
<td className="mono muted">{t.status
|
|
947
|
+
<td className="mono muted">{formatBackgroundTaskTimelineCell(t.status, t.timeline)}</td>
|
|
836
948
|
</tr>
|
|
837
949
|
))}
|
|
838
950
|
</tbody>
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { toDashboardPayload } from "./App"
|
|
3
|
+
|
|
4
|
+
describe('toDashboardPayload', () => {
|
|
5
|
+
it('should preserve planProgress.steps from server JSON', () => {
|
|
6
|
+
// #given: server JSON with planProgress.steps
|
|
7
|
+
const serverJson = {
|
|
8
|
+
mainSession: {
|
|
9
|
+
agent: "sisyphus",
|
|
10
|
+
currentTool: "dashboard_start",
|
|
11
|
+
currentModel: "anthropic/claude-opus-4-5",
|
|
12
|
+
lastUpdatedLabel: "just now",
|
|
13
|
+
session: "test-session",
|
|
14
|
+
statusPill: "busy",
|
|
15
|
+
},
|
|
16
|
+
planProgress: {
|
|
17
|
+
name: "test-plan",
|
|
18
|
+
completed: 2,
|
|
19
|
+
total: 4,
|
|
20
|
+
path: "/tmp/test-plan.md",
|
|
21
|
+
statusPill: "in progress",
|
|
22
|
+
steps: [
|
|
23
|
+
{ checked: true, text: "First completed task" },
|
|
24
|
+
{ checked: true, text: "Second completed task" },
|
|
25
|
+
{ checked: false, text: "Third pending task" },
|
|
26
|
+
{ checked: false, text: "Fourth pending task" },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
backgroundTasks: [],
|
|
30
|
+
timeSeries: {
|
|
31
|
+
windowMs: 300000,
|
|
32
|
+
buckets: 150,
|
|
33
|
+
bucketMs: 2000,
|
|
34
|
+
anchorMs: 1640995200000,
|
|
35
|
+
serverNowMs: 1640995500000,
|
|
36
|
+
series: [
|
|
37
|
+
{
|
|
38
|
+
id: "overall-main",
|
|
39
|
+
label: "Overall",
|
|
40
|
+
tone: "muted",
|
|
41
|
+
values: new Array(150).fill(0),
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// #when: converting to dashboard payload
|
|
48
|
+
const payload = toDashboardPayload(serverJson)
|
|
49
|
+
|
|
50
|
+
// #then: planProgress.steps should be preserved with correct structure
|
|
51
|
+
expect(payload.planProgress.steps).toBeDefined()
|
|
52
|
+
expect(payload.planProgress.steps).toEqual([
|
|
53
|
+
{ checked: true, text: "First completed task" },
|
|
54
|
+
{ checked: true, text: "Second completed task" },
|
|
55
|
+
{ checked: false, text: "Third pending task" },
|
|
56
|
+
{ checked: false, text: "Fourth pending task" },
|
|
57
|
+
])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should handle missing or malformed planProgress.steps defensively', () => {
|
|
61
|
+
// #given: server JSON with malformed planProgress.steps
|
|
62
|
+
const serverJson = {
|
|
63
|
+
mainSession: {
|
|
64
|
+
agent: "sisyphus",
|
|
65
|
+
currentTool: "dashboard_start",
|
|
66
|
+
currentModel: "anthropic/claude-opus-4-5",
|
|
67
|
+
lastUpdatedLabel: "just now",
|
|
68
|
+
session: "test-session",
|
|
69
|
+
statusPill: "busy",
|
|
70
|
+
},
|
|
71
|
+
planProgress: {
|
|
72
|
+
name: "test-plan",
|
|
73
|
+
completed: 0,
|
|
74
|
+
total: 0,
|
|
75
|
+
path: "/tmp/test-plan.md",
|
|
76
|
+
statusPill: "not started",
|
|
77
|
+
steps: [
|
|
78
|
+
{ checked: true, text: "Valid step" },
|
|
79
|
+
{ checked: false }, // missing text
|
|
80
|
+
{ text: "Missing checked" }, // missing checked
|
|
81
|
+
"invalid string", // wrong type
|
|
82
|
+
null, // null value
|
|
83
|
+
{ checked: "not-boolean", text: "Invalid checked type" }, // wrong checked type
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
backgroundTasks: [],
|
|
87
|
+
timeSeries: {
|
|
88
|
+
windowMs: 300000,
|
|
89
|
+
buckets: 150,
|
|
90
|
+
bucketMs: 2000,
|
|
91
|
+
anchorMs: 1640995200000,
|
|
92
|
+
serverNowMs: 1640995500000,
|
|
93
|
+
series: [
|
|
94
|
+
{
|
|
95
|
+
id: "overall-main",
|
|
96
|
+
label: "Overall",
|
|
97
|
+
tone: "muted",
|
|
98
|
+
values: new Array(150).fill(0),
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// #when: converting to dashboard payload
|
|
105
|
+
const payload = toDashboardPayload(serverJson)
|
|
106
|
+
|
|
107
|
+
// #then: should only include valid steps, ignore malformed ones
|
|
108
|
+
expect(payload.planProgress.steps).toEqual([
|
|
109
|
+
{ checked: true, text: "Valid step" },
|
|
110
|
+
{ checked: false, text: "Missing checked" }, // default checked to false
|
|
111
|
+
{ checked: false, text: "Invalid checked type" }, // default checked to false for invalid boolean
|
|
112
|
+
])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('should handle non-array planProgress.steps', () => {
|
|
116
|
+
// #given: server JSON with non-array planProgress.steps
|
|
117
|
+
const serverJson = {
|
|
118
|
+
mainSession: {
|
|
119
|
+
agent: "sisyphus",
|
|
120
|
+
currentTool: "dashboard_start",
|
|
121
|
+
currentModel: "anthropic/claude-opus-4-5",
|
|
122
|
+
lastUpdatedLabel: "just now",
|
|
123
|
+
session: "test-session",
|
|
124
|
+
statusPill: "busy",
|
|
125
|
+
},
|
|
126
|
+
planProgress: {
|
|
127
|
+
name: "test-plan",
|
|
128
|
+
completed: 0,
|
|
129
|
+
total: 0,
|
|
130
|
+
path: "/tmp/test-plan.md",
|
|
131
|
+
statusPill: "not started",
|
|
132
|
+
steps: "not an array",
|
|
133
|
+
},
|
|
134
|
+
backgroundTasks: [],
|
|
135
|
+
timeSeries: {
|
|
136
|
+
windowMs: 300000,
|
|
137
|
+
buckets: 150,
|
|
138
|
+
bucketMs: 2000,
|
|
139
|
+
anchorMs: 1640995200000,
|
|
140
|
+
serverNowMs: 1640995500000,
|
|
141
|
+
series: [
|
|
142
|
+
{
|
|
143
|
+
id: "overall-main",
|
|
144
|
+
label: "Overall",
|
|
145
|
+
tone: "muted",
|
|
146
|
+
values: new Array(150).fill(0),
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// #when: converting to dashboard payload
|
|
153
|
+
const payload = toDashboardPayload(serverJson)
|
|
154
|
+
|
|
155
|
+
// #then: should handle non-array steps gracefully
|
|
156
|
+
expect(payload.planProgress.steps).toEqual([])
|
|
157
|
+
})
|
|
158
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { formatBackgroundTaskTimelineCell } from "./App"
|
|
3
|
+
|
|
4
|
+
describe('formatBackgroundTaskTimelineCell', () => {
|
|
5
|
+
it('should render "-" for queued regardless of timeline', () => {
|
|
6
|
+
// #given: queued status
|
|
7
|
+
const status = "queued"
|
|
8
|
+
|
|
9
|
+
// #when/#then
|
|
10
|
+
expect(formatBackgroundTaskTimelineCell(status, "")).toBe("-")
|
|
11
|
+
expect(formatBackgroundTaskTimelineCell(status, "2026-01-01T00:00:00Z: 2m")).toBe("-")
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('should render blank for unknown regardless of timeline', () => {
|
|
15
|
+
// #given: unknown status
|
|
16
|
+
const status = "unknown"
|
|
17
|
+
|
|
18
|
+
// #when/#then
|
|
19
|
+
expect(formatBackgroundTaskTimelineCell(status, "")).toBe("")
|
|
20
|
+
expect(formatBackgroundTaskTimelineCell(status, "2026-01-01T00:00:00Z: 2m")).toBe("")
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('should render timeline when present, otherwise "-" for other statuses', () => {
|
|
24
|
+
// #given: a non-queued, non-unknown status
|
|
25
|
+
const status = "running"
|
|
26
|
+
|
|
27
|
+
// #when/#then
|
|
28
|
+
expect(formatBackgroundTaskTimelineCell(status, "2026-01-01T00:00:00Z: 2m")).toBe("2026-01-01T00:00:00Z: 2m")
|
|
29
|
+
expect(formatBackgroundTaskTimelineCell(status, "")).toBe("-")
|
|
30
|
+
expect(formatBackgroundTaskTimelineCell(status, " ")).toBe("-")
|
|
31
|
+
})
|
|
32
|
+
})
|