@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.
@@ -37,7 +37,9 @@
37
37
  selectedSlug: null,
38
38
  activeTab: "plan",
39
39
  query: "", // lowercased search query; "" means show all
40
- topicFilter: null // active topic label, null = no topic filter
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 &rarr; " + 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, "&amp;")
921
+ .replace(/</g, "&lt;")
922
+ .replace(/>/g, "&gt;");
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 { upsertPlan(JSON.parse(e.data).plan); } catch (_) {}
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);
@@ -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;
@@ -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 &lt;40</button>
53
+ <button type="button" class="pl-chip" data-bucket="mid">Mid 40&ndash;69</button>
54
+ <button type="button" class="pl-chip" data-bucket="high">High &ge;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&hellip;"
63
+ autocomplete="off"
64
+ spellcheck="false"
65
+ />
66
+ <button class="pl-search-clear" id="pl-search-clear" aria-label="Clear search" hidden>&times;</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>