@tenonhq/dovetail-dashboard 0.0.29 → 0.0.31
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/CHANGELOG.md +15 -0
- package/package.json +2 -1
- package/public/claude-plans.css +215 -0
- package/public/claude-plans.html +16 -0
- package/public/claude-plans.js +360 -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 +190 -0
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 = {
|
|
@@ -60,14 +62,45 @@
|
|
|
60
62
|
planPanel: document.getElementById("cp-tab-plan"),
|
|
61
63
|
artifactsPanel: document.getElementById("cp-tab-artifacts"),
|
|
62
64
|
promptsPanel: document.getElementById("cp-tab-prompts"),
|
|
65
|
+
questionsPanel: document.getElementById("cp-tab-questions"),
|
|
66
|
+
questionCount: document.getElementById("cp-question-count"),
|
|
67
|
+
stageMap: document.getElementById("cp-stage-map"),
|
|
63
68
|
tabs: document.querySelectorAll(".cp-tab"),
|
|
64
69
|
topics: document.getElementById("cp-topics"),
|
|
65
70
|
topicsCloud: document.getElementById("cp-topics-cloud"),
|
|
66
71
|
topicsCount: document.getElementById("cp-topics-count"),
|
|
67
72
|
topicsEmpty: document.getElementById("cp-topics-empty"),
|
|
68
|
-
topicsClear: document.getElementById("cp-topics-clear")
|
|
73
|
+
topicsClear: document.getElementById("cp-topics-clear"),
|
|
74
|
+
mergedToggle: document.getElementById("cp-merged-toggle")
|
|
69
75
|
};
|
|
70
76
|
|
|
77
|
+
// Merged === true only when gh resolved the plan's PR as MERGED.
|
|
78
|
+
function planPrMerged(plan) {
|
|
79
|
+
if (!plan.pr_url) return false;
|
|
80
|
+
var st = state.prStatus.get(plan.pr_url);
|
|
81
|
+
return !!(st && st.merged);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function planMatchesPr(plan) {
|
|
85
|
+
if (!state.mergedOnly) return true;
|
|
86
|
+
return planPrMerged(plan);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A small colored dot in the rail row reflecting the PR's merge state.
|
|
90
|
+
// Rendered only when the plan links a PR and gh has returned a status.
|
|
91
|
+
function prDotMarkup(plan) {
|
|
92
|
+
if (!plan.pr_url) return "";
|
|
93
|
+
var st = state.prStatus.get(plan.pr_url);
|
|
94
|
+
if (!st) return "";
|
|
95
|
+
var cls = "cp-pr-dot";
|
|
96
|
+
if (st.merged) cls += " cp-pr-dot--merged";
|
|
97
|
+
else if (st.state === "OPEN") cls += " cp-pr-dot--open";
|
|
98
|
+
else if (st.state === "CLOSED") cls += " cp-pr-dot--closed";
|
|
99
|
+
else cls += " cp-pr-dot--unknown";
|
|
100
|
+
var label = st.merged ? "merged" : (st.state || "unknown").toLowerCase();
|
|
101
|
+
return '<span class="' + cls + '" title="PR ' + label + '"></span>';
|
|
102
|
+
}
|
|
103
|
+
|
|
71
104
|
function planMatchesTopic(plan, topic) {
|
|
72
105
|
if (!topic) return true;
|
|
73
106
|
var cats = plan.categories;
|
|
@@ -115,9 +148,9 @@
|
|
|
115
148
|
var q = state.query;
|
|
116
149
|
var topic = state.topicFilter;
|
|
117
150
|
var all = allPlansSorted();
|
|
118
|
-
if (!q && !topic) return all;
|
|
151
|
+
if (!q && !topic && !state.mergedOnly) return all;
|
|
119
152
|
return all.filter(function (p) {
|
|
120
|
-
return planMatchesQuery(p, q) && planMatchesTopic(p, topic);
|
|
153
|
+
return planMatchesQuery(p, q) && planMatchesTopic(p, topic) && planMatchesPr(p);
|
|
121
154
|
});
|
|
122
155
|
}
|
|
123
156
|
|
|
@@ -428,7 +461,7 @@
|
|
|
428
461
|
renderTopics();
|
|
429
462
|
var plans = sortedPlans();
|
|
430
463
|
var totalPlans = state.plans.size;
|
|
431
|
-
var isFiltering = !!state.query || !!state.topicFilter;
|
|
464
|
+
var isFiltering = !!state.query || !!state.topicFilter || state.mergedOnly;
|
|
432
465
|
els.count.textContent = isFiltering
|
|
433
466
|
? plans.length + " / " + totalPlans
|
|
434
467
|
: String(totalPlans);
|
|
@@ -441,6 +474,7 @@
|
|
|
441
474
|
var pieces = [];
|
|
442
475
|
if (state.query) pieces.push('"' + state.query + '"');
|
|
443
476
|
if (state.topicFilter) pieces.push("topic “" + state.topicFilter + "”");
|
|
477
|
+
if (state.mergedOnly) pieces.push("merged PRs only");
|
|
444
478
|
els.railNoMatchQ.textContent = pieces.join(" + ");
|
|
445
479
|
}
|
|
446
480
|
} else {
|
|
@@ -461,6 +495,7 @@
|
|
|
461
495
|
li.innerHTML =
|
|
462
496
|
'<div class="cp-list-row">' +
|
|
463
497
|
' <span class="cp-list-title"></span>' +
|
|
498
|
+
prDotMarkup(plan) +
|
|
464
499
|
' <span class="cp-status-pill cp-status-' + plan.status + '">' + plan.status + '</span>' +
|
|
465
500
|
'</div>' +
|
|
466
501
|
'<div class="cp-list-meta">' +
|
|
@@ -592,6 +627,10 @@
|
|
|
592
627
|
renderMarkdown(plan.content_md, els.planPanel);
|
|
593
628
|
}
|
|
594
629
|
|
|
630
|
+
// v2 surfaces — Stage Map strip + Questions tab.
|
|
631
|
+
renderStageMap(plan);
|
|
632
|
+
renderQuestions(plan);
|
|
633
|
+
|
|
595
634
|
addPlanSectionCopyBtns();
|
|
596
635
|
addTabsCopyAll(plan, artifacts, prompts);
|
|
597
636
|
|
|
@@ -706,6 +745,275 @@
|
|
|
706
745
|
els.planPanel.hidden = tab !== "plan";
|
|
707
746
|
els.artifactsPanel.hidden = tab !== "artifacts";
|
|
708
747
|
if (els.promptsPanel) els.promptsPanel.hidden = tab !== "prompts";
|
|
748
|
+
if (els.questionsPanel) els.questionsPanel.hidden = tab !== "questions";
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/* ─── v2 bidirectional pipeline (Phase E) ──────────────────────────────────
|
|
752
|
+
* Stage Map, Questions tab, Dispatch dialog. Server enforces every safety
|
|
753
|
+
* rule (state machine, conflict resolution, dispatch token); the client
|
|
754
|
+
* just renders state and POSTs intents. The latest set_stage token is
|
|
755
|
+
* cached per-plan so the Dispatch button can hand it back to the live call
|
|
756
|
+
* without a round-trip.
|
|
757
|
+
* ─────────────────────────────────────────────────────────────────────── */
|
|
758
|
+
|
|
759
|
+
var PIPELINE_STAGES = [
|
|
760
|
+
"research",
|
|
761
|
+
"pre-stage-improve",
|
|
762
|
+
"planning",
|
|
763
|
+
"post-plan-improve",
|
|
764
|
+
"test-first",
|
|
765
|
+
"code",
|
|
766
|
+
"per-step-review",
|
|
767
|
+
"architectural-review",
|
|
768
|
+
"test-reality",
|
|
769
|
+
"documentation"
|
|
770
|
+
];
|
|
771
|
+
|
|
772
|
+
// Stages whose driving agent isn't shipped yet (matches dispatch.ts).
|
|
773
|
+
// dispatch_stage will raise MissingAgentError for these; the UI greys
|
|
774
|
+
// them out and adds a ⚠ marker.
|
|
775
|
+
var STAGES_MISSING_AGENT = { "test-first": true, "test-reality": true };
|
|
776
|
+
|
|
777
|
+
// Per-plan cache of the most recent setStage response. The dispatch
|
|
778
|
+
// button reads token from here. Cleared on plan switch.
|
|
779
|
+
var stageTokenCache = {};
|
|
780
|
+
|
|
781
|
+
function v2ApiPath(slug, leaf) { return "/api/claude-plans/" + slug + "/" + leaf; }
|
|
782
|
+
|
|
783
|
+
function v2Post(slug, leaf, body) {
|
|
784
|
+
return fetch(v2ApiPath(slug, leaf), {
|
|
785
|
+
method: "POST",
|
|
786
|
+
headers: { "Content-Type": "application/json" },
|
|
787
|
+
body: JSON.stringify(body || {})
|
|
788
|
+
}).then(function (r) {
|
|
789
|
+
return r.json().then(function (json) {
|
|
790
|
+
if (!r.ok) {
|
|
791
|
+
var err = new Error(json.message || json.error || ("HTTP " + r.status));
|
|
792
|
+
err.code = json.error;
|
|
793
|
+
err.name = json.name || "ServerError";
|
|
794
|
+
err.status = r.status;
|
|
795
|
+
err.payload = json;
|
|
796
|
+
throw err;
|
|
797
|
+
}
|
|
798
|
+
return json;
|
|
799
|
+
});
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function renderStageMap(plan) {
|
|
804
|
+
var map = els.stageMap;
|
|
805
|
+
if (!map) return;
|
|
806
|
+
map.hidden = false;
|
|
807
|
+
map.innerHTML = "";
|
|
808
|
+
var current = plan.stage || null;
|
|
809
|
+
PIPELINE_STAGES.forEach(function (stage) {
|
|
810
|
+
var pill = document.createElement("button");
|
|
811
|
+
pill.className = "cp-stage";
|
|
812
|
+
if (stage === current) pill.classList.add("cp-stage--current");
|
|
813
|
+
if (STAGES_MISSING_AGENT[stage]) pill.classList.add("cp-stage--missing-agent");
|
|
814
|
+
pill.textContent = stage;
|
|
815
|
+
pill.title = STAGES_MISSING_AGENT[stage]
|
|
816
|
+
? stage + " — agent not shipped yet (PR #160); dispatch will raise MissingAgentError"
|
|
817
|
+
: stage + " — click to move plan here";
|
|
818
|
+
pill.addEventListener("click", function () {
|
|
819
|
+
v2Post(plan.slug, "stage", { to: stage }).then(function (res) {
|
|
820
|
+
stageTokenCache[plan.slug] = res.token;
|
|
821
|
+
showToast("Stage → " + res.stage + " (token issued)");
|
|
822
|
+
}).catch(function (err) {
|
|
823
|
+
showToast(err.code === "ILLEGAL_TRANSITION"
|
|
824
|
+
? "Illegal move: " + err.message
|
|
825
|
+
: "Stage move failed: " + err.message);
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
// Per-stage Dispatch button (Phase E §3).
|
|
829
|
+
var dispatchBtn = document.createElement("button");
|
|
830
|
+
dispatchBtn.className = "cp-stage-dispatch";
|
|
831
|
+
dispatchBtn.textContent = "▶";
|
|
832
|
+
dispatchBtn.title = "Dispatch a Claude Code session at " + stage;
|
|
833
|
+
dispatchBtn.addEventListener("click", function (e) {
|
|
834
|
+
e.stopPropagation();
|
|
835
|
+
openDispatchDialog(plan, stage);
|
|
836
|
+
});
|
|
837
|
+
pill.appendChild(dispatchBtn);
|
|
838
|
+
map.appendChild(pill);
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function openDispatchDialog(plan, stage) {
|
|
843
|
+
// Step 1 — dry-run dispatch. Render the resolved command, then offer
|
|
844
|
+
// a "Confirm live" button that re-POSTs with confirm:true + the
|
|
845
|
+
// cached token for this plan.
|
|
846
|
+
v2Post(plan.slug, "dispatch", { target_stage: stage }).then(function (dryRun) {
|
|
847
|
+
var backdrop = document.createElement("div");
|
|
848
|
+
backdrop.className = "cp-dispatch-dialog";
|
|
849
|
+
var inner = document.createElement("div");
|
|
850
|
+
inner.className = "cp-dispatch-dialog-inner";
|
|
851
|
+
inner.innerHTML =
|
|
852
|
+
"<h3>Dispatch → " + stage + "</h3>" +
|
|
853
|
+
"<p>Dry-run resolved the following spawn. Confirm to launch a real Claude Code session.</p>" +
|
|
854
|
+
"<pre>" +
|
|
855
|
+
"<strong>command:</strong> " + escapeHtml(dryRun.command) + "\n" +
|
|
856
|
+
"<strong>cwd:</strong> " + escapeHtml(dryRun.cwd) + "\n" +
|
|
857
|
+
"<strong>plan:</strong> " + escapeHtml(dryRun.plan_slug) + "\n" +
|
|
858
|
+
"<strong>stage:</strong> " + escapeHtml(dryRun.target_stage) +
|
|
859
|
+
"</pre>";
|
|
860
|
+
var errorEl = document.createElement("div");
|
|
861
|
+
errorEl.className = "cp-dispatch-error";
|
|
862
|
+
errorEl.hidden = true;
|
|
863
|
+
inner.appendChild(errorEl);
|
|
864
|
+
|
|
865
|
+
var actions = document.createElement("div");
|
|
866
|
+
actions.className = "cp-dispatch-dialog-actions";
|
|
867
|
+
var cancel = document.createElement("button");
|
|
868
|
+
cancel.textContent = "Cancel";
|
|
869
|
+
cancel.addEventListener("click", function () { document.body.removeChild(backdrop); });
|
|
870
|
+
var confirm = document.createElement("button");
|
|
871
|
+
confirm.className = "cp-dispatch-confirm";
|
|
872
|
+
confirm.textContent = "Confirm live dispatch";
|
|
873
|
+
confirm.addEventListener("click", function () {
|
|
874
|
+
var token = stageTokenCache[plan.slug];
|
|
875
|
+
if (!token || !token.token) {
|
|
876
|
+
errorEl.hidden = false;
|
|
877
|
+
errorEl.textContent =
|
|
878
|
+
"No fresh dispatch token cached for this plan. Click the stage on the Stage Map first " +
|
|
879
|
+
"to mint one (tokens are 5-min, single-use, stage-bound).";
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
if (token.issued_for_stage !== stage) {
|
|
883
|
+
errorEl.hidden = false;
|
|
884
|
+
errorEl.textContent =
|
|
885
|
+
"Cached token was issued for " + token.issued_for_stage +
|
|
886
|
+
", not " + stage + ". Click " + stage + " on the Stage Map to issue a matching token.";
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
confirm.disabled = true;
|
|
890
|
+
v2Post(plan.slug, "dispatch", {
|
|
891
|
+
target_stage: stage,
|
|
892
|
+
confirm: true,
|
|
893
|
+
token: token.token
|
|
894
|
+
}).then(function (live) {
|
|
895
|
+
document.body.removeChild(backdrop);
|
|
896
|
+
showToast("Live dispatch launched (pid=" + live.pid + ")");
|
|
897
|
+
delete stageTokenCache[plan.slug];
|
|
898
|
+
}).catch(function (err) {
|
|
899
|
+
confirm.disabled = false;
|
|
900
|
+
errorEl.hidden = false;
|
|
901
|
+
errorEl.textContent = (err.code || "Error") + ": " + err.message;
|
|
902
|
+
});
|
|
903
|
+
});
|
|
904
|
+
actions.appendChild(cancel);
|
|
905
|
+
actions.appendChild(confirm);
|
|
906
|
+
inner.appendChild(actions);
|
|
907
|
+
backdrop.appendChild(inner);
|
|
908
|
+
document.body.appendChild(backdrop);
|
|
909
|
+
}).catch(function (err) {
|
|
910
|
+
if (err.code === "MISSING_AGENT") {
|
|
911
|
+
showToast(stage + ": " + err.message);
|
|
912
|
+
} else {
|
|
913
|
+
showToast("Dispatch dry-run failed: " + err.message);
|
|
914
|
+
}
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function escapeHtml(s) {
|
|
919
|
+
return String(s == null ? "" : s)
|
|
920
|
+
.replace(/&/g, "&")
|
|
921
|
+
.replace(/</g, "<")
|
|
922
|
+
.replace(/>/g, ">");
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function renderQuestions(plan) {
|
|
926
|
+
if (!els.questionsPanel) return;
|
|
927
|
+
var questions = plan.questions || [];
|
|
928
|
+
els.questionsPanel.innerHTML = "";
|
|
929
|
+
if (els.questionCount) els.questionCount.textContent = String(questions.length);
|
|
930
|
+
if (questions.length === 0) {
|
|
931
|
+
var empty = document.createElement("div");
|
|
932
|
+
empty.className = "cp-detail-empty";
|
|
933
|
+
empty.style.padding = "40px 0";
|
|
934
|
+
empty.textContent = "No questions yet. Call push_question from Claude (or /qa park).";
|
|
935
|
+
els.questionsPanel.appendChild(empty);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
questions.forEach(function (q) {
|
|
939
|
+
var card = document.createElement("div");
|
|
940
|
+
card.className = "cp-question" + (q.answer ? " cp-question--answered" : "");
|
|
941
|
+
|
|
942
|
+
var header = document.createElement("div");
|
|
943
|
+
header.className = "cp-question-header";
|
|
944
|
+
if (q.stage) {
|
|
945
|
+
var stageTag = document.createElement("span");
|
|
946
|
+
stageTag.className = "cp-question-stage";
|
|
947
|
+
stageTag.textContent = q.stage;
|
|
948
|
+
header.appendChild(stageTag);
|
|
949
|
+
}
|
|
950
|
+
if (q.asked_by) {
|
|
951
|
+
var by = document.createElement("span");
|
|
952
|
+
by.textContent = "asked by " + q.asked_by;
|
|
953
|
+
header.appendChild(by);
|
|
954
|
+
}
|
|
955
|
+
card.appendChild(header);
|
|
956
|
+
|
|
957
|
+
var qText = document.createElement("div");
|
|
958
|
+
qText.className = "cp-question-q";
|
|
959
|
+
qText.textContent = q.question;
|
|
960
|
+
card.appendChild(qText);
|
|
961
|
+
|
|
962
|
+
if (q.answer) {
|
|
963
|
+
var recorded = document.createElement("div");
|
|
964
|
+
recorded.className = "cp-question-recorded";
|
|
965
|
+
recorded.textContent = q.answer;
|
|
966
|
+
var meta = document.createElement("div");
|
|
967
|
+
meta.className = "cp-question-recorded-meta";
|
|
968
|
+
meta.textContent = "answered " +
|
|
969
|
+
(q.answered_by ? "by " + q.answered_by + " " : "") +
|
|
970
|
+
(q.answered_at ? "at " + fmtTime(q.answered_at) : "");
|
|
971
|
+
recorded.appendChild(meta);
|
|
972
|
+
card.appendChild(recorded);
|
|
973
|
+
} else {
|
|
974
|
+
if (q.options && q.options.length) {
|
|
975
|
+
var optsRow = document.createElement("div");
|
|
976
|
+
optsRow.className = "cp-question-options";
|
|
977
|
+
q.options.forEach(function (opt) {
|
|
978
|
+
var b = document.createElement("button");
|
|
979
|
+
b.className = "cp-question-option";
|
|
980
|
+
b.textContent = opt;
|
|
981
|
+
b.addEventListener("click", function () { submitAnswer(plan.slug, q.id, opt); });
|
|
982
|
+
optsRow.appendChild(b);
|
|
983
|
+
});
|
|
984
|
+
card.appendChild(optsRow);
|
|
985
|
+
}
|
|
986
|
+
var form = document.createElement("form");
|
|
987
|
+
form.className = "cp-question-answer-form";
|
|
988
|
+
var input = document.createElement("input");
|
|
989
|
+
input.className = "cp-question-answer-input";
|
|
990
|
+
input.placeholder = "Type an answer…";
|
|
991
|
+
input.required = true;
|
|
992
|
+
var submit = document.createElement("button");
|
|
993
|
+
submit.type = "submit";
|
|
994
|
+
submit.className = "cp-question-answer-submit";
|
|
995
|
+
submit.textContent = "Answer";
|
|
996
|
+
form.appendChild(input);
|
|
997
|
+
form.appendChild(submit);
|
|
998
|
+
form.addEventListener("submit", function (e) {
|
|
999
|
+
e.preventDefault();
|
|
1000
|
+
if (!input.value.trim()) return;
|
|
1001
|
+
submitAnswer(plan.slug, q.id, input.value.trim());
|
|
1002
|
+
});
|
|
1003
|
+
card.appendChild(form);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
els.questionsPanel.appendChild(card);
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function submitAnswer(slug, questionId, answer) {
|
|
1011
|
+
v2Post(slug, "answers", { question_id: questionId, answer: answer }).then(function () {
|
|
1012
|
+
showToast("Answer recorded.");
|
|
1013
|
+
// SSE will fan out the plan upsert and re-render naturally.
|
|
1014
|
+
}).catch(function (err) {
|
|
1015
|
+
showToast("record_answer failed: " + err.message);
|
|
1016
|
+
});
|
|
709
1017
|
}
|
|
710
1018
|
|
|
711
1019
|
function selectPlan(slug) {
|
|
@@ -714,6 +1022,12 @@
|
|
|
714
1022
|
var url = window.location.pathname + "?plan=" + encodeURIComponent(slug);
|
|
715
1023
|
window.history.replaceState(null, "", url);
|
|
716
1024
|
}
|
|
1025
|
+
// Tokens are stage+plan bound — switching plans should not retain
|
|
1026
|
+
// tokens from elsewhere (they'd be rejected on the server anyway,
|
|
1027
|
+
// but better not to surface a stale token to the operator).
|
|
1028
|
+
Object.keys(stageTokenCache).forEach(function (k) {
|
|
1029
|
+
if (k !== slug) delete stageTokenCache[k];
|
|
1030
|
+
});
|
|
717
1031
|
renderRail();
|
|
718
1032
|
renderDetail();
|
|
719
1033
|
}
|
|
@@ -872,6 +1186,41 @@
|
|
|
872
1186
|
});
|
|
873
1187
|
}
|
|
874
1188
|
|
|
1189
|
+
var prStatusDebounce = null;
|
|
1190
|
+
|
|
1191
|
+
// Resolve live PR merge state from the server (which shells out to gh) and
|
|
1192
|
+
// rerender so dots + the merged filter reflect reality. Best-effort: any
|
|
1193
|
+
// failure leaves prStatus as-is and the toggle simply matches nothing.
|
|
1194
|
+
function loadPrStatus() {
|
|
1195
|
+
return fetch("/api/claude-plans/pr-status")
|
|
1196
|
+
.then(function (r) { return r.json(); })
|
|
1197
|
+
.then(function (data) {
|
|
1198
|
+
var statuses = data && data.statuses ? data.statuses : {};
|
|
1199
|
+
Object.keys(statuses).forEach(function (url) {
|
|
1200
|
+
state.prStatus.set(url, statuses[url]);
|
|
1201
|
+
});
|
|
1202
|
+
renderRail();
|
|
1203
|
+
})
|
|
1204
|
+
.catch(function (err) {
|
|
1205
|
+
console.warn("[claude-plans] failed to load PR status:", err);
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function schedulePrStatusRefresh() {
|
|
1210
|
+
if (prStatusDebounce) clearTimeout(prStatusDebounce);
|
|
1211
|
+
prStatusDebounce = setTimeout(loadPrStatus, 500);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function setupMergedToggle() {
|
|
1215
|
+
if (!els.mergedToggle) return;
|
|
1216
|
+
els.mergedToggle.addEventListener("click", function () {
|
|
1217
|
+
state.mergedOnly = !state.mergedOnly;
|
|
1218
|
+
els.mergedToggle.setAttribute("aria-pressed", state.mergedOnly ? "true" : "false");
|
|
1219
|
+
els.mergedToggle.classList.toggle("cp-pr-toggle--active", state.mergedOnly);
|
|
1220
|
+
renderRail();
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
|
|
875
1224
|
async function loadInitial() {
|
|
876
1225
|
try {
|
|
877
1226
|
var res = await fetch("/api/claude-plans");
|
|
@@ -903,6 +1252,7 @@
|
|
|
903
1252
|
.catch(function () { /* ignore */ });
|
|
904
1253
|
}));
|
|
905
1254
|
renderRail();
|
|
1255
|
+
loadPrStatus();
|
|
906
1256
|
if (!state.selectedSlug) {
|
|
907
1257
|
var paramSlug = new URLSearchParams(window.location.search).get("plan");
|
|
908
1258
|
var sorted = sortedPlans();
|
|
@@ -916,7 +1266,10 @@
|
|
|
916
1266
|
function startStream() {
|
|
917
1267
|
var es = new EventSource("/api/claude-plans/stream");
|
|
918
1268
|
es.addEventListener("plan:upsert", function (e) {
|
|
919
|
-
try {
|
|
1269
|
+
try {
|
|
1270
|
+
upsertPlan(JSON.parse(e.data).plan);
|
|
1271
|
+
schedulePrStatusRefresh();
|
|
1272
|
+
} catch (_) {}
|
|
920
1273
|
});
|
|
921
1274
|
es.addEventListener("plan:delete", function (e) {
|
|
922
1275
|
try { removePlan(JSON.parse(e.data).slug); } catch (_) {}
|
|
@@ -980,6 +1333,7 @@
|
|
|
980
1333
|
setupTheme();
|
|
981
1334
|
setupSearch();
|
|
982
1335
|
setupTopics();
|
|
1336
|
+
setupMergedToggle();
|
|
983
1337
|
setActiveTab("plan");
|
|
984
1338
|
loadLintsBadge();
|
|
985
1339
|
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>
|