@tenonhq/dovetail-dashboard 0.0.21 → 0.0.23

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.21",
3
+ "version": "0.0.23",
4
4
  "description": "Update Set Dashboard for Dovetail",
5
5
  "main": "server.js",
6
6
  "scripts": {
@@ -311,6 +311,47 @@
311
311
  border-radius: var(--radius-pill);
312
312
  letter-spacing: 0.07em;
313
313
  }
314
+
315
+ /* ── Prompt cards (Prompt tab) ──────────────────────────────────────────── */
316
+ .cp-prompt-card .cp-prompt-score {
317
+ background: var(--accent-bg, var(--bg-muted));
318
+ color: var(--accent);
319
+ text-transform: none;
320
+ letter-spacing: 0;
321
+ font-size: 11px;
322
+ }
323
+ .cp-prompt-source {
324
+ margin: 0 0 12px 0;
325
+ font-size: 12px;
326
+ color: var(--fg-muted);
327
+ }
328
+ .cp-prompt-source summary {
329
+ cursor: pointer;
330
+ user-select: none;
331
+ padding: 4px 0;
332
+ }
333
+ .cp-prompt-source pre,
334
+ .cp-prompt-body {
335
+ background: var(--bg-paper);
336
+ border: 1px solid var(--border);
337
+ border-radius: var(--radius-md);
338
+ padding: 12px;
339
+ overflow-x: auto;
340
+ white-space: pre-wrap;
341
+ word-break: break-word;
342
+ font-family: var(--font-mono);
343
+ font-size: 12px;
344
+ line-height: 1.5;
345
+ margin: 0;
346
+ }
347
+ .cp-prompt-body code,
348
+ .cp-prompt-source pre code {
349
+ background: none;
350
+ padding: 0;
351
+ font-family: inherit;
352
+ font-size: inherit;
353
+ color: var(--fg);
354
+ }
314
355
  .cp-mermaid {
315
356
  background: var(--bg-paper);
316
357
  border: 1px solid var(--border);
@@ -1082,3 +1123,138 @@
1082
1123
  background: #14384a;
1083
1124
  color: #b6e8ff;
1084
1125
  }
1126
+
1127
+ /* ─── Topics cloud ─────────────────────────────────────────────────────────── */
1128
+
1129
+ .cp-topics {
1130
+ margin: 0 24px 12px;
1131
+ background: var(--surface-raised);
1132
+ border: 1px solid var(--border);
1133
+ border-radius: var(--radius-md, 8px);
1134
+ box-shadow: var(--shadow-1, 0 1px 2px rgba(0,0,0,0.04));
1135
+ }
1136
+
1137
+ .cp-topics-summary {
1138
+ display: flex;
1139
+ align-items: center;
1140
+ gap: 8px;
1141
+ padding: 10px 14px;
1142
+ cursor: pointer;
1143
+ list-style: none;
1144
+ user-select: none;
1145
+ font-size: 13px;
1146
+ color: var(--fg);
1147
+ }
1148
+ .cp-topics-summary::-webkit-details-marker { display: none; }
1149
+ .cp-topics-summary:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }
1150
+
1151
+ .cp-topics-caret {
1152
+ display: inline-block;
1153
+ transition: transform 0.15s ease;
1154
+ color: var(--fg-muted);
1155
+ font-size: 12px;
1156
+ width: 12px;
1157
+ }
1158
+ .cp-topics:not([open]) .cp-topics-caret { transform: rotate(-90deg); }
1159
+
1160
+ .cp-topics-title {
1161
+ font-weight: 600;
1162
+ letter-spacing: 0.02em;
1163
+ }
1164
+
1165
+ .cp-topics-count {
1166
+ display: inline-flex;
1167
+ align-items: center;
1168
+ justify-content: center;
1169
+ min-width: 22px;
1170
+ height: 20px;
1171
+ padding: 0 6px;
1172
+ background: var(--bg-muted, var(--surface));
1173
+ color: var(--fg-muted);
1174
+ border: 1px solid var(--border);
1175
+ border-radius: 999px;
1176
+ font-size: 11px;
1177
+ font-weight: 600;
1178
+ }
1179
+
1180
+ .cp-topics-clear {
1181
+ margin-left: auto;
1182
+ background: transparent;
1183
+ border: 1px solid var(--border);
1184
+ border-radius: 6px;
1185
+ padding: 3px 10px;
1186
+ font-size: 11px;
1187
+ color: var(--fg-muted);
1188
+ cursor: pointer;
1189
+ }
1190
+ .cp-topics-clear:hover {
1191
+ background: var(--bg-muted, var(--surface));
1192
+ color: var(--fg);
1193
+ }
1194
+
1195
+ .cp-topics-cloud {
1196
+ display: flex;
1197
+ flex-wrap: wrap;
1198
+ gap: 6px 8px;
1199
+ padding: 10px 14px 14px;
1200
+ border-top: 1px solid var(--border-subtle, var(--border));
1201
+ line-height: 1.3;
1202
+ }
1203
+
1204
+ .cp-topics-empty {
1205
+ color: var(--fg-muted);
1206
+ font-size: 12px;
1207
+ font-style: italic;
1208
+ padding: 2px 0;
1209
+ }
1210
+
1211
+ .cp-topic-chip {
1212
+ display: inline-flex;
1213
+ align-items: baseline;
1214
+ gap: 6px;
1215
+ padding: 4px 10px;
1216
+ background: var(--surface);
1217
+ color: var(--fg);
1218
+ border: 1px solid var(--border);
1219
+ border-radius: 999px;
1220
+ font-family: inherit;
1221
+ font-weight: 500;
1222
+ letter-spacing: 0.01em;
1223
+ cursor: pointer;
1224
+ transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
1225
+ }
1226
+ .cp-topic-chip:hover {
1227
+ background: color-mix(in srgb, var(--accent) 10%, var(--surface));
1228
+ border-color: var(--accent);
1229
+ }
1230
+ .cp-topic-chip:focus-visible {
1231
+ outline: none;
1232
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
1233
+ }
1234
+
1235
+ .cp-topic-chip--active {
1236
+ background: var(--accent);
1237
+ color: var(--accent-fg);
1238
+ border-color: var(--accent);
1239
+ }
1240
+ .cp-topic-chip--active:hover {
1241
+ background: var(--accent-hover, var(--accent));
1242
+ color: var(--accent-fg);
1243
+ }
1244
+
1245
+ .cp-topic-chip-count {
1246
+ display: inline-flex;
1247
+ align-items: center;
1248
+ justify-content: center;
1249
+ min-width: 18px;
1250
+ padding: 0 5px;
1251
+ background: color-mix(in srgb, currentColor 12%, transparent);
1252
+ border-radius: 999px;
1253
+ font-size: 10px;
1254
+ font-weight: 700;
1255
+ line-height: 1.6;
1256
+ letter-spacing: 0;
1257
+ }
1258
+ .cp-topic-chip--active .cp-topic-chip-count {
1259
+ background: color-mix(in srgb, var(--accent-fg) 20%, transparent);
1260
+ }
@@ -36,6 +36,27 @@
36
36
  </div>
37
37
  </header>
38
38
 
39
+ <details class="cp-topics" id="cp-topics" open>
40
+ <summary class="cp-topics-summary">
41
+ <span class="cp-topics-caret" aria-hidden="true">▾</span>
42
+ <span class="cp-topics-title">Topics</span>
43
+ <span class="cp-topics-count" id="cp-topics-count">0</span>
44
+ <button
45
+ type="button"
46
+ class="cp-topics-clear"
47
+ id="cp-topics-clear"
48
+ hidden
49
+ title="Clear topic filter"
50
+ aria-label="Clear topic filter"
51
+ >Clear filter</button>
52
+ </summary>
53
+ <div class="cp-topics-cloud" id="cp-topics-cloud" role="group" aria-label="Plan topics">
54
+ <span class="cp-topics-empty" id="cp-topics-empty">
55
+ No topics yet. Push a plan and topics will appear here.
56
+ </span>
57
+ </div>
58
+ </details>
59
+
39
60
  <main class="cp-layout">
40
61
  <aside class="cp-rail" id="cp-rail">
41
62
  <div class="cp-rail-header">
@@ -83,11 +104,15 @@
83
104
  </header>
84
105
  <nav class="cp-tabs">
85
106
  <button class="cp-tab active" data-tab="plan">Plan</button>
107
+ <button class="cp-tab" data-tab="prompts">
108
+ Prompt <span class="cp-tab-count" id="cp-prompt-count">0</span>
109
+ </button>
86
110
  <button class="cp-tab" data-tab="artifacts">
87
111
  Artifacts <span class="cp-tab-count" id="cp-artifact-count">0</span>
88
112
  </button>
89
113
  </nav>
90
114
  <div class="cp-tab-panel" id="cp-tab-plan"></div>
115
+ <div class="cp-tab-panel" id="cp-tab-prompts" hidden></div>
91
116
  <div class="cp-tab-panel" id="cp-tab-artifacts" hidden></div>
92
117
  </article>
93
118
  </section>
@@ -33,9 +33,11 @@
33
33
  var state = {
34
34
  plans: new Map(), // slug -> plan
35
35
  artifacts: new Map(), // slug -> Map<artifactSlug, artifact>
36
+ prompts: new Map(), // slug -> Map<promptSlug, prompt>
36
37
  selectedSlug: null,
37
38
  activeTab: "plan",
38
- query: "" // lowercased search query; "" means show all
39
+ query: "", // lowercased search query; "" means show all
40
+ topicFilter: null // active topic label, null = no topic filter
39
41
  };
40
42
 
41
43
  var els = {
@@ -53,11 +55,28 @@
53
55
  detailStatus: document.getElementById("cp-detail-status"),
54
56
  detailStamp: document.getElementById("cp-detail-stamp"),
55
57
  artifactCount: document.getElementById("cp-artifact-count"),
58
+ promptCount: document.getElementById("cp-prompt-count"),
56
59
  planPanel: document.getElementById("cp-tab-plan"),
57
60
  artifactsPanel: document.getElementById("cp-tab-artifacts"),
58
- tabs: document.querySelectorAll(".cp-tab")
61
+ promptsPanel: document.getElementById("cp-tab-prompts"),
62
+ tabs: document.querySelectorAll(".cp-tab"),
63
+ topics: document.getElementById("cp-topics"),
64
+ topicsCloud: document.getElementById("cp-topics-cloud"),
65
+ topicsCount: document.getElementById("cp-topics-count"),
66
+ topicsEmpty: document.getElementById("cp-topics-empty"),
67
+ topicsClear: document.getElementById("cp-topics-clear")
59
68
  };
60
69
 
70
+ function planMatchesTopic(plan, topic) {
71
+ if (!topic) return true;
72
+ var cats = plan.categories;
73
+ if (!cats || !cats.length) return false;
74
+ for (var i = 0; i < cats.length; i++) {
75
+ if (cats[i] === topic) return true;
76
+ }
77
+ return false;
78
+ }
79
+
61
80
  // True when plan matches q across title, slug, status, plan markdown/html,
62
81
  // pr title, and every artifact title + content. Lets you find a plan by any
63
82
  // snippet that appears anywhere in or attached to it.
@@ -93,9 +112,121 @@
93
112
 
94
113
  function sortedPlans() {
95
114
  var q = state.query;
115
+ var topic = state.topicFilter;
96
116
  var all = allPlansSorted();
97
- if (!q) return all;
98
- return all.filter(function (p) { return planMatchesQuery(p, q); });
117
+ if (!q && !topic) return all;
118
+ return all.filter(function (p) {
119
+ return planMatchesQuery(p, q) && planMatchesTopic(p, topic);
120
+ });
121
+ }
122
+
123
+ /* ─── Topics aggregation + render ──────────────────────────────────────────
124
+ * Counts topic occurrences across all plans (not just filtered ones) so the
125
+ * cloud reflects the full corpus. Click a chip to filter the rail by that
126
+ * topic; click the active chip again to clear.
127
+ *
128
+ * Counts are computed client-side from plan.categories arrays — no new
129
+ * server endpoint or SSE event. The existing plan:upsert stream rerenders
130
+ * the cloud automatically because renderRail() now also calls renderTopics().
131
+ */
132
+ function aggregateTopics() {
133
+ var counts = new Map();
134
+ state.plans.forEach(function (plan) {
135
+ var cats = plan.categories;
136
+ if (!cats || !cats.length) return;
137
+ for (var i = 0; i < cats.length; i++) {
138
+ var label = cats[i];
139
+ counts.set(label, (counts.get(label) || 0) + 1);
140
+ }
141
+ });
142
+ var rows = [];
143
+ counts.forEach(function (count, label) {
144
+ rows.push({ label: label, count: count });
145
+ });
146
+ rows.sort(function (a, b) {
147
+ if (b.count !== a.count) return b.count - a.count;
148
+ return a.label.localeCompare(b.label);
149
+ });
150
+ return rows;
151
+ }
152
+
153
+ // sqrt compression keeps a 30-count topic from dwarfing a 2-count one.
154
+ function topicFontSize(count, max) {
155
+ if (max <= 1) return 14;
156
+ var ratio = Math.sqrt(count) / Math.sqrt(max);
157
+ return Math.round(12 + ratio * 12);
158
+ }
159
+
160
+ function renderTopics() {
161
+ if (!els.topicsCloud) return;
162
+ var rows = aggregateTopics();
163
+ if (els.topicsCount) els.topicsCount.textContent = String(rows.length);
164
+ if (els.topicsClear) els.topicsClear.hidden = !state.topicFilter;
165
+ if (rows.length === 0) {
166
+ els.topicsCloud.innerHTML = "";
167
+ if (els.topicsEmpty) {
168
+ els.topicsCloud.appendChild(els.topicsEmpty);
169
+ els.topicsEmpty.style.display = "inline";
170
+ }
171
+ return;
172
+ }
173
+ if (els.topicsEmpty) els.topicsEmpty.style.display = "none";
174
+ els.topicsCloud.innerHTML = "";
175
+ var max = rows[0].count;
176
+ rows.forEach(function (row) {
177
+ var chip = document.createElement("button");
178
+ chip.type = "button";
179
+ var isActive = state.topicFilter === row.label;
180
+ chip.className = "cp-topic-chip" + (isActive ? " cp-topic-chip--active" : "");
181
+ chip.style.fontSize = topicFontSize(row.count, max) + "px";
182
+ chip.setAttribute("aria-pressed", isActive ? "true" : "false");
183
+ chip.title = row.count + " plan" + (row.count === 1 ? "" : "s");
184
+ var labelEl = document.createElement("span");
185
+ labelEl.className = "cp-topic-chip-label";
186
+ labelEl.textContent = row.label;
187
+ var countEl = document.createElement("span");
188
+ countEl.className = "cp-topic-chip-count";
189
+ countEl.textContent = String(row.count);
190
+ chip.appendChild(labelEl);
191
+ chip.appendChild(countEl);
192
+ chip.addEventListener("click", function () {
193
+ applyTopicFilter(state.topicFilter === row.label ? null : row.label);
194
+ });
195
+ els.topicsCloud.appendChild(chip);
196
+ });
197
+ }
198
+
199
+ function applyTopicFilter(label) {
200
+ state.topicFilter = label;
201
+ renderTopics();
202
+ renderRail();
203
+ if (label) {
204
+ var visible = sortedPlans();
205
+ var stillVisible = state.selectedSlug &&
206
+ visible.some(function (p) { return p.slug === state.selectedSlug; });
207
+ if (!stillVisible && visible.length > 0) selectPlan(visible[0].slug);
208
+ }
209
+ }
210
+
211
+ function setupTopics() {
212
+ if (els.topicsClear) {
213
+ els.topicsClear.addEventListener("click", function (e) {
214
+ e.preventDefault();
215
+ e.stopPropagation();
216
+ applyTopicFilter(null);
217
+ });
218
+ }
219
+ if (els.topics) {
220
+ try {
221
+ var stored = localStorage.getItem("cp-topics-collapsed");
222
+ if (stored === "1") els.topics.open = false;
223
+ } catch (_) {}
224
+ els.topics.addEventListener("toggle", function () {
225
+ try {
226
+ localStorage.setItem("cp-topics-collapsed", els.topics.open ? "0" : "1");
227
+ } catch (_) {}
228
+ });
229
+ }
99
230
  }
100
231
 
101
232
  function sortedArtifacts(slug) {
@@ -106,6 +237,14 @@
106
237
  });
107
238
  }
108
239
 
240
+ function sortedPrompts(slug) {
241
+ var map = state.prompts.get(slug);
242
+ if (!map) return [];
243
+ return Array.from(map.values()).sort(function (a, b) {
244
+ return (a.created_at || "").localeCompare(b.created_at || "");
245
+ });
246
+ }
247
+
109
248
  function fmtTime(iso) {
110
249
  if (!iso) return "";
111
250
  var d = new Date(iso);
@@ -241,7 +380,7 @@
241
380
  return btn;
242
381
  }
243
382
 
244
- function addTabsCopyAll(plan, artifacts) {
383
+ function addTabsCopyAll(plan, artifacts, prompts) {
245
384
  var existing = document.getElementById("cp-tabs-copy-all");
246
385
  if (existing) existing.remove();
247
386
 
@@ -251,6 +390,11 @@
251
390
  return "# " + a.title + "\n\n" + a.content;
252
391
  }).join("\n\n---\n\n");
253
392
  }
393
+ if (state.activeTab === "prompts") {
394
+ return prompts.map(function (p) {
395
+ return "# " + p.title + "\n\n" + p.content;
396
+ }).join("\n\n---\n\n");
397
+ }
254
398
  return plan.content_md && plan.content_md.trim()
255
399
  ? plan.content_md
256
400
  : els.planPanel.innerText.trim();
@@ -280,9 +424,10 @@
280
424
  /* ─────────────────────────────────────────────────────────────────────────── */
281
425
 
282
426
  function renderRail() {
427
+ renderTopics();
283
428
  var plans = sortedPlans();
284
429
  var totalPlans = state.plans.size;
285
- var isFiltering = !!state.query;
430
+ var isFiltering = !!state.query || !!state.topicFilter;
286
431
  els.count.textContent = isFiltering
287
432
  ? plans.length + " / " + totalPlans
288
433
  : String(totalPlans);
@@ -291,7 +436,12 @@
291
436
  if (isFiltering && totalPlans > 0) {
292
437
  els.railEmpty.style.display = "none";
293
438
  els.railNoMatch.hidden = false;
294
- if (els.railNoMatchQ) els.railNoMatchQ.textContent = '"' + state.query + '"';
439
+ if (els.railNoMatchQ) {
440
+ var pieces = [];
441
+ if (state.query) pieces.push('"' + state.query + '"');
442
+ if (state.topicFilter) pieces.push("topic “" + state.topicFilter + "”");
443
+ els.railNoMatchQ.textContent = pieces.join(" + ");
444
+ }
295
445
  } else {
296
446
  els.railEmpty.style.display = "block";
297
447
  els.railNoMatch.hidden = true;
@@ -363,6 +513,7 @@
363
513
  }
364
514
  var plan = state.plans.get(state.selectedSlug);
365
515
  var artifacts = sortedArtifacts(state.selectedSlug);
516
+ var prompts = sortedPrompts(state.selectedSlug);
366
517
 
367
518
  els.detailEmpty.style.display = "none";
368
519
  els.detailBody.hidden = false;
@@ -371,6 +522,7 @@
371
522
  els.detailStatus.className = "cp-status-pill cp-status-" + plan.status;
372
523
  els.detailStamp.textContent = "updated " + fmtTime(plan.updated_at);
373
524
  els.artifactCount.textContent = String(artifacts.length);
525
+ if (els.promptCount) els.promptCount.textContent = String(prompts.length);
374
526
 
375
527
  var existingPrBadge = document.getElementById("cp-pr-badge");
376
528
  if (existingPrBadge) existingPrBadge.remove();
@@ -409,6 +561,28 @@
409
561
  });
410
562
  els.detailStatus.insertAdjacentElement("afterend", resumeBtn);
411
563
 
564
+ var existingPromptBtn = document.getElementById("cp-gen-prompt-btn");
565
+ if (existingPromptBtn) existingPromptBtn.remove();
566
+ var genPromptBtn = document.createElement("button");
567
+ genPromptBtn.id = "cp-gen-prompt-btn";
568
+ genPromptBtn.className = "cp-resume-btn";
569
+ genPromptBtn.textContent = "Generate Prompt";
570
+ genPromptBtn.title = "Copy /improve-prompt --from-plan <slug> to clipboard";
571
+ genPromptBtn.addEventListener("click", function () {
572
+ var cmd = "/improve-prompt --from-plan " + plan.slug;
573
+ var finish = function () { showToast("Copied — paste into Claude Code."); };
574
+ if (navigator.clipboard && navigator.clipboard.writeText) {
575
+ navigator.clipboard.writeText(cmd).then(finish).catch(function () {
576
+ fallbackCopy(cmd);
577
+ finish();
578
+ });
579
+ } else {
580
+ fallbackCopy(cmd);
581
+ finish();
582
+ }
583
+ });
584
+ resumeBtn.insertAdjacentElement("afterend", genPromptBtn);
585
+
412
586
  if (plan.content_html) {
413
587
  els.planPanel.innerHTML = window.DOMPurify
414
588
  ? window.DOMPurify.sanitize(plan.content_html)
@@ -418,7 +592,7 @@
418
592
  }
419
593
 
420
594
  addPlanSectionCopyBtns();
421
- addTabsCopyAll(plan, artifacts);
595
+ addTabsCopyAll(plan, artifacts, prompts);
422
596
 
423
597
  els.artifactsPanel.innerHTML = "";
424
598
  if (artifacts.length === 0) {
@@ -458,6 +632,69 @@
458
632
  els.artifactsPanel.appendChild(card);
459
633
  });
460
634
  }
635
+
636
+ els.promptsPanel.innerHTML = "";
637
+ if (prompts.length === 0) {
638
+ var emptyPrompts = document.createElement("div");
639
+ emptyPrompts.className = "cp-detail-empty";
640
+ emptyPrompts.style.padding = "40px 0";
641
+ emptyPrompts.textContent = 'No prompts yet. Click "Generate Prompt" above to seed one, or push from /improve-prompt --push.';
642
+ els.promptsPanel.appendChild(emptyPrompts);
643
+ } else {
644
+ prompts.forEach(function (prompt) {
645
+ var card = document.createElement("div");
646
+ card.className = "cp-artifact-card cp-prompt-card";
647
+ var head = document.createElement("div");
648
+ head.className = "cp-artifact-head";
649
+ var title = document.createElement("span");
650
+ title.className = "cp-artifact-title";
651
+ title.textContent = prompt.title;
652
+ head.appendChild(title);
653
+
654
+ if (typeof prompt.score_before === "number" && typeof prompt.score_after === "number") {
655
+ var scorePill = document.createElement("span");
656
+ scorePill.className = "cp-kind-pill cp-prompt-score";
657
+ scorePill.textContent = prompt.score_before + " → " + prompt.score_after + "%";
658
+ head.appendChild(scorePill);
659
+ } else if (typeof prompt.score_after === "number") {
660
+ var scorePillOnly = document.createElement("span");
661
+ scorePillOnly.className = "cp-kind-pill cp-prompt-score";
662
+ scorePillOnly.textContent = prompt.score_after + "%";
663
+ head.appendChild(scorePillOnly);
664
+ }
665
+
666
+ var copyGroup = document.createElement("div");
667
+ copyGroup.className = "cp-copy-btn-group cp-copy-btn-group--artifact";
668
+ copyGroup.appendChild(makeCopyBtn("Copy", (function (content) {
669
+ return function () { return content; };
670
+ })(prompt.content)));
671
+ head.appendChild(copyGroup);
672
+
673
+ card.appendChild(head);
674
+
675
+ if (prompt.source_draft) {
676
+ var details = document.createElement("details");
677
+ details.className = "cp-prompt-source";
678
+ var summary = document.createElement("summary");
679
+ summary.textContent = "Original draft";
680
+ details.appendChild(summary);
681
+ var pre = document.createElement("pre");
682
+ var code = document.createElement("code");
683
+ code.textContent = prompt.source_draft;
684
+ pre.appendChild(code);
685
+ details.appendChild(pre);
686
+ card.appendChild(details);
687
+ }
688
+
689
+ var body = document.createElement("pre");
690
+ body.className = "cp-prompt-body";
691
+ var bodyCode = document.createElement("code");
692
+ bodyCode.textContent = prompt.content;
693
+ body.appendChild(bodyCode);
694
+ card.appendChild(body);
695
+ els.promptsPanel.appendChild(card);
696
+ });
697
+ }
461
698
  }
462
699
 
463
700
  function setActiveTab(tab) {
@@ -467,6 +704,7 @@
467
704
  });
468
705
  els.planPanel.hidden = tab !== "plan";
469
706
  els.artifactsPanel.hidden = tab !== "artifacts";
707
+ if (els.promptsPanel) els.promptsPanel.hidden = tab !== "prompts";
470
708
  }
471
709
 
472
710
  function selectPlan(slug) {
@@ -551,6 +789,7 @@
551
789
  function removePlan(slug) {
552
790
  state.plans.delete(slug);
553
791
  state.artifacts.delete(slug);
792
+ state.prompts.delete(slug);
554
793
  if (state.selectedSlug === slug) state.selectedSlug = null;
555
794
  renderRail();
556
795
  renderDetail();
@@ -575,6 +814,23 @@
575
814
  if (state.selectedSlug === planSlug) renderDetail();
576
815
  }
577
816
 
817
+ function upsertPrompt(prompt) {
818
+ var bucket = state.prompts.get(prompt.plan_slug);
819
+ if (!bucket) {
820
+ bucket = new Map();
821
+ state.prompts.set(prompt.plan_slug, bucket);
822
+ }
823
+ bucket.set(prompt.slug, prompt);
824
+ if (state.selectedSlug === prompt.plan_slug) renderDetail();
825
+ }
826
+
827
+ function removePrompt(planSlug, slug) {
828
+ var bucket = state.prompts.get(planSlug);
829
+ if (!bucket) return;
830
+ bucket.delete(slug);
831
+ if (state.selectedSlug === planSlug) renderDetail();
832
+ }
833
+
578
834
  async function loadInitial() {
579
835
  try {
580
836
  var res = await fetch("/api/claude-plans");
@@ -586,7 +842,7 @@
586
842
  } catch (err) {
587
843
  console.warn("[claude-plans] failed to load initial state:", err);
588
844
  }
589
- // Preload artifacts for visible plans
845
+ // Preload artifacts + prompts for visible plans
590
846
  var slugs = Array.from(state.plans.keys());
591
847
  await Promise.all(slugs.map(function (slug) {
592
848
  return fetch("/api/claude-plans/" + encodeURIComponent(slug))
@@ -597,6 +853,11 @@
597
853
  data.artifacts.forEach(function (a) { bucket.set(a.slug, a); });
598
854
  state.artifacts.set(slug, bucket);
599
855
  }
856
+ if (data && Array.isArray(data.prompts)) {
857
+ var pBucket = new Map();
858
+ data.prompts.forEach(function (p) { pBucket.set(p.slug, p); });
859
+ state.prompts.set(slug, pBucket);
860
+ }
600
861
  })
601
862
  .catch(function () { /* ignore */ });
602
863
  }));
@@ -624,6 +885,15 @@
624
885
  removeArtifact(payload.plan_slug, payload.slug);
625
886
  } catch (_) {}
626
887
  });
888
+ es.addEventListener("prompt:upsert", function (e) {
889
+ try { upsertPrompt(JSON.parse(e.data).prompt); } catch (_) {}
890
+ });
891
+ es.addEventListener("prompt:delete", function (e) {
892
+ try {
893
+ var payload = JSON.parse(e.data);
894
+ removePrompt(payload.plan_slug, payload.slug);
895
+ } catch (_) {}
896
+ });
627
897
  es.addEventListener("plan:focus", function (e) {
628
898
  try {
629
899
  var data = JSON.parse(e.data);
@@ -664,6 +934,7 @@
664
934
  document.addEventListener("DOMContentLoaded", function () {
665
935
  setupTheme();
666
936
  setupSearch();
937
+ setupTopics();
667
938
  setActiveTab("plan");
668
939
  loadInitial().then(startStream);
669
940
  });
package/server.js CHANGED
@@ -830,6 +830,10 @@ function artifactsDirFor(slug) {
830
830
  return path.join(CLAUDE_PLANS_DIR, slug, "artifacts");
831
831
  }
832
832
 
833
+ function promptsDirFor(slug) {
834
+ return path.join(CLAUDE_PLANS_DIR, slug, "prompts");
835
+ }
836
+
833
837
  function safeReadJson(filePath) {
834
838
  try {
835
839
  if (!fs.existsSync(filePath)) return null;
@@ -872,6 +876,25 @@ function listClaudeArtifacts(slug) {
872
876
  return artifacts;
873
877
  }
874
878
 
879
+ function listClaudePrompts(slug) {
880
+ if (!isValidSlug(slug)) return [];
881
+ const baseDir = path.resolve(CLAUDE_PLANS_DIR);
882
+ const dir = path.resolve(promptsDirFor(slug));
883
+ if (!dir.startsWith(baseDir + path.sep)) return [];
884
+ if (!fs.existsSync(dir)) return [];
885
+ const entries = fs.readdirSync(dir);
886
+ const prompts = [];
887
+ for (let i = 0; i < entries.length; i++) {
888
+ if (!entries[i].endsWith(".json")) continue;
889
+ const p = safeReadJson(path.join(dir, entries[i]));
890
+ if (p) prompts.push(p);
891
+ }
892
+ prompts.sort(function (a, b) {
893
+ return (a.created_at || "").localeCompare(b.created_at || "");
894
+ });
895
+ return prompts;
896
+ }
897
+
875
898
  // Parse a watcher path like "<root>/<slug>.json" or
876
899
  // "<root>/<slug>/artifacts/<artifact-slug>.json" into { kind, slug, artifactSlug }.
877
900
  function classifyPath(filePath) {
@@ -887,6 +910,9 @@ function classifyPath(filePath) {
887
910
  if (parts.length === 3 && parts[1] === "artifacts" && parts[2].endsWith(".json")) {
888
911
  return { kind: "artifact", slug: parts[0], artifactSlug: parts[2].slice(0, -5) };
889
912
  }
913
+ if (parts.length === 3 && parts[1] === "prompts" && parts[2].endsWith(".json")) {
914
+ return { kind: "prompt", slug: parts[0], promptSlug: parts[2].slice(0, -5) };
915
+ }
890
916
  return null;
891
917
  }
892
918
 
@@ -934,6 +960,18 @@ function handleWatcherChange(event, filePath) {
934
960
  }
935
961
  const artifact = safeReadJson(filePath);
936
962
  if (artifact) broadcastClaudePlanEvent("artifact:upsert", { artifact: artifact });
963
+ return;
964
+ }
965
+ if (info.kind === "prompt") {
966
+ if (event === "unlink") {
967
+ broadcastClaudePlanEvent("prompt:delete", {
968
+ plan_slug: info.slug,
969
+ slug: info.promptSlug
970
+ });
971
+ return;
972
+ }
973
+ const prompt = safeReadJson(filePath);
974
+ if (prompt) broadcastClaudePlanEvent("prompt:upsert", { prompt: prompt });
937
975
  }
938
976
  }
939
977
 
@@ -1003,7 +1041,11 @@ app.get("/api/claude-plans/:slug", function (req, res) {
1003
1041
  if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1004
1042
  const plan = safeReadJson(planFilePath(slug));
1005
1043
  if (!plan) return res.status(404).json({ error: "plan not found" });
1006
- res.json({ plan: plan, artifacts: listClaudeArtifacts(slug) });
1044
+ res.json({
1045
+ plan: plan,
1046
+ artifacts: listClaudeArtifacts(slug),
1047
+ prompts: listClaudePrompts(slug)
1048
+ });
1007
1049
  } catch (e) {
1008
1050
  res.status(500).json({ error: e.message });
1009
1051
  }