@tenonhq/dovetail-dashboard 0.0.30 → 0.0.32
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/package.json +1 -1
- package/public/claude-plans.css +99 -4
- package/public/claude-plans.html +17 -4
- package/public/claude-plans.js +218 -6
- package/public/prompt-lints.css +142 -0
- package/public/prompt-lints.html +26 -0
- package/public/prompt-lints.js +174 -6
- package/server.js +142 -0
package/package.json
CHANGED
package/public/claude-plans.css
CHANGED
|
@@ -173,9 +173,16 @@
|
|
|
173
173
|
}
|
|
174
174
|
.cp-list-item:hover { background: var(--bg-subtle); }
|
|
175
175
|
.cp-list-item.active {
|
|
176
|
+
/* Selected plan: filled raised surface + brand-emerald bar + bold.
|
|
177
|
+
--brand flips to a bright emerald (#32b57f) in dark mode, so the bar
|
|
178
|
+
stays legible without using the unreadable deep green on dark grounds. */
|
|
176
179
|
background: var(--surface-raised);
|
|
177
|
-
border-left:
|
|
180
|
+
border-left: 4px solid var(--brand);
|
|
181
|
+
padding-left: 12px;
|
|
182
|
+
font-weight: var(--weight-semibold);
|
|
183
|
+
box-shadow: inset 0 0 0 1px var(--border-subtle);
|
|
178
184
|
}
|
|
185
|
+
.cp-list-item.active:hover { background: var(--surface-raised); }
|
|
179
186
|
.cp-list-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
|
180
187
|
.cp-list-title { font-weight: var(--weight-semibold); font-size: 13px; color: var(--fg); }
|
|
181
188
|
.cp-list-meta { font-size: 11px; color: var(--fg-muted); display: flex; gap: 8px; }
|
|
@@ -278,7 +285,7 @@
|
|
|
278
285
|
padding: 1px 6px;
|
|
279
286
|
border-radius: var(--radius-xs);
|
|
280
287
|
font-size: 13px;
|
|
281
|
-
color: var(--
|
|
288
|
+
color: var(--brand);
|
|
282
289
|
}
|
|
283
290
|
.cp-tab-panel pre {
|
|
284
291
|
background: var(--neutral-900);
|
|
@@ -292,7 +299,7 @@
|
|
|
292
299
|
.cp-tab-panel th, .cp-tab-panel td { border: 1px solid var(--border); padding: 7px 11px; }
|
|
293
300
|
.cp-tab-panel th { background: var(--bg-subtle); }
|
|
294
301
|
.cp-tab-panel a { color: var(--brand); }
|
|
295
|
-
.cp-tab-panel a:hover { color: var(--
|
|
302
|
+
.cp-tab-panel a:hover { color: var(--brand); }
|
|
296
303
|
.cp-tab-panel blockquote {
|
|
297
304
|
border-left: 3px solid var(--accent);
|
|
298
305
|
padding-left: 12px;
|
|
@@ -635,7 +642,7 @@
|
|
|
635
642
|
color: var(--fg-muted);
|
|
636
643
|
text-align: center;
|
|
637
644
|
}
|
|
638
|
-
.cp-c-step-done .cp-c-step-label { color: var(--
|
|
645
|
+
.cp-c-step-done .cp-c-step-label { color: var(--brand); }
|
|
639
646
|
.cp-c-step-active .cp-c-step-label { color: var(--fg); font-weight: var(--weight-bold); }
|
|
640
647
|
.cp-c-step-error .cp-c-step-label { color: var(--error-700); }
|
|
641
648
|
.cp-c-step-note {
|
|
@@ -1312,6 +1319,49 @@
|
|
|
1312
1319
|
background: color-mix(in srgb, var(--accent-fg) 20%, transparent);
|
|
1313
1320
|
}
|
|
1314
1321
|
|
|
1322
|
+
/* Merged-PR filter toggle + per-row PR state dots */
|
|
1323
|
+
.cp-rail-filters {
|
|
1324
|
+
padding: 8px 0 4px;
|
|
1325
|
+
}
|
|
1326
|
+
.cp-pr-toggle {
|
|
1327
|
+
display: inline-flex;
|
|
1328
|
+
align-items: center;
|
|
1329
|
+
gap: 6px;
|
|
1330
|
+
padding: 4px 12px;
|
|
1331
|
+
background: var(--surface);
|
|
1332
|
+
color: var(--fg);
|
|
1333
|
+
border: 1px solid var(--border);
|
|
1334
|
+
border-radius: 999px;
|
|
1335
|
+
font-family: inherit;
|
|
1336
|
+
font-size: 12px;
|
|
1337
|
+
font-weight: 500;
|
|
1338
|
+
cursor: pointer;
|
|
1339
|
+
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
|
|
1340
|
+
}
|
|
1341
|
+
.cp-pr-toggle:hover {
|
|
1342
|
+
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
|
1343
|
+
border-color: var(--accent);
|
|
1344
|
+
}
|
|
1345
|
+
.cp-pr-toggle--active {
|
|
1346
|
+
background: var(--accent);
|
|
1347
|
+
color: var(--accent-fg);
|
|
1348
|
+
border-color: var(--accent);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
.cp-pr-dot {
|
|
1352
|
+
display: inline-block;
|
|
1353
|
+
width: 8px;
|
|
1354
|
+
height: 8px;
|
|
1355
|
+
border-radius: 50%;
|
|
1356
|
+
flex: 0 0 auto;
|
|
1357
|
+
margin: 0 4px;
|
|
1358
|
+
background: var(--fg-muted);
|
|
1359
|
+
}
|
|
1360
|
+
.cp-pr-dot--merged { background: var(--success-700); }
|
|
1361
|
+
.cp-pr-dot--open { background: var(--warning-700); }
|
|
1362
|
+
.cp-pr-dot--closed { background: var(--danger); }
|
|
1363
|
+
.cp-pr-dot--unknown { background: var(--fg-muted); }
|
|
1364
|
+
|
|
1315
1365
|
/* ===========================================================================
|
|
1316
1366
|
* v2 — Stage Map, Questions tab, Dispatch dialog (Phase E)
|
|
1317
1367
|
* Lightweight additions on top of the existing token system.
|
|
@@ -1483,3 +1533,48 @@
|
|
|
1483
1533
|
margin-top: 10px;
|
|
1484
1534
|
font-size: 13px;
|
|
1485
1535
|
}
|
|
1536
|
+
|
|
1537
|
+
/* ─── Version history (History tab) ─────────────────────────────────────────
|
|
1538
|
+
* Snapshot rows with View (inline expand) + Restore. Uses semantic tokens so
|
|
1539
|
+
* it tracks light/dark; --brand flips to bright emerald in dark mode. */
|
|
1540
|
+
.cp-versions { display: flex; flex-direction: column; gap: 8px; }
|
|
1541
|
+
.cp-version {
|
|
1542
|
+
border: 1px solid var(--border);
|
|
1543
|
+
border-radius: var(--radius-md);
|
|
1544
|
+
background: var(--surface);
|
|
1545
|
+
}
|
|
1546
|
+
.cp-version--current {
|
|
1547
|
+
border-left: 4px solid var(--brand);
|
|
1548
|
+
background: var(--surface-raised);
|
|
1549
|
+
}
|
|
1550
|
+
.cp-version-head {
|
|
1551
|
+
display: flex;
|
|
1552
|
+
align-items: center;
|
|
1553
|
+
gap: 10px;
|
|
1554
|
+
padding: 10px 14px;
|
|
1555
|
+
}
|
|
1556
|
+
.cp-version-label { font-weight: var(--weight-semibold); }
|
|
1557
|
+
.cp-version-status { text-transform: none; }
|
|
1558
|
+
.cp-version-stamp { color: var(--fg-muted); font-size: 12px; }
|
|
1559
|
+
.cp-version-actions { margin-left: auto; display: flex; gap: 8px; }
|
|
1560
|
+
.cp-version-btn {
|
|
1561
|
+
border: 1px solid var(--border-strong);
|
|
1562
|
+
background: var(--surface);
|
|
1563
|
+
color: var(--fg);
|
|
1564
|
+
border-radius: var(--radius-sm);
|
|
1565
|
+
padding: 4px 12px;
|
|
1566
|
+
font-size: 13px;
|
|
1567
|
+
cursor: pointer;
|
|
1568
|
+
transition: background var(--dur-1) var(--ease-out);
|
|
1569
|
+
}
|
|
1570
|
+
.cp-version-btn:hover { background: var(--bg-subtle); }
|
|
1571
|
+
.cp-version-btn:disabled { opacity: 0.6; cursor: default; }
|
|
1572
|
+
.cp-version-btn--primary {
|
|
1573
|
+
border-color: var(--brand);
|
|
1574
|
+
color: var(--brand);
|
|
1575
|
+
font-weight: var(--weight-semibold);
|
|
1576
|
+
}
|
|
1577
|
+
.cp-version-body {
|
|
1578
|
+
border-top: 1px solid var(--border);
|
|
1579
|
+
padding: 14px;
|
|
1580
|
+
}
|
package/public/claude-plans.html
CHANGED
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
} catch (e) {}
|
|
16
16
|
})();
|
|
17
17
|
</script>
|
|
18
|
-
<link rel="stylesheet" href="tokens.css" />
|
|
19
|
-
<link rel="stylesheet" href="styles.css" />
|
|
20
|
-
<link rel="stylesheet" href="claude-plans.css" />
|
|
18
|
+
<link rel="stylesheet" href="/tokens.css" />
|
|
19
|
+
<link rel="stylesheet" href="/styles.css" />
|
|
20
|
+
<link rel="stylesheet" href="/claude-plans.css" />
|
|
21
21
|
</head>
|
|
22
22
|
<body class="cp-body">
|
|
23
23
|
<header>
|
|
@@ -84,6 +84,15 @@
|
|
|
84
84
|
hidden
|
|
85
85
|
>×</button>
|
|
86
86
|
</div>
|
|
87
|
+
<div class="cp-rail-filters">
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
class="cp-pr-toggle"
|
|
91
|
+
id="cp-merged-toggle"
|
|
92
|
+
aria-pressed="false"
|
|
93
|
+
title="Show only plans whose linked PR is merged"
|
|
94
|
+
>Merged PRs only</button>
|
|
95
|
+
</div>
|
|
87
96
|
<div class="cp-rail-empty" id="cp-rail-empty">
|
|
88
97
|
Waiting for plans. Run <code>push_plan</code> from a Claude Code
|
|
89
98
|
session to populate this list.
|
|
@@ -118,11 +127,15 @@
|
|
|
118
127
|
<button class="cp-tab" data-tab="questions">
|
|
119
128
|
Q&A <span class="cp-tab-count" id="cp-question-count">0</span>
|
|
120
129
|
</button>
|
|
130
|
+
<button class="cp-tab" data-tab="versions">
|
|
131
|
+
History <span class="cp-tab-count" id="cp-version-count">0</span>
|
|
132
|
+
</button>
|
|
121
133
|
</nav>
|
|
122
134
|
<div class="cp-tab-panel" id="cp-tab-plan"></div>
|
|
123
135
|
<div class="cp-tab-panel" id="cp-tab-prompts" hidden></div>
|
|
124
136
|
<div class="cp-tab-panel" id="cp-tab-artifacts" hidden></div>
|
|
125
137
|
<div class="cp-tab-panel" id="cp-tab-questions" hidden></div>
|
|
138
|
+
<div class="cp-tab-panel" id="cp-tab-versions" hidden></div>
|
|
126
139
|
</article>
|
|
127
140
|
</section>
|
|
128
141
|
</main>
|
|
@@ -130,6 +143,6 @@
|
|
|
130
143
|
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
|
|
131
144
|
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.5/dist/purify.min.js"></script>
|
|
132
145
|
<script src="https://cdn.jsdelivr.net/npm/mermaid@10.9.6/dist/mermaid.min.js"></script>
|
|
133
|
-
<script src="claude-plans.js"></script>
|
|
146
|
+
<script src="/claude-plans.js"></script>
|
|
134
147
|
</body>
|
|
135
148
|
</html>
|
package/public/claude-plans.js
CHANGED
|
@@ -37,7 +37,9 @@
|
|
|
37
37
|
selectedSlug: null,
|
|
38
38
|
activeTab: "plan",
|
|
39
39
|
query: "", // lowercased search query; "" means show all
|
|
40
|
-
topicFilter: null
|
|
40
|
+
topicFilter: null, // active topic label, null = no topic filter
|
|
41
|
+
prStatus: new Map(), // pr_url -> { state, merged } (live from gh, lazy)
|
|
42
|
+
mergedOnly: false // when true, show only plans with a merged PR
|
|
41
43
|
};
|
|
42
44
|
|
|
43
45
|
var els = {
|
|
@@ -62,15 +64,45 @@
|
|
|
62
64
|
promptsPanel: document.getElementById("cp-tab-prompts"),
|
|
63
65
|
questionsPanel: document.getElementById("cp-tab-questions"),
|
|
64
66
|
questionCount: document.getElementById("cp-question-count"),
|
|
67
|
+
versionsPanel: document.getElementById("cp-tab-versions"),
|
|
68
|
+
versionCount: document.getElementById("cp-version-count"),
|
|
65
69
|
stageMap: document.getElementById("cp-stage-map"),
|
|
66
70
|
tabs: document.querySelectorAll(".cp-tab"),
|
|
67
71
|
topics: document.getElementById("cp-topics"),
|
|
68
72
|
topicsCloud: document.getElementById("cp-topics-cloud"),
|
|
69
73
|
topicsCount: document.getElementById("cp-topics-count"),
|
|
70
74
|
topicsEmpty: document.getElementById("cp-topics-empty"),
|
|
71
|
-
topicsClear: document.getElementById("cp-topics-clear")
|
|
75
|
+
topicsClear: document.getElementById("cp-topics-clear"),
|
|
76
|
+
mergedToggle: document.getElementById("cp-merged-toggle")
|
|
72
77
|
};
|
|
73
78
|
|
|
79
|
+
// Merged === true only when gh resolved the plan's PR as MERGED.
|
|
80
|
+
function planPrMerged(plan) {
|
|
81
|
+
if (!plan.pr_url) return false;
|
|
82
|
+
var st = state.prStatus.get(plan.pr_url);
|
|
83
|
+
return !!(st && st.merged);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function planMatchesPr(plan) {
|
|
87
|
+
if (!state.mergedOnly) return true;
|
|
88
|
+
return planPrMerged(plan);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A small colored dot in the rail row reflecting the PR's merge state.
|
|
92
|
+
// Rendered only when the plan links a PR and gh has returned a status.
|
|
93
|
+
function prDotMarkup(plan) {
|
|
94
|
+
if (!plan.pr_url) return "";
|
|
95
|
+
var st = state.prStatus.get(plan.pr_url);
|
|
96
|
+
if (!st) return "";
|
|
97
|
+
var cls = "cp-pr-dot";
|
|
98
|
+
if (st.merged) cls += " cp-pr-dot--merged";
|
|
99
|
+
else if (st.state === "OPEN") cls += " cp-pr-dot--open";
|
|
100
|
+
else if (st.state === "CLOSED") cls += " cp-pr-dot--closed";
|
|
101
|
+
else cls += " cp-pr-dot--unknown";
|
|
102
|
+
var label = st.merged ? "merged" : (st.state || "unknown").toLowerCase();
|
|
103
|
+
return '<span class="' + cls + '" title="PR ' + label + '"></span>';
|
|
104
|
+
}
|
|
105
|
+
|
|
74
106
|
function planMatchesTopic(plan, topic) {
|
|
75
107
|
if (!topic) return true;
|
|
76
108
|
var cats = plan.categories;
|
|
@@ -118,9 +150,9 @@
|
|
|
118
150
|
var q = state.query;
|
|
119
151
|
var topic = state.topicFilter;
|
|
120
152
|
var all = allPlansSorted();
|
|
121
|
-
if (!q && !topic) return all;
|
|
153
|
+
if (!q && !topic && !state.mergedOnly) return all;
|
|
122
154
|
return all.filter(function (p) {
|
|
123
|
-
return planMatchesQuery(p, q) && planMatchesTopic(p, topic);
|
|
155
|
+
return planMatchesQuery(p, q) && planMatchesTopic(p, topic) && planMatchesPr(p);
|
|
124
156
|
});
|
|
125
157
|
}
|
|
126
158
|
|
|
@@ -431,7 +463,7 @@
|
|
|
431
463
|
renderTopics();
|
|
432
464
|
var plans = sortedPlans();
|
|
433
465
|
var totalPlans = state.plans.size;
|
|
434
|
-
var isFiltering = !!state.query || !!state.topicFilter;
|
|
466
|
+
var isFiltering = !!state.query || !!state.topicFilter || state.mergedOnly;
|
|
435
467
|
els.count.textContent = isFiltering
|
|
436
468
|
? plans.length + " / " + totalPlans
|
|
437
469
|
: String(totalPlans);
|
|
@@ -444,6 +476,7 @@
|
|
|
444
476
|
var pieces = [];
|
|
445
477
|
if (state.query) pieces.push('"' + state.query + '"');
|
|
446
478
|
if (state.topicFilter) pieces.push("topic “" + state.topicFilter + "”");
|
|
479
|
+
if (state.mergedOnly) pieces.push("merged PRs only");
|
|
447
480
|
els.railNoMatchQ.textContent = pieces.join(" + ");
|
|
448
481
|
}
|
|
449
482
|
} else {
|
|
@@ -464,6 +497,7 @@
|
|
|
464
497
|
li.innerHTML =
|
|
465
498
|
'<div class="cp-list-row">' +
|
|
466
499
|
' <span class="cp-list-title"></span>' +
|
|
500
|
+
prDotMarkup(plan) +
|
|
467
501
|
' <span class="cp-status-pill cp-status-' + plan.status + '">' + plan.status + '</span>' +
|
|
468
502
|
'</div>' +
|
|
469
503
|
'<div class="cp-list-meta">' +
|
|
@@ -598,6 +632,7 @@
|
|
|
598
632
|
// v2 surfaces — Stage Map strip + Questions tab.
|
|
599
633
|
renderStageMap(plan);
|
|
600
634
|
renderQuestions(plan);
|
|
635
|
+
renderVersions(plan);
|
|
601
636
|
|
|
602
637
|
addPlanSectionCopyBtns();
|
|
603
638
|
addTabsCopyAll(plan, artifacts, prompts);
|
|
@@ -714,6 +749,7 @@
|
|
|
714
749
|
els.artifactsPanel.hidden = tab !== "artifacts";
|
|
715
750
|
if (els.promptsPanel) els.promptsPanel.hidden = tab !== "prompts";
|
|
716
751
|
if (els.questionsPanel) els.questionsPanel.hidden = tab !== "questions";
|
|
752
|
+
if (els.versionsPanel) els.versionsPanel.hidden = tab !== "versions";
|
|
717
753
|
}
|
|
718
754
|
|
|
719
755
|
/* ─── v2 bidirectional pipeline (Phase E) ──────────────────────────────────
|
|
@@ -975,6 +1011,142 @@
|
|
|
975
1011
|
});
|
|
976
1012
|
}
|
|
977
1013
|
|
|
1014
|
+
function renderVersions(plan) {
|
|
1015
|
+
if (!els.versionsPanel) return;
|
|
1016
|
+
var panel = els.versionsPanel;
|
|
1017
|
+
panel.innerHTML = "";
|
|
1018
|
+
fetch("/api/claude-plans/" + encodeURIComponent(plan.slug) + "/versions")
|
|
1019
|
+
.then(function (r) { return r.json(); })
|
|
1020
|
+
.then(function (data) {
|
|
1021
|
+
// Bail if the user switched plans while this fetch was in flight —
|
|
1022
|
+
// otherwise a stale response would paint the wrong plan's history.
|
|
1023
|
+
if (state.selectedSlug !== plan.slug) return;
|
|
1024
|
+
var versions = (data && data.versions) || [];
|
|
1025
|
+
if (els.versionCount) els.versionCount.textContent = String(versions.length);
|
|
1026
|
+
if (versions.length === 0) {
|
|
1027
|
+
var empty = document.createElement("div");
|
|
1028
|
+
empty.className = "cp-detail-empty";
|
|
1029
|
+
empty.style.padding = "40px 0";
|
|
1030
|
+
empty.textContent =
|
|
1031
|
+
"No prior versions yet. A snapshot is saved automatically whenever a " +
|
|
1032
|
+
"push changes this plan's content.";
|
|
1033
|
+
panel.appendChild(empty);
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
var list = document.createElement("div");
|
|
1037
|
+
list.className = "cp-versions";
|
|
1038
|
+
// Synthetic "current" row at the top for orientation (not restorable).
|
|
1039
|
+
list.appendChild(versionRow({
|
|
1040
|
+
version: null,
|
|
1041
|
+
saved_at: plan.updated_at,
|
|
1042
|
+
title: plan.title,
|
|
1043
|
+
status: plan.status
|
|
1044
|
+
}, plan, true));
|
|
1045
|
+
versions.forEach(function (v) {
|
|
1046
|
+
list.appendChild(versionRow(v, plan, false));
|
|
1047
|
+
});
|
|
1048
|
+
panel.appendChild(list);
|
|
1049
|
+
})
|
|
1050
|
+
.catch(function () {
|
|
1051
|
+
panel.innerHTML = "";
|
|
1052
|
+
var err = document.createElement("div");
|
|
1053
|
+
err.className = "cp-detail-empty";
|
|
1054
|
+
err.style.padding = "40px 0";
|
|
1055
|
+
err.textContent = "Could not load version history.";
|
|
1056
|
+
panel.appendChild(err);
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function versionRow(v, plan, isCurrent) {
|
|
1061
|
+
var row = document.createElement("div");
|
|
1062
|
+
row.className = "cp-version" + (isCurrent ? " cp-version--current" : "");
|
|
1063
|
+
|
|
1064
|
+
var head = document.createElement("div");
|
|
1065
|
+
head.className = "cp-version-head";
|
|
1066
|
+
|
|
1067
|
+
var label = document.createElement("span");
|
|
1068
|
+
label.className = "cp-version-label";
|
|
1069
|
+
label.textContent = isCurrent ? "Current" : "v" + v.version;
|
|
1070
|
+
head.appendChild(label);
|
|
1071
|
+
|
|
1072
|
+
if (v.status) {
|
|
1073
|
+
var status = document.createElement("span");
|
|
1074
|
+
status.className = "cp-status-pill cp-version-status";
|
|
1075
|
+
status.textContent = v.status;
|
|
1076
|
+
head.appendChild(status);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
var stamp = document.createElement("span");
|
|
1080
|
+
stamp.className = "cp-stamp cp-version-stamp";
|
|
1081
|
+
stamp.textContent = v.saved_at ? fmtTime(v.saved_at) : "";
|
|
1082
|
+
head.appendChild(stamp);
|
|
1083
|
+
|
|
1084
|
+
var actions = document.createElement("span");
|
|
1085
|
+
actions.className = "cp-version-actions";
|
|
1086
|
+
if (!isCurrent) {
|
|
1087
|
+
var viewBtn = document.createElement("button");
|
|
1088
|
+
viewBtn.type = "button";
|
|
1089
|
+
viewBtn.className = "cp-version-btn";
|
|
1090
|
+
viewBtn.textContent = "View";
|
|
1091
|
+
var body = null;
|
|
1092
|
+
viewBtn.addEventListener("click", function () {
|
|
1093
|
+
if (body && body.parentNode) {
|
|
1094
|
+
body.parentNode.removeChild(body);
|
|
1095
|
+
body = null;
|
|
1096
|
+
viewBtn.textContent = "View";
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
viewBtn.textContent = "Hide";
|
|
1100
|
+
body = document.createElement("div");
|
|
1101
|
+
body.className = "cp-version-body cp-tab-panel";
|
|
1102
|
+
row.appendChild(body);
|
|
1103
|
+
fetch("/api/claude-plans/" + encodeURIComponent(plan.slug) + "/versions/" + v.version)
|
|
1104
|
+
.then(function (r) { return r.json(); })
|
|
1105
|
+
.then(function (snap) {
|
|
1106
|
+
var p = snap && snap.plan;
|
|
1107
|
+
if (!p) { body.textContent = "Snapshot unavailable."; return; }
|
|
1108
|
+
if (p.content_html && window.DOMPurify) {
|
|
1109
|
+
body.innerHTML = window.DOMPurify.sanitize(p.content_html);
|
|
1110
|
+
} else {
|
|
1111
|
+
renderMarkdown(p.content_md || "", body);
|
|
1112
|
+
}
|
|
1113
|
+
})
|
|
1114
|
+
.catch(function () { body.textContent = "Could not load snapshot."; });
|
|
1115
|
+
});
|
|
1116
|
+
actions.appendChild(viewBtn);
|
|
1117
|
+
|
|
1118
|
+
var restoreBtn = document.createElement("button");
|
|
1119
|
+
restoreBtn.type = "button";
|
|
1120
|
+
restoreBtn.className = "cp-version-btn cp-version-btn--primary";
|
|
1121
|
+
restoreBtn.textContent = "Restore";
|
|
1122
|
+
restoreBtn.addEventListener("click", function () {
|
|
1123
|
+
restoreVersion(plan.slug, v.version, restoreBtn);
|
|
1124
|
+
});
|
|
1125
|
+
actions.appendChild(restoreBtn);
|
|
1126
|
+
}
|
|
1127
|
+
head.appendChild(actions);
|
|
1128
|
+
row.appendChild(head);
|
|
1129
|
+
return row;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function restoreVersion(slug, version, btn) {
|
|
1133
|
+
if (btn) { btn.disabled = true; btn.textContent = "Restoring…"; }
|
|
1134
|
+
fetch("/api/claude-plans/" + encodeURIComponent(slug) + "/versions/" + version + "/restore", {
|
|
1135
|
+
method: "POST"
|
|
1136
|
+
})
|
|
1137
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, json: j }; }); })
|
|
1138
|
+
.then(function (res) {
|
|
1139
|
+
if (!res.ok) throw new Error((res.json && res.json.error) || "restore failed");
|
|
1140
|
+
// The plan-file write fires an SSE plan:upsert → renderDetail →
|
|
1141
|
+
// renderVersions refreshes the list automatically.
|
|
1142
|
+
showToast("Restored v" + version + " as the current plan.");
|
|
1143
|
+
})
|
|
1144
|
+
.catch(function (e) {
|
|
1145
|
+
if (btn) { btn.disabled = false; btn.textContent = "Restore"; }
|
|
1146
|
+
showToast("Restore failed: " + e.message);
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
|
|
978
1150
|
function submitAnswer(slug, questionId, answer) {
|
|
979
1151
|
v2Post(slug, "answers", { question_id: questionId, answer: answer }).then(function () {
|
|
980
1152
|
showToast("Answer recorded.");
|
|
@@ -1154,6 +1326,41 @@
|
|
|
1154
1326
|
});
|
|
1155
1327
|
}
|
|
1156
1328
|
|
|
1329
|
+
var prStatusDebounce = null;
|
|
1330
|
+
|
|
1331
|
+
// Resolve live PR merge state from the server (which shells out to gh) and
|
|
1332
|
+
// rerender so dots + the merged filter reflect reality. Best-effort: any
|
|
1333
|
+
// failure leaves prStatus as-is and the toggle simply matches nothing.
|
|
1334
|
+
function loadPrStatus() {
|
|
1335
|
+
return fetch("/api/claude-plans/pr-status")
|
|
1336
|
+
.then(function (r) { return r.json(); })
|
|
1337
|
+
.then(function (data) {
|
|
1338
|
+
var statuses = data && data.statuses ? data.statuses : {};
|
|
1339
|
+
Object.keys(statuses).forEach(function (url) {
|
|
1340
|
+
state.prStatus.set(url, statuses[url]);
|
|
1341
|
+
});
|
|
1342
|
+
renderRail();
|
|
1343
|
+
})
|
|
1344
|
+
.catch(function (err) {
|
|
1345
|
+
console.warn("[claude-plans] failed to load PR status:", err);
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function schedulePrStatusRefresh() {
|
|
1350
|
+
if (prStatusDebounce) clearTimeout(prStatusDebounce);
|
|
1351
|
+
prStatusDebounce = setTimeout(loadPrStatus, 500);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
function setupMergedToggle() {
|
|
1355
|
+
if (!els.mergedToggle) return;
|
|
1356
|
+
els.mergedToggle.addEventListener("click", function () {
|
|
1357
|
+
state.mergedOnly = !state.mergedOnly;
|
|
1358
|
+
els.mergedToggle.setAttribute("aria-pressed", state.mergedOnly ? "true" : "false");
|
|
1359
|
+
els.mergedToggle.classList.toggle("cp-pr-toggle--active", state.mergedOnly);
|
|
1360
|
+
renderRail();
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1157
1364
|
async function loadInitial() {
|
|
1158
1365
|
try {
|
|
1159
1366
|
var res = await fetch("/api/claude-plans");
|
|
@@ -1185,6 +1392,7 @@
|
|
|
1185
1392
|
.catch(function () { /* ignore */ });
|
|
1186
1393
|
}));
|
|
1187
1394
|
renderRail();
|
|
1395
|
+
loadPrStatus();
|
|
1188
1396
|
if (!state.selectedSlug) {
|
|
1189
1397
|
var paramSlug = new URLSearchParams(window.location.search).get("plan");
|
|
1190
1398
|
var sorted = sortedPlans();
|
|
@@ -1198,7 +1406,10 @@
|
|
|
1198
1406
|
function startStream() {
|
|
1199
1407
|
var es = new EventSource("/api/claude-plans/stream");
|
|
1200
1408
|
es.addEventListener("plan:upsert", function (e) {
|
|
1201
|
-
try {
|
|
1409
|
+
try {
|
|
1410
|
+
upsertPlan(JSON.parse(e.data).plan);
|
|
1411
|
+
schedulePrStatusRefresh();
|
|
1412
|
+
} catch (_) {}
|
|
1202
1413
|
});
|
|
1203
1414
|
es.addEventListener("plan:delete", function (e) {
|
|
1204
1415
|
try { removePlan(JSON.parse(e.data).slug); } catch (_) {}
|
|
@@ -1262,6 +1473,7 @@
|
|
|
1262
1473
|
setupTheme();
|
|
1263
1474
|
setupSearch();
|
|
1264
1475
|
setupTopics();
|
|
1476
|
+
setupMergedToggle();
|
|
1265
1477
|
setActiveTab("plan");
|
|
1266
1478
|
loadLintsBadge();
|
|
1267
1479
|
loadInitial().then(startStream);
|
package/public/prompt-lints.css
CHANGED
|
@@ -21,6 +21,148 @@
|
|
|
21
21
|
font-size: 12px;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/* ─── Filter toolbar ──────────────────────────────────────────────────────── */
|
|
25
|
+
|
|
26
|
+
.pl-filters {
|
|
27
|
+
display: flex;
|
|
28
|
+
flex-wrap: wrap;
|
|
29
|
+
align-items: center;
|
|
30
|
+
gap: 16px;
|
|
31
|
+
margin-bottom: 14px;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.pl-filter-group {
|
|
35
|
+
display: inline-flex;
|
|
36
|
+
flex-wrap: wrap;
|
|
37
|
+
align-items: center;
|
|
38
|
+
gap: 6px;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.pl-filter-group:empty {
|
|
42
|
+
display: none;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Segmented control (status) */
|
|
46
|
+
.pl-seg {
|
|
47
|
+
padding: 4px 12px;
|
|
48
|
+
background: var(--surface);
|
|
49
|
+
color: var(--fg);
|
|
50
|
+
border: 1px solid var(--border);
|
|
51
|
+
font-family: inherit;
|
|
52
|
+
font-size: 13px;
|
|
53
|
+
font-weight: 500;
|
|
54
|
+
cursor: pointer;
|
|
55
|
+
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
|
|
56
|
+
}
|
|
57
|
+
.pl-seg:first-child { border-radius: 999px 0 0 999px; }
|
|
58
|
+
.pl-seg:last-child { border-radius: 0 999px 999px 0; }
|
|
59
|
+
.pl-seg + .pl-seg { border-left: none; }
|
|
60
|
+
.pl-seg:hover {
|
|
61
|
+
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
|
62
|
+
}
|
|
63
|
+
.pl-seg--active {
|
|
64
|
+
background: var(--accent);
|
|
65
|
+
color: var(--accent-fg);
|
|
66
|
+
border-color: var(--accent);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* Toggle chips (score buckets, sources) */
|
|
70
|
+
.pl-chip {
|
|
71
|
+
display: inline-flex;
|
|
72
|
+
align-items: baseline;
|
|
73
|
+
gap: 6px;
|
|
74
|
+
padding: 4px 10px;
|
|
75
|
+
background: var(--surface);
|
|
76
|
+
color: var(--fg);
|
|
77
|
+
border: 1px solid var(--border);
|
|
78
|
+
border-radius: 999px;
|
|
79
|
+
font-family: inherit;
|
|
80
|
+
font-size: 13px;
|
|
81
|
+
font-weight: 500;
|
|
82
|
+
cursor: pointer;
|
|
83
|
+
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
|
|
84
|
+
}
|
|
85
|
+
.pl-chip:hover {
|
|
86
|
+
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
|
87
|
+
border-color: var(--accent);
|
|
88
|
+
}
|
|
89
|
+
.pl-chip--active {
|
|
90
|
+
background: var(--accent);
|
|
91
|
+
color: var(--accent-fg);
|
|
92
|
+
border-color: var(--accent);
|
|
93
|
+
}
|
|
94
|
+
.pl-chip-count {
|
|
95
|
+
display: inline-flex;
|
|
96
|
+
align-items: center;
|
|
97
|
+
justify-content: center;
|
|
98
|
+
min-width: 18px;
|
|
99
|
+
padding: 0 5px;
|
|
100
|
+
background: color-mix(in srgb, currentColor 12%, transparent);
|
|
101
|
+
border-radius: 999px;
|
|
102
|
+
font-size: 10px;
|
|
103
|
+
font-weight: 700;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.pl-search {
|
|
107
|
+
position: relative;
|
|
108
|
+
display: inline-flex;
|
|
109
|
+
align-items: center;
|
|
110
|
+
margin-left: auto;
|
|
111
|
+
}
|
|
112
|
+
.pl-search-input {
|
|
113
|
+
width: 220px;
|
|
114
|
+
padding: 5px 28px 5px 10px;
|
|
115
|
+
background: var(--surface);
|
|
116
|
+
color: var(--fg);
|
|
117
|
+
border: 1px solid var(--border);
|
|
118
|
+
border-radius: 999px;
|
|
119
|
+
font-family: inherit;
|
|
120
|
+
font-size: 13px;
|
|
121
|
+
}
|
|
122
|
+
.pl-search-input::placeholder { color: var(--fg-muted); }
|
|
123
|
+
.pl-search-input:focus {
|
|
124
|
+
outline: none;
|
|
125
|
+
border-color: var(--accent);
|
|
126
|
+
}
|
|
127
|
+
.pl-search-input::-webkit-search-cancel-button { -webkit-appearance: none; }
|
|
128
|
+
.pl-search-clear {
|
|
129
|
+
position: absolute;
|
|
130
|
+
right: 8px;
|
|
131
|
+
background: none;
|
|
132
|
+
border: none;
|
|
133
|
+
color: var(--fg-muted);
|
|
134
|
+
font-size: 16px;
|
|
135
|
+
line-height: 1;
|
|
136
|
+
cursor: pointer;
|
|
137
|
+
}
|
|
138
|
+
.pl-search-clear:hover { color: var(--fg); }
|
|
139
|
+
|
|
140
|
+
/* Status badges */
|
|
141
|
+
.pl-status {
|
|
142
|
+
display: inline-block;
|
|
143
|
+
border-radius: 999px;
|
|
144
|
+
padding: 1px 8px;
|
|
145
|
+
font-size: 11px;
|
|
146
|
+
font-weight: 600;
|
|
147
|
+
white-space: nowrap;
|
|
148
|
+
}
|
|
149
|
+
.pl-status--needs {
|
|
150
|
+
background: color-mix(in srgb, var(--danger) 14%, transparent);
|
|
151
|
+
color: var(--danger);
|
|
152
|
+
}
|
|
153
|
+
.pl-status--good {
|
|
154
|
+
background: color-mix(in srgb, var(--success-700) 14%, transparent);
|
|
155
|
+
color: var(--success-700);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.pl-no-match {
|
|
159
|
+
padding: 40px 20px;
|
|
160
|
+
text-align: center;
|
|
161
|
+
color: var(--fg-muted);
|
|
162
|
+
border: 1px dashed var(--border);
|
|
163
|
+
border-radius: 8px;
|
|
164
|
+
}
|
|
165
|
+
|
|
24
166
|
.pl-summary {
|
|
25
167
|
color: var(--fg-subtle);
|
|
26
168
|
font-size: 13px;
|
package/public/prompt-lints.html
CHANGED
|
@@ -42,15 +42,41 @@
|
|
|
42
42
|
Turn-0 prompt-lint observations recorded by the <code>UserPromptSubmit</code> hook and
|
|
43
43
|
<code>push_lint_event</code>. Newest first. Updates live.
|
|
44
44
|
</div>
|
|
45
|
+
<div class="pl-filters" id="pl-filters">
|
|
46
|
+
<div class="pl-filter-group" role="group" aria-label="Status">
|
|
47
|
+
<button type="button" class="pl-seg pl-seg--active" data-status="all">All</button>
|
|
48
|
+
<button type="button" class="pl-seg" data-status="needs-improvement">Needs improvement</button>
|
|
49
|
+
<button type="button" class="pl-seg" data-status="good">Good</button>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="pl-filter-group" role="group" aria-label="Score">
|
|
52
|
+
<button type="button" class="pl-chip" data-bucket="low">Low <40</button>
|
|
53
|
+
<button type="button" class="pl-chip" data-bucket="mid">Mid 40–69</button>
|
|
54
|
+
<button type="button" class="pl-chip" data-bucket="high">High ≥70</button>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="pl-filter-group" id="pl-sources" role="group" aria-label="Source"></div>
|
|
57
|
+
<div class="pl-search">
|
|
58
|
+
<input
|
|
59
|
+
type="search"
|
|
60
|
+
class="pl-search-input"
|
|
61
|
+
id="pl-search-input"
|
|
62
|
+
placeholder="Search excerpt / tags…"
|
|
63
|
+
autocomplete="off"
|
|
64
|
+
spellcheck="false"
|
|
65
|
+
/>
|
|
66
|
+
<button class="pl-search-clear" id="pl-search-clear" aria-label="Clear search" hidden>×</button>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
45
69
|
<div class="pl-summary" id="pl-summary"></div>
|
|
46
70
|
<div class="pl-empty" id="pl-empty">
|
|
47
71
|
No lint events yet. Low-scoring prompts will appear here as you work.
|
|
48
72
|
</div>
|
|
73
|
+
<div class="pl-no-match" id="pl-no-match" hidden>No events match the current filters.</div>
|
|
49
74
|
<table class="pl-table" id="pl-table" hidden>
|
|
50
75
|
<thead>
|
|
51
76
|
<tr>
|
|
52
77
|
<th>When</th>
|
|
53
78
|
<th>Score</th>
|
|
79
|
+
<th>Status</th>
|
|
54
80
|
<th>Missing tags</th>
|
|
55
81
|
<th>Source</th>
|
|
56
82
|
<th>Prompt excerpt</th>
|
package/public/prompt-lints.js
CHANGED
|
@@ -6,12 +6,22 @@
|
|
|
6
6
|
|
|
7
7
|
var events = new Map(); // id -> event
|
|
8
8
|
|
|
9
|
+
// Active filters. status: "all" | "needs-improvement" | "good".
|
|
10
|
+
// buckets: map of "low"/"mid"/"high" -> true (multi-select, empty = all).
|
|
11
|
+
// source: a single source string, or null = all. query: lowercased substring.
|
|
12
|
+
var filters = { status: "all", buckets: {}, source: null, query: "" };
|
|
13
|
+
|
|
9
14
|
var els = {
|
|
10
15
|
tbody: document.getElementById("pl-tbody"),
|
|
11
16
|
table: document.getElementById("pl-table"),
|
|
12
17
|
empty: document.getElementById("pl-empty"),
|
|
18
|
+
noMatch: document.getElementById("pl-no-match"),
|
|
13
19
|
summary: document.getElementById("pl-summary"),
|
|
14
|
-
storage: document.getElementById("cp-storage")
|
|
20
|
+
storage: document.getElementById("cp-storage"),
|
|
21
|
+
filters: document.getElementById("pl-filters"),
|
|
22
|
+
sources: document.getElementById("pl-sources"),
|
|
23
|
+
searchInput: document.getElementById("pl-search-input"),
|
|
24
|
+
searchClear: document.getElementById("pl-search-clear")
|
|
15
25
|
};
|
|
16
26
|
|
|
17
27
|
// Bound the initial load — the global lint log is append-only and can grow
|
|
@@ -32,6 +42,38 @@
|
|
|
32
42
|
return "pl-score-high";
|
|
33
43
|
}
|
|
34
44
|
|
|
45
|
+
function scoreBucket(score) {
|
|
46
|
+
if (typeof score !== "number") return null;
|
|
47
|
+
if (score < 40) return "low";
|
|
48
|
+
if (score < 70) return "mid";
|
|
49
|
+
return "high";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Classify an event as "needs-improvement" / "good" / "unknown". Prefer the
|
|
53
|
+
// hook's outcome ("nag" = under-specified, "format" = strong); fall back to
|
|
54
|
+
// score-vs-threshold for legacy events recorded before outcome existed.
|
|
55
|
+
function lintStatus(e) {
|
|
56
|
+
if (e.outcome === "nag") return "needs-improvement";
|
|
57
|
+
if (e.outcome === "format") return "good";
|
|
58
|
+
if (typeof e.score !== "number") return "unknown";
|
|
59
|
+
var threshold = typeof e.threshold === "number" ? e.threshold : 50;
|
|
60
|
+
if (e.score < threshold) return "needs-improvement";
|
|
61
|
+
if (e.score >= 70) return "good";
|
|
62
|
+
return "unknown";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function matchesFilters(e) {
|
|
66
|
+
if (filters.status !== "all" && lintStatus(e) !== filters.status) return false;
|
|
67
|
+
var activeBuckets = Object.keys(filters.buckets);
|
|
68
|
+
if (activeBuckets.length && filters.buckets[scoreBucket(e.score)] !== true) return false;
|
|
69
|
+
if (filters.source !== null && (e.source || "") !== filters.source) return false;
|
|
70
|
+
if (filters.query) {
|
|
71
|
+
var hay = ((e.prompt_excerpt || "") + " " + (e.missing || []).join(" ")).toLowerCase();
|
|
72
|
+
if (hay.indexOf(filters.query) === -1) return false;
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
35
77
|
function sorted() {
|
|
36
78
|
return Array.from(events.values()).sort(function (a, b) {
|
|
37
79
|
var c = (b.timestamp || "").localeCompare(a.timestamp || "");
|
|
@@ -40,6 +82,10 @@
|
|
|
40
82
|
});
|
|
41
83
|
}
|
|
42
84
|
|
|
85
|
+
function filtered() {
|
|
86
|
+
return sorted().filter(matchesFilters);
|
|
87
|
+
}
|
|
88
|
+
|
|
43
89
|
function renderTagCells(missing) {
|
|
44
90
|
var td = document.createElement("td");
|
|
45
91
|
td.className = "pl-missing";
|
|
@@ -71,6 +117,17 @@
|
|
|
71
117
|
if (typeof e.threshold === "number") score.title = "threshold " + e.threshold + "%";
|
|
72
118
|
tr.appendChild(score);
|
|
73
119
|
|
|
120
|
+
var statusCell = document.createElement("td");
|
|
121
|
+
var status = lintStatus(e);
|
|
122
|
+
if (status === "needs-improvement") {
|
|
123
|
+
statusCell.innerHTML = '<span class="pl-status pl-status--needs">Needs work</span>';
|
|
124
|
+
} else if (status === "good") {
|
|
125
|
+
statusCell.innerHTML = '<span class="pl-status pl-status--good">Good</span>';
|
|
126
|
+
} else {
|
|
127
|
+
statusCell.textContent = "—";
|
|
128
|
+
}
|
|
129
|
+
tr.appendChild(statusCell);
|
|
130
|
+
|
|
74
131
|
tr.appendChild(renderTagCells(e.missing));
|
|
75
132
|
|
|
76
133
|
var source = document.createElement("td");
|
|
@@ -87,23 +144,131 @@
|
|
|
87
144
|
return tr;
|
|
88
145
|
}
|
|
89
146
|
|
|
147
|
+
function isFiltering() {
|
|
148
|
+
return (
|
|
149
|
+
filters.status !== "all" ||
|
|
150
|
+
Object.keys(filters.buckets).length > 0 ||
|
|
151
|
+
filters.source !== null ||
|
|
152
|
+
!!filters.query
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
90
156
|
function render(newId) {
|
|
91
|
-
var
|
|
157
|
+
var total = events.size;
|
|
158
|
+
var list = filtered();
|
|
92
159
|
els.tbody.innerHTML = "";
|
|
93
|
-
|
|
160
|
+
|
|
161
|
+
if (!total) {
|
|
94
162
|
els.empty.hidden = false;
|
|
163
|
+
els.noMatch.hidden = true;
|
|
95
164
|
els.table.hidden = true;
|
|
96
165
|
els.summary.textContent = "";
|
|
97
166
|
return;
|
|
98
167
|
}
|
|
168
|
+
|
|
99
169
|
els.empty.hidden = true;
|
|
170
|
+
|
|
171
|
+
if (!list.length) {
|
|
172
|
+
els.noMatch.hidden = false;
|
|
173
|
+
els.table.hidden = true;
|
|
174
|
+
els.summary.textContent = "0 of " + total + (total === 1 ? " event" : " events");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
els.noMatch.hidden = true;
|
|
100
179
|
els.table.hidden = false;
|
|
101
180
|
for (var i = 0; i < list.length; i++) {
|
|
102
181
|
els.tbody.appendChild(rowFor(list[i], list[i].id === newId));
|
|
103
182
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
183
|
+
|
|
184
|
+
var noun = total === 1 ? " event" : " events";
|
|
185
|
+
if (isFiltering()) {
|
|
186
|
+
els.summary.textContent = list.length + " of " + total + noun;
|
|
187
|
+
} else {
|
|
188
|
+
els.summary.textContent =
|
|
189
|
+
total + noun + (total >= PAGE_LIMIT ? " (latest " + PAGE_LIMIT + ")" : "");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* ─── Filter UI ─────────────────────────────────────────────────────────── */
|
|
194
|
+
|
|
195
|
+
function renderSources() {
|
|
196
|
+
if (!els.sources) return;
|
|
197
|
+
var counts = {};
|
|
198
|
+
events.forEach(function (e) {
|
|
199
|
+
var s = e.source || "—";
|
|
200
|
+
counts[s] = (counts[s] || 0) + 1;
|
|
201
|
+
});
|
|
202
|
+
var labels = Object.keys(counts).sort();
|
|
203
|
+
els.sources.innerHTML = "";
|
|
204
|
+
// A lone source carries no signal — hide the group until there's a choice.
|
|
205
|
+
if (labels.length < 2) return;
|
|
206
|
+
for (var i = 0; i < labels.length; i++) {
|
|
207
|
+
els.sources.appendChild(sourceChip(labels[i], counts[labels[i]]));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function sourceChip(label, count) {
|
|
212
|
+
var value = label === "—" ? "" : label;
|
|
213
|
+
var chip = document.createElement("button");
|
|
214
|
+
chip.type = "button";
|
|
215
|
+
chip.className = "pl-chip" + (filters.source === value ? " pl-chip--active" : "");
|
|
216
|
+
chip.appendChild(document.createTextNode(label + " "));
|
|
217
|
+
var c = document.createElement("span");
|
|
218
|
+
c.className = "pl-chip-count";
|
|
219
|
+
c.textContent = String(count);
|
|
220
|
+
chip.appendChild(c);
|
|
221
|
+
chip.addEventListener("click", function () {
|
|
222
|
+
filters.source = filters.source === value ? null : value;
|
|
223
|
+
renderSources();
|
|
224
|
+
render(null);
|
|
225
|
+
});
|
|
226
|
+
return chip;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function setupFilters() {
|
|
230
|
+
var segs = els.filters ? els.filters.querySelectorAll(".pl-seg") : [];
|
|
231
|
+
for (var i = 0; i < segs.length; i++) {
|
|
232
|
+
(function (btn) {
|
|
233
|
+
btn.addEventListener("click", function () {
|
|
234
|
+
filters.status = btn.getAttribute("data-status");
|
|
235
|
+
for (var j = 0; j < segs.length; j++) {
|
|
236
|
+
segs[j].classList.toggle("pl-seg--active", segs[j] === btn);
|
|
237
|
+
}
|
|
238
|
+
render(null);
|
|
239
|
+
});
|
|
240
|
+
})(segs[i]);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
var buckets = els.filters ? els.filters.querySelectorAll(".pl-chip[data-bucket]") : [];
|
|
244
|
+
for (var k = 0; k < buckets.length; k++) {
|
|
245
|
+
(function (btn) {
|
|
246
|
+
btn.addEventListener("click", function () {
|
|
247
|
+
var key = btn.getAttribute("data-bucket");
|
|
248
|
+
if (filters.buckets[key]) delete filters.buckets[key];
|
|
249
|
+
else filters.buckets[key] = true;
|
|
250
|
+
btn.classList.toggle("pl-chip--active", !!filters.buckets[key]);
|
|
251
|
+
render(null);
|
|
252
|
+
});
|
|
253
|
+
})(buckets[k]);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (els.searchInput) {
|
|
257
|
+
els.searchInput.addEventListener("input", function () {
|
|
258
|
+
filters.query = els.searchInput.value.trim().toLowerCase();
|
|
259
|
+
if (els.searchClear) els.searchClear.hidden = !els.searchInput.value;
|
|
260
|
+
render(null);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
if (els.searchClear) {
|
|
264
|
+
els.searchClear.addEventListener("click", function () {
|
|
265
|
+
els.searchInput.value = "";
|
|
266
|
+
filters.query = "";
|
|
267
|
+
els.searchClear.hidden = true;
|
|
268
|
+
render(null);
|
|
269
|
+
els.searchInput.focus();
|
|
270
|
+
});
|
|
271
|
+
}
|
|
107
272
|
}
|
|
108
273
|
|
|
109
274
|
function loadInitial() {
|
|
@@ -114,6 +279,7 @@
|
|
|
114
279
|
if (e && e.id) events.set(e.id, e);
|
|
115
280
|
});
|
|
116
281
|
if (els.storage && data.storage) els.storage.textContent = data.storage;
|
|
282
|
+
renderSources();
|
|
117
283
|
render(null);
|
|
118
284
|
})
|
|
119
285
|
.catch(function () { render(null); });
|
|
@@ -126,6 +292,7 @@
|
|
|
126
292
|
var data = JSON.parse(msg.data);
|
|
127
293
|
if (data && data.lint && data.lint.id) {
|
|
128
294
|
events.set(data.lint.id, data.lint);
|
|
295
|
+
renderSources();
|
|
129
296
|
render(data.lint.id);
|
|
130
297
|
}
|
|
131
298
|
} catch (_) {}
|
|
@@ -177,6 +344,7 @@
|
|
|
177
344
|
|
|
178
345
|
document.addEventListener("DOMContentLoaded", function () {
|
|
179
346
|
setupTheme();
|
|
347
|
+
setupFilters();
|
|
180
348
|
loadInitial().then(startStream);
|
|
181
349
|
});
|
|
182
350
|
})();
|
package/server.js
CHANGED
|
@@ -4,6 +4,7 @@ const axios = require("axios");
|
|
|
4
4
|
const { wrapper } = require("axios-cookiejar-support");
|
|
5
5
|
const { CookieJar } = require("tough-cookie");
|
|
6
6
|
const fs = require("fs");
|
|
7
|
+
const { execFile } = require("child_process");
|
|
7
8
|
const RateLimit = require("express-rate-limit");
|
|
8
9
|
|
|
9
10
|
// Everything resolves from CWD — run this from your Dovetail project directory
|
|
@@ -1075,6 +1076,100 @@ app.get("/api/claude-plans", function (req, res) {
|
|
|
1075
1076
|
}
|
|
1076
1077
|
});
|
|
1077
1078
|
|
|
1079
|
+
// Live GitHub merge state for plan-linked PRs. Plans store pr_url but no merge
|
|
1080
|
+
// status, so we resolve it on demand via the `gh` CLI (authenticated locally).
|
|
1081
|
+
// Results are cached: a MERGED state is terminal and cached forever; anything
|
|
1082
|
+
// else is cached briefly so an open PR that later merges is picked up.
|
|
1083
|
+
const PR_URL_RE = /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+$/;
|
|
1084
|
+
const PR_STATUS_TTL_MS = 60 * 1000;
|
|
1085
|
+
const prStatusCache = new Map(); // pr_url -> { state, merged, fetchedAt }
|
|
1086
|
+
|
|
1087
|
+
function ghPrState(prUrl) {
|
|
1088
|
+
return new Promise(function (resolve) {
|
|
1089
|
+
execFile(
|
|
1090
|
+
"gh",
|
|
1091
|
+
["pr", "view", prUrl, "--json", "state,mergedAt"],
|
|
1092
|
+
{ timeout: 5000 },
|
|
1093
|
+
function (err, stdout) {
|
|
1094
|
+
if (err) return resolve({ state: "unknown", merged: false });
|
|
1095
|
+
try {
|
|
1096
|
+
const data = JSON.parse(stdout);
|
|
1097
|
+
const state = data.state || "unknown";
|
|
1098
|
+
resolve({ state: state, merged: state === "MERGED" });
|
|
1099
|
+
} catch (e) {
|
|
1100
|
+
resolve({ state: "unknown", merged: false });
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
);
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function resolvePrStatus(prUrl) {
|
|
1108
|
+
const cached = prStatusCache.get(prUrl);
|
|
1109
|
+
const now = Date.now();
|
|
1110
|
+
if (cached && (cached.merged || now - cached.fetchedAt < PR_STATUS_TTL_MS)) {
|
|
1111
|
+
return Promise.resolve({ state: cached.state, merged: cached.merged });
|
|
1112
|
+
}
|
|
1113
|
+
return ghPrState(prUrl).then(function (result) {
|
|
1114
|
+
prStatusCache.set(prUrl, {
|
|
1115
|
+
state: result.state,
|
|
1116
|
+
merged: result.merged,
|
|
1117
|
+
fetchedAt: Date.now()
|
|
1118
|
+
});
|
|
1119
|
+
return result;
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Run promise-returning tasks with bounded concurrency so a large plan set
|
|
1124
|
+
// doesn't spawn dozens of `gh` processes at once.
|
|
1125
|
+
function mapWithConcurrency(items, limit, worker) {
|
|
1126
|
+
return new Promise(function (resolve) {
|
|
1127
|
+
const results = new Array(items.length);
|
|
1128
|
+
let index = 0;
|
|
1129
|
+
let completed = 0;
|
|
1130
|
+
if (!items.length) return resolve(results);
|
|
1131
|
+
function runNext() {
|
|
1132
|
+
if (index >= items.length) return;
|
|
1133
|
+
const i = index++;
|
|
1134
|
+
Promise.resolve(worker(items[i]))
|
|
1135
|
+
.then(function (r) { results[i] = r; })
|
|
1136
|
+
.catch(function () { results[i] = null; })
|
|
1137
|
+
.then(function () {
|
|
1138
|
+
completed++;
|
|
1139
|
+
if (completed === items.length) resolve(results);
|
|
1140
|
+
else runNext();
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
for (let k = 0; k < Math.min(limit, items.length); k++) runNext();
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// Place before "/api/claude-plans/:slug" so Express doesn't treat "pr-status"
|
|
1148
|
+
// as a slug. Always resolves to a 200 with whatever statuses we could gather —
|
|
1149
|
+
// a missing/unauthenticated `gh` degrades to {} rather than failing the page.
|
|
1150
|
+
app.get("/api/claude-plans/pr-status", function (req, res) {
|
|
1151
|
+
try {
|
|
1152
|
+
const urls = [];
|
|
1153
|
+
const seen = {};
|
|
1154
|
+
listClaudePlans().forEach(function (plan) {
|
|
1155
|
+
const url = plan && plan.pr_url;
|
|
1156
|
+
if (typeof url === "string" && PR_URL_RE.test(url) && !seen[url]) {
|
|
1157
|
+
seen[url] = true;
|
|
1158
|
+
urls.push(url);
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
mapWithConcurrency(urls, 5, resolvePrStatus).then(function (resolved) {
|
|
1162
|
+
const statuses = {};
|
|
1163
|
+
for (let i = 0; i < urls.length; i++) {
|
|
1164
|
+
if (resolved[i]) statuses[urls[i]] = resolved[i];
|
|
1165
|
+
}
|
|
1166
|
+
res.json({ statuses: statuses });
|
|
1167
|
+
});
|
|
1168
|
+
} catch (e) {
|
|
1169
|
+
res.status(500).json({ error: e.message });
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1078
1173
|
app.get("/api/claude-plans/stream", function (req, res) {
|
|
1079
1174
|
res.set({
|
|
1080
1175
|
"Content-Type": "text/event-stream",
|
|
@@ -1213,6 +1308,53 @@ app.post("/api/claude-plans/:slug/dispatch", claudePlansLimiter, express.json(),
|
|
|
1213
1308
|
}
|
|
1214
1309
|
});
|
|
1215
1310
|
|
|
1311
|
+
// GET /api/claude-plans/:slug/versions — list saved version snapshots (newest-first).
|
|
1312
|
+
app.get("/api/claude-plans/:slug/versions", function (req, res) {
|
|
1313
|
+
try {
|
|
1314
|
+
var slug = req.params.slug;
|
|
1315
|
+
if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
|
|
1316
|
+
res.json({ slug: slug, versions: claudePlansLib.listVersions(slug) });
|
|
1317
|
+
} catch (e) {
|
|
1318
|
+
sendTypedError(res, e);
|
|
1319
|
+
}
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
// GET /api/claude-plans/:slug/versions/:n — read one full version snapshot.
|
|
1323
|
+
app.get("/api/claude-plans/:slug/versions/:n", function (req, res) {
|
|
1324
|
+
try {
|
|
1325
|
+
var slug = req.params.slug;
|
|
1326
|
+
if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
|
|
1327
|
+
var n = parseInt(req.params.n, 10);
|
|
1328
|
+
if (!(n > 0)) return res.status(400).json({ error: "invalid version" });
|
|
1329
|
+
var version = claudePlansLib.getVersion(slug, n);
|
|
1330
|
+
if (!version) return res.status(404).json({ error: "version not found" });
|
|
1331
|
+
res.json(version);
|
|
1332
|
+
} catch (e) {
|
|
1333
|
+
sendTypedError(res, e);
|
|
1334
|
+
}
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
// POST /api/claude-plans/:slug/versions/:n/restore — re-push a prior version as
|
|
1338
|
+
// the new current record. Non-destructive (pre-restore current is snapshotted).
|
|
1339
|
+
// The plan-file write triggers the chokidar watcher → plan:upsert SSE, so
|
|
1340
|
+
// connected dashboards refresh without a manual broadcast.
|
|
1341
|
+
app.post(
|
|
1342
|
+
"/api/claude-plans/:slug/versions/:n/restore",
|
|
1343
|
+
claudePlansLimiter,
|
|
1344
|
+
function (req, res) {
|
|
1345
|
+
try {
|
|
1346
|
+
var slug = req.params.slug;
|
|
1347
|
+
if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
|
|
1348
|
+
var n = parseInt(req.params.n, 10);
|
|
1349
|
+
if (!(n > 0)) return res.status(400).json({ error: "invalid version" });
|
|
1350
|
+
var plan = claudePlansLib.restoreVersion(slug, n);
|
|
1351
|
+
res.json({ plan: plan });
|
|
1352
|
+
} catch (e) {
|
|
1353
|
+
sendTypedError(res, e);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
);
|
|
1357
|
+
|
|
1216
1358
|
app.delete("/api/claude-plans/:slug", claudePlansLimiter, function (req, res) {
|
|
1217
1359
|
try {
|
|
1218
1360
|
var slug = req.params.slug;
|