@tenonhq/dovetail-dashboard 0.0.13 → 0.0.15

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.
@@ -0,0 +1,445 @@
1
+ /* /claude-plans page logic.
2
+ * - Fetches initial state from REST.
3
+ * - Subscribes to /api/claude-plans/stream (SSE) for live updates.
4
+ * - Renders markdown via marked + DOMPurify, Mermaid via mermaid.run().
5
+ */
6
+
7
+ (function () {
8
+ "use strict";
9
+
10
+ if (window.mermaid && typeof window.mermaid.initialize === "function") {
11
+ window.mermaid.initialize({ startOnLoad: false, theme: "default", securityLevel: "strict" });
12
+ }
13
+ if (window.marked && typeof window.marked.setOptions === "function") {
14
+ window.marked.setOptions({ breaks: true, gfm: true });
15
+ }
16
+
17
+ var state = {
18
+ plans: new Map(), // slug -> plan
19
+ artifacts: new Map(), // slug -> Map<artifactSlug, artifact>
20
+ selectedSlug: null,
21
+ activeTab: "plan"
22
+ };
23
+
24
+ var els = {
25
+ storage: document.getElementById("cp-storage"),
26
+ list: document.getElementById("cp-list"),
27
+ count: document.getElementById("cp-count"),
28
+ railEmpty: document.getElementById("cp-rail-empty"),
29
+ detailEmpty: document.getElementById("cp-detail-empty"),
30
+ detailBody: document.getElementById("cp-detail-body"),
31
+ detailTitle: document.getElementById("cp-detail-title"),
32
+ detailStatus: document.getElementById("cp-detail-status"),
33
+ detailStamp: document.getElementById("cp-detail-stamp"),
34
+ artifactCount: document.getElementById("cp-artifact-count"),
35
+ planPanel: document.getElementById("cp-tab-plan"),
36
+ artifactsPanel: document.getElementById("cp-tab-artifacts"),
37
+ tabs: document.querySelectorAll(".cp-tab")
38
+ };
39
+
40
+ function sortedPlans() {
41
+ return Array.from(state.plans.values()).sort(function (a, b) {
42
+ return (b.updated_at || "").localeCompare(a.updated_at || "");
43
+ });
44
+ }
45
+
46
+ function sortedArtifacts(slug) {
47
+ var map = state.artifacts.get(slug);
48
+ if (!map) return [];
49
+ return Array.from(map.values()).sort(function (a, b) {
50
+ return (a.created_at || "").localeCompare(b.created_at || "");
51
+ });
52
+ }
53
+
54
+ function fmtTime(iso) {
55
+ if (!iso) return "";
56
+ var d = new Date(iso);
57
+ if (isNaN(d.getTime())) return iso;
58
+ return d.toLocaleString();
59
+ }
60
+
61
+ function showError(msg) {
62
+ var el = document.createElement("div");
63
+ el.style.cssText = "position:fixed;bottom:16px;right:16px;background:var(--danger);color:#fff;padding:8px 14px;border-radius:4px;font-size:13px;z-index:9999";
64
+ el.textContent = msg;
65
+ document.body.appendChild(el);
66
+ setTimeout(function () { el.remove(); }, 4000);
67
+ }
68
+
69
+ function renderMarkdown(md, target) {
70
+ if (!window.marked || !window.DOMPurify) {
71
+ target.textContent = md;
72
+ return;
73
+ }
74
+ var html = window.marked.parse(md || "");
75
+ target.innerHTML = window.DOMPurify.sanitize(html);
76
+ }
77
+
78
+ function renderMermaid(source, target) {
79
+ target.classList.add("cp-mermaid");
80
+ target.textContent = "";
81
+ if (!window.mermaid || typeof window.mermaid.render !== "function") {
82
+ target.textContent = source;
83
+ return;
84
+ }
85
+ var id = "mmd-" + Math.random().toString(36).slice(2, 10);
86
+ try {
87
+ window.mermaid.render(id, source).then(
88
+ function (out) { target.innerHTML = out.svg; },
89
+ function (err) {
90
+ target.classList.remove("cp-mermaid");
91
+ target.classList.add("cp-mermaid-error");
92
+ target.textContent = "mermaid error: " + (err && err.message ? err.message : String(err));
93
+ }
94
+ );
95
+ } catch (err) {
96
+ target.classList.remove("cp-mermaid");
97
+ target.classList.add("cp-mermaid-error");
98
+ target.textContent = "mermaid error: " + err.message;
99
+ }
100
+ }
101
+
102
+ /* ─── Copy helpers ─────────────────────────────────────────────────────────── */
103
+
104
+ function fallbackCopy(text) {
105
+ var ta = document.createElement("textarea");
106
+ ta.value = text;
107
+ ta.style.cssText = "position:fixed;left:-9999px;top:-9999px;opacity:0";
108
+ document.body.appendChild(ta);
109
+ ta.select();
110
+ try { document.execCommand("copy"); } catch (_) {}
111
+ ta.remove();
112
+ }
113
+
114
+ function makeCopyBtn(label, getText) {
115
+ var btn = document.createElement("button");
116
+ btn.className = "cp-copy-btn";
117
+ btn.textContent = label;
118
+ btn.addEventListener("click", function (e) {
119
+ e.stopPropagation();
120
+ var text = getText();
121
+ var flash = function () {
122
+ btn.textContent = "Copied!";
123
+ btn.classList.add("cp-copy-btn--copied");
124
+ setTimeout(function () {
125
+ btn.textContent = label;
126
+ btn.classList.remove("cp-copy-btn--copied");
127
+ }, 1500);
128
+ };
129
+ if (navigator.clipboard && navigator.clipboard.writeText) {
130
+ navigator.clipboard.writeText(text).then(flash).catch(function () {
131
+ fallbackCopy(text);
132
+ flash();
133
+ });
134
+ } else {
135
+ fallbackCopy(text);
136
+ flash();
137
+ }
138
+ });
139
+ return btn;
140
+ }
141
+
142
+ function addTabsCopyAll(plan, artifacts) {
143
+ var existing = document.getElementById("cp-tabs-copy-all");
144
+ if (existing) existing.remove();
145
+
146
+ var btn = makeCopyBtn("Copy All", function () {
147
+ if (state.activeTab === "artifacts") {
148
+ return artifacts.map(function (a) {
149
+ return "# " + a.title + "\n\n" + a.content;
150
+ }).join("\n\n---\n\n");
151
+ }
152
+ return plan.content_md && plan.content_md.trim()
153
+ ? plan.content_md
154
+ : els.planPanel.innerText.trim();
155
+ });
156
+ btn.id = "cp-tabs-copy-all";
157
+ btn.style.marginLeft = "auto";
158
+ var tabsEl = document.querySelector(".cp-tabs");
159
+ if (tabsEl) tabsEl.appendChild(btn);
160
+ }
161
+
162
+ function addPlanSectionCopyBtns() {
163
+ var structured = els.planPanel.querySelector(".cp-structured");
164
+ if (!structured) return;
165
+ var children = Array.from(structured.children);
166
+ children.forEach(function (child) {
167
+ child.classList.add("cp-c-copy-wrap");
168
+ var group = document.createElement("div");
169
+ group.className = "cp-copy-btn-group";
170
+ var btn = makeCopyBtn("Copy", (function (el) {
171
+ return function () { return el.innerText.trim(); };
172
+ })(child));
173
+ group.appendChild(btn);
174
+ child.appendChild(group);
175
+ });
176
+ }
177
+
178
+ /* ─────────────────────────────────────────────────────────────────────────── */
179
+
180
+ function renderRail() {
181
+ var plans = sortedPlans();
182
+ els.count.textContent = String(plans.length);
183
+ if (plans.length === 0) {
184
+ els.railEmpty.style.display = "block";
185
+ els.list.innerHTML = "";
186
+ return;
187
+ }
188
+ els.railEmpty.style.display = "none";
189
+ els.list.innerHTML = "";
190
+ plans.forEach(function (plan) {
191
+ var artifactCount = (state.artifacts.get(plan.slug) || new Map()).size;
192
+ var li = document.createElement("li");
193
+ li.className = "cp-list-item" + (plan.slug === state.selectedSlug ? " active" : "");
194
+ li.tabIndex = 0;
195
+ li.dataset.slug = plan.slug;
196
+ li.innerHTML =
197
+ '<div class="cp-list-row">' +
198
+ ' <span class="cp-list-title"></span>' +
199
+ ' <span class="cp-status-pill cp-status-' + plan.status + '">' + plan.status + '</span>' +
200
+ '</div>' +
201
+ '<div class="cp-list-meta">' +
202
+ ' <span class="cp-list-meta-slug"></span>' +
203
+ ' <span class="cp-artifact-badge">' + artifactCount + ' artifact' + (artifactCount === 1 ? '' : 's') + '</span>' +
204
+ '</div>';
205
+ li.querySelector(".cp-list-title").textContent = plan.title;
206
+ li.querySelector(".cp-list-meta-slug").textContent = plan.slug;
207
+ li.addEventListener("click", function () { selectPlan(plan.slug); });
208
+ li.addEventListener("keydown", function (e) {
209
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); selectPlan(plan.slug); }
210
+ });
211
+ var delBtn = document.createElement("button");
212
+ delBtn.className = "cp-list-delete";
213
+ delBtn.title = "Delete plan";
214
+ delBtn.textContent = "×";
215
+ delBtn.addEventListener("click", function (e) {
216
+ e.stopPropagation();
217
+ if (!delBtn.dataset.confirm) {
218
+ delBtn.dataset.confirm = "1";
219
+ delBtn.textContent = "?";
220
+ setTimeout(function () {
221
+ delBtn.textContent = "×";
222
+ delete delBtn.dataset.confirm;
223
+ }, 2000);
224
+ return;
225
+ }
226
+ fetch("/api/claude-plans/" + encodeURIComponent(plan.slug), { method: "DELETE" })
227
+ .then(function (r) {
228
+ if (!r.ok) throw new Error("HTTP " + r.status);
229
+ removePlan(plan.slug);
230
+ })
231
+ .catch(function () {
232
+ delBtn.textContent = "×";
233
+ delete delBtn.dataset.confirm;
234
+ showError("Failed to delete plan. Try again.");
235
+ });
236
+ });
237
+ li.querySelector(".cp-list-row").appendChild(delBtn);
238
+ els.list.appendChild(li);
239
+ });
240
+ }
241
+
242
+ function renderDetail() {
243
+ if (!state.selectedSlug || !state.plans.has(state.selectedSlug)) {
244
+ els.detailEmpty.style.display = "block";
245
+ els.detailBody.hidden = true;
246
+ var orphan = document.getElementById("cp-tabs-copy-all");
247
+ if (orphan) orphan.remove();
248
+ return;
249
+ }
250
+ var plan = state.plans.get(state.selectedSlug);
251
+ var artifacts = sortedArtifacts(state.selectedSlug);
252
+
253
+ els.detailEmpty.style.display = "none";
254
+ els.detailBody.hidden = false;
255
+ els.detailTitle.textContent = plan.title;
256
+ els.detailStatus.textContent = plan.status;
257
+ els.detailStatus.className = "cp-status-pill cp-status-" + plan.status;
258
+ els.detailStamp.textContent = "updated " + fmtTime(plan.updated_at);
259
+ els.artifactCount.textContent = String(artifacts.length);
260
+
261
+ var existingPrBadge = document.getElementById("cp-pr-badge");
262
+ if (existingPrBadge) existingPrBadge.remove();
263
+ if (plan.pr_url) {
264
+ var prBadge = document.createElement("a");
265
+ prBadge.id = "cp-pr-badge";
266
+ prBadge.className = "cp-pr-badge";
267
+ prBadge.href = plan.pr_url;
268
+ prBadge.target = "_blank";
269
+ prBadge.rel = "noopener noreferrer";
270
+ prBadge.textContent = plan.pr_title
271
+ ? "PR #" + plan.pr_number + " — " + plan.pr_title
272
+ : "PR #" + (plan.pr_number || "");
273
+ els.detailStamp.insertAdjacentElement("afterend", prBadge);
274
+ }
275
+
276
+ if (plan.content_html) {
277
+ els.planPanel.innerHTML = window.DOMPurify
278
+ ? window.DOMPurify.sanitize(plan.content_html)
279
+ : plan.content_html;
280
+ } else {
281
+ renderMarkdown(plan.content_md, els.planPanel);
282
+ }
283
+
284
+ addPlanSectionCopyBtns();
285
+ addTabsCopyAll(plan, artifacts);
286
+
287
+ els.artifactsPanel.innerHTML = "";
288
+ if (artifacts.length === 0) {
289
+ var empty = document.createElement("div");
290
+ empty.className = "cp-detail-empty";
291
+ empty.style.padding = "40px 0";
292
+ empty.textContent = "No artifacts yet. Call push_artifact or push_diagram from Claude.";
293
+ els.artifactsPanel.appendChild(empty);
294
+ } else {
295
+ artifacts.forEach(function (artifact) {
296
+ var card = document.createElement("div");
297
+ card.className = "cp-artifact-card";
298
+ var head = document.createElement("div");
299
+ head.className = "cp-artifact-head";
300
+ var title = document.createElement("span");
301
+ title.className = "cp-artifact-title";
302
+ title.textContent = artifact.title;
303
+ var kind = document.createElement("span");
304
+ kind.className = "cp-kind-pill";
305
+ kind.textContent = artifact.kind;
306
+ head.appendChild(title);
307
+ head.appendChild(kind);
308
+
309
+ var copyGroup = document.createElement("div");
310
+ copyGroup.className = "cp-copy-btn-group cp-copy-btn-group--artifact";
311
+ copyGroup.appendChild(makeCopyBtn("Copy", (function (content) {
312
+ return function () { return content; };
313
+ })(artifact.content)));
314
+ head.appendChild(copyGroup);
315
+
316
+ card.appendChild(head);
317
+
318
+ var body = document.createElement("div");
319
+ if (artifact.kind === "mermaid") renderMermaid(artifact.content, body);
320
+ else renderMarkdown(artifact.content, body);
321
+ card.appendChild(body);
322
+ els.artifactsPanel.appendChild(card);
323
+ });
324
+ }
325
+ }
326
+
327
+ function setActiveTab(tab) {
328
+ state.activeTab = tab;
329
+ els.tabs.forEach(function (btn) {
330
+ btn.classList.toggle("active", btn.dataset.tab === tab);
331
+ });
332
+ els.planPanel.hidden = tab !== "plan";
333
+ els.artifactsPanel.hidden = tab !== "artifacts";
334
+ }
335
+
336
+ function selectPlan(slug) {
337
+ state.selectedSlug = slug;
338
+ renderRail();
339
+ renderDetail();
340
+ }
341
+
342
+ els.tabs.forEach(function (btn) {
343
+ btn.addEventListener("click", function () { setActiveTab(btn.dataset.tab); });
344
+ });
345
+
346
+ function upsertPlan(plan) {
347
+ state.plans.set(plan.slug, plan);
348
+ renderRail();
349
+ if (state.selectedSlug === plan.slug) renderDetail();
350
+ }
351
+
352
+ function removePlan(slug) {
353
+ state.plans.delete(slug);
354
+ state.artifacts.delete(slug);
355
+ if (state.selectedSlug === slug) state.selectedSlug = null;
356
+ renderRail();
357
+ renderDetail();
358
+ }
359
+
360
+ function upsertArtifact(artifact) {
361
+ var bucket = state.artifacts.get(artifact.plan_slug);
362
+ if (!bucket) {
363
+ bucket = new Map();
364
+ state.artifacts.set(artifact.plan_slug, bucket);
365
+ }
366
+ bucket.set(artifact.slug, artifact);
367
+ renderRail();
368
+ if (state.selectedSlug === artifact.plan_slug) renderDetail();
369
+ }
370
+
371
+ function removeArtifact(planSlug, slug) {
372
+ var bucket = state.artifacts.get(planSlug);
373
+ if (!bucket) return;
374
+ bucket.delete(slug);
375
+ renderRail();
376
+ if (state.selectedSlug === planSlug) renderDetail();
377
+ }
378
+
379
+ async function loadInitial() {
380
+ try {
381
+ var res = await fetch("/api/claude-plans");
382
+ var data = await res.json();
383
+ if (data && Array.isArray(data.plans)) {
384
+ data.plans.forEach(function (p) { state.plans.set(p.slug, p); });
385
+ }
386
+ if (data && data.storage) els.storage.textContent = data.storage;
387
+ } catch (err) {
388
+ console.warn("[claude-plans] failed to load initial state:", err);
389
+ }
390
+ // Preload artifacts for visible plans
391
+ var slugs = Array.from(state.plans.keys());
392
+ await Promise.all(slugs.map(function (slug) {
393
+ return fetch("/api/claude-plans/" + encodeURIComponent(slug))
394
+ .then(function (r) { return r.json(); })
395
+ .then(function (data) {
396
+ if (data && Array.isArray(data.artifacts)) {
397
+ var bucket = new Map();
398
+ data.artifacts.forEach(function (a) { bucket.set(a.slug, a); });
399
+ state.artifacts.set(slug, bucket);
400
+ }
401
+ })
402
+ .catch(function () { /* ignore */ });
403
+ }));
404
+ renderRail();
405
+ if (!state.selectedSlug) {
406
+ var sorted = sortedPlans();
407
+ if (sorted.length > 0) selectPlan(sorted[0].slug);
408
+ }
409
+ }
410
+
411
+ function startStream() {
412
+ var es = new EventSource("/api/claude-plans/stream");
413
+ es.addEventListener("plan:upsert", function (e) {
414
+ try { upsertPlan(JSON.parse(e.data).plan); } catch (_) {}
415
+ });
416
+ es.addEventListener("plan:delete", function (e) {
417
+ try { removePlan(JSON.parse(e.data).slug); } catch (_) {}
418
+ });
419
+ es.addEventListener("artifact:upsert", function (e) {
420
+ try { upsertArtifact(JSON.parse(e.data).artifact); } catch (_) {}
421
+ });
422
+ es.addEventListener("artifact:delete", function (e) {
423
+ try {
424
+ var payload = JSON.parse(e.data);
425
+ removeArtifact(payload.plan_slug, payload.slug);
426
+ } catch (_) {}
427
+ });
428
+ es.addEventListener("plan:focus", function (e) {
429
+ try {
430
+ var data = JSON.parse(e.data);
431
+ if (data.slug) {
432
+ selectPlan(data.slug);
433
+ var item = els.list.querySelector("[data-slug=\"" + data.slug + "\"]");
434
+ if (item) item.scrollIntoView({ behavior: "smooth", block: "nearest" });
435
+ }
436
+ } catch (_) {}
437
+ });
438
+ es.onerror = function () { /* EventSource auto-reconnects */ };
439
+ }
440
+
441
+ document.addEventListener("DOMContentLoaded", function () {
442
+ setActiveTab("plan");
443
+ loadInitial().then(startStream);
444
+ });
445
+ })();
package/public/index.html CHANGED
@@ -14,6 +14,7 @@
14
14
  </div>
15
15
  <div class="header-right">
16
16
  <button class="btn-refresh" id="refresh-btn" title="Refresh scopes and update sets">Refresh</button>
17
+ <a class="btn-refresh" href="/claude-plans" title="Claude plans and diagrams pushed via MCP">Claude</a>
17
18
  <div class="active-task-chip" id="active-task-chip" style="display:none;"></div>
18
19
  <div class="instance-badge" id="instance-badge">Loading...</div>
19
20
  </div>
package/public/styles.css CHANGED
@@ -717,6 +717,11 @@ header h1 span {
717
717
  border-color: var(--danger);
718
718
  }
719
719
 
720
+ .toast.warn {
721
+ border-color: #c88c2a;
722
+ color: #e8b86d;
723
+ }
724
+
720
725
  @keyframes slideIn {
721
726
  from {
722
727
  opacity: 0;
@@ -817,3 +822,24 @@ header h1 span {
817
822
  color: var(--text-muted);
818
823
  white-space: nowrap;
819
824
  }
825
+
826
+ .recent-edit-dismiss {
827
+ background: none;
828
+ border: none;
829
+ color: var(--text-muted);
830
+ font-size: 16px;
831
+ line-height: 1;
832
+ padding: 0 2px;
833
+ cursor: pointer;
834
+ opacity: 0;
835
+ transition: opacity 0.15s, color 0.15s;
836
+ flex-shrink: 0;
837
+ }
838
+
839
+ .recent-edit-row:hover .recent-edit-dismiss {
840
+ opacity: 1;
841
+ }
842
+
843
+ .recent-edit-dismiss:hover {
844
+ color: var(--danger);
845
+ }