@tenonhq/dovetail-dashboard 0.0.30 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenonhq/dovetail-dashboard",
3
- "version": "0.0.30",
3
+ "version": "0.0.31",
4
4
  "description": "Update Set Dashboard for Dovetail",
5
5
  "main": "server.js",
6
6
  "scripts": {
@@ -1312,6 +1312,49 @@
1312
1312
  background: color-mix(in srgb, var(--accent-fg) 20%, transparent);
1313
1313
  }
1314
1314
 
1315
+ /* Merged-PR filter toggle + per-row PR state dots */
1316
+ .cp-rail-filters {
1317
+ padding: 8px 0 4px;
1318
+ }
1319
+ .cp-pr-toggle {
1320
+ display: inline-flex;
1321
+ align-items: center;
1322
+ gap: 6px;
1323
+ padding: 4px 12px;
1324
+ background: var(--surface);
1325
+ color: var(--fg);
1326
+ border: 1px solid var(--border);
1327
+ border-radius: 999px;
1328
+ font-family: inherit;
1329
+ font-size: 12px;
1330
+ font-weight: 500;
1331
+ cursor: pointer;
1332
+ transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
1333
+ }
1334
+ .cp-pr-toggle:hover {
1335
+ background: color-mix(in srgb, var(--accent) 10%, var(--surface));
1336
+ border-color: var(--accent);
1337
+ }
1338
+ .cp-pr-toggle--active {
1339
+ background: var(--accent);
1340
+ color: var(--accent-fg);
1341
+ border-color: var(--accent);
1342
+ }
1343
+
1344
+ .cp-pr-dot {
1345
+ display: inline-block;
1346
+ width: 8px;
1347
+ height: 8px;
1348
+ border-radius: 50%;
1349
+ flex: 0 0 auto;
1350
+ margin: 0 4px;
1351
+ background: var(--fg-muted);
1352
+ }
1353
+ .cp-pr-dot--merged { background: var(--success-700); }
1354
+ .cp-pr-dot--open { background: var(--warning-700); }
1355
+ .cp-pr-dot--closed { background: var(--danger); }
1356
+ .cp-pr-dot--unknown { background: var(--fg-muted); }
1357
+
1315
1358
  /* ===========================================================================
1316
1359
  * v2 — Stage Map, Questions tab, Dispatch dialog (Phase E)
1317
1360
  * Lightweight additions on top of the existing token system.
@@ -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.
@@ -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 = {
@@ -68,9 +70,37 @@
68
70
  topicsCloud: document.getElementById("cp-topics-cloud"),
69
71
  topicsCount: document.getElementById("cp-topics-count"),
70
72
  topicsEmpty: document.getElementById("cp-topics-empty"),
71
- topicsClear: document.getElementById("cp-topics-clear")
73
+ topicsClear: document.getElementById("cp-topics-clear"),
74
+ mergedToggle: document.getElementById("cp-merged-toggle")
72
75
  };
73
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
+
74
104
  function planMatchesTopic(plan, topic) {
75
105
  if (!topic) return true;
76
106
  var cats = plan.categories;
@@ -118,9 +148,9 @@
118
148
  var q = state.query;
119
149
  var topic = state.topicFilter;
120
150
  var all = allPlansSorted();
121
- if (!q && !topic) return all;
151
+ if (!q && !topic && !state.mergedOnly) return all;
122
152
  return all.filter(function (p) {
123
- return planMatchesQuery(p, q) && planMatchesTopic(p, topic);
153
+ return planMatchesQuery(p, q) && planMatchesTopic(p, topic) && planMatchesPr(p);
124
154
  });
125
155
  }
126
156
 
@@ -431,7 +461,7 @@
431
461
  renderTopics();
432
462
  var plans = sortedPlans();
433
463
  var totalPlans = state.plans.size;
434
- var isFiltering = !!state.query || !!state.topicFilter;
464
+ var isFiltering = !!state.query || !!state.topicFilter || state.mergedOnly;
435
465
  els.count.textContent = isFiltering
436
466
  ? plans.length + " / " + totalPlans
437
467
  : String(totalPlans);
@@ -444,6 +474,7 @@
444
474
  var pieces = [];
445
475
  if (state.query) pieces.push('"' + state.query + '"');
446
476
  if (state.topicFilter) pieces.push("topic “" + state.topicFilter + "”");
477
+ if (state.mergedOnly) pieces.push("merged PRs only");
447
478
  els.railNoMatchQ.textContent = pieces.join(" + ");
448
479
  }
449
480
  } else {
@@ -464,6 +495,7 @@
464
495
  li.innerHTML =
465
496
  '<div class="cp-list-row">' +
466
497
  ' <span class="cp-list-title"></span>' +
498
+ prDotMarkup(plan) +
467
499
  ' <span class="cp-status-pill cp-status-' + plan.status + '">' + plan.status + '</span>' +
468
500
  '</div>' +
469
501
  '<div class="cp-list-meta">' +
@@ -1154,6 +1186,41 @@
1154
1186
  });
1155
1187
  }
1156
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
+
1157
1224
  async function loadInitial() {
1158
1225
  try {
1159
1226
  var res = await fetch("/api/claude-plans");
@@ -1185,6 +1252,7 @@
1185
1252
  .catch(function () { /* ignore */ });
1186
1253
  }));
1187
1254
  renderRail();
1255
+ loadPrStatus();
1188
1256
  if (!state.selectedSlug) {
1189
1257
  var paramSlug = new URLSearchParams(window.location.search).get("plan");
1190
1258
  var sorted = sortedPlans();
@@ -1198,7 +1266,10 @@
1198
1266
  function startStream() {
1199
1267
  var es = new EventSource("/api/claude-plans/stream");
1200
1268
  es.addEventListener("plan:upsert", function (e) {
1201
- try { upsertPlan(JSON.parse(e.data).plan); } catch (_) {}
1269
+ try {
1270
+ upsertPlan(JSON.parse(e.data).plan);
1271
+ schedulePrStatusRefresh();
1272
+ } catch (_) {}
1202
1273
  });
1203
1274
  es.addEventListener("plan:delete", function (e) {
1204
1275
  try { removePlan(JSON.parse(e.data).slug); } catch (_) {}
@@ -1262,6 +1333,7 @@
1262
1333
  setupTheme();
1263
1334
  setupSearch();
1264
1335
  setupTopics();
1336
+ setupMergedToggle();
1265
1337
  setActiveTab("plan");
1266
1338
  loadLintsBadge();
1267
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>
@@ -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 list = sorted();
157
+ var total = events.size;
158
+ var list = filtered();
92
159
  els.tbody.innerHTML = "";
93
- if (!list.length) {
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
- els.summary.textContent =
105
- list.length + (list.length === 1 ? " event" : " events") +
106
- (list.length >= PAGE_LIMIT ? " (latest " + PAGE_LIMIT + ")" : "");
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",