@tenonhq/dovetail-dashboard 0.0.18 → 0.0.21
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 +7 -2
- package/public/claude-plans.css +83 -0
- package/public/claude-plans.html +23 -1
- package/public/claude-plans.js +171 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tenonhq/dovetail-dashboard",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.21",
|
|
4
4
|
"description": "Update Set Dashboard for Dovetail",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"scripts": {
|
|
@@ -18,10 +18,15 @@
|
|
|
18
18
|
"tough-cookie": "^4.1.3"
|
|
19
19
|
},
|
|
20
20
|
"engines": {
|
|
21
|
-
"node": ">=
|
|
21
|
+
"node": ">=22"
|
|
22
22
|
},
|
|
23
23
|
"license": "ISC",
|
|
24
24
|
"publishConfig": {
|
|
25
25
|
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/TenonHQ/Dovetail.git",
|
|
30
|
+
"directory": "packages/dashboard"
|
|
26
31
|
}
|
|
27
32
|
}
|
package/public/claude-plans.css
CHANGED
|
@@ -82,6 +82,70 @@
|
|
|
82
82
|
font-size: 12px;
|
|
83
83
|
color: var(--brand);
|
|
84
84
|
}
|
|
85
|
+
.cp-rail-no-match {
|
|
86
|
+
padding: 20px 16px;
|
|
87
|
+
color: var(--fg-muted);
|
|
88
|
+
font-size: 13px;
|
|
89
|
+
line-height: 1.55;
|
|
90
|
+
}
|
|
91
|
+
.cp-rail-no-match-q {
|
|
92
|
+
font-family: var(--font-mono);
|
|
93
|
+
color: var(--fg);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* ── Search box ─────────────────────────────────────────────────────────── */
|
|
97
|
+
.cp-search {
|
|
98
|
+
position: relative;
|
|
99
|
+
padding: 10px 12px;
|
|
100
|
+
border-bottom: 1px solid var(--border);
|
|
101
|
+
background: var(--bg-paper);
|
|
102
|
+
}
|
|
103
|
+
.cp-search-input {
|
|
104
|
+
width: 100%;
|
|
105
|
+
box-sizing: border-box;
|
|
106
|
+
padding: 7px 28px 7px 10px;
|
|
107
|
+
font-size: 13px;
|
|
108
|
+
font-family: inherit;
|
|
109
|
+
color: var(--fg);
|
|
110
|
+
background: var(--bg);
|
|
111
|
+
border: 1px solid var(--border);
|
|
112
|
+
border-radius: var(--radius-sm);
|
|
113
|
+
outline: none;
|
|
114
|
+
transition: border-color var(--dur-1) var(--ease-out),
|
|
115
|
+
box-shadow var(--dur-1) var(--ease-out);
|
|
116
|
+
}
|
|
117
|
+
.cp-search-input::placeholder { color: var(--fg-muted); }
|
|
118
|
+
.cp-search-input:focus {
|
|
119
|
+
border-color: var(--accent);
|
|
120
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
|
|
121
|
+
}
|
|
122
|
+
/* Hide the native clear "x" on WebKit since we render our own button. */
|
|
123
|
+
.cp-search-input::-webkit-search-cancel-button { -webkit-appearance: none; }
|
|
124
|
+
|
|
125
|
+
.cp-search-clear {
|
|
126
|
+
position: absolute;
|
|
127
|
+
right: 16px;
|
|
128
|
+
top: 50%;
|
|
129
|
+
transform: translateY(-50%);
|
|
130
|
+
width: 20px;
|
|
131
|
+
height: 20px;
|
|
132
|
+
display: flex;
|
|
133
|
+
align-items: center;
|
|
134
|
+
justify-content: center;
|
|
135
|
+
background: transparent;
|
|
136
|
+
border: 0;
|
|
137
|
+
color: var(--fg-muted);
|
|
138
|
+
font-size: 16px;
|
|
139
|
+
line-height: 1;
|
|
140
|
+
cursor: pointer;
|
|
141
|
+
border-radius: var(--radius-xs);
|
|
142
|
+
padding: 0;
|
|
143
|
+
}
|
|
144
|
+
.cp-search-clear:hover {
|
|
145
|
+
background: var(--bg-muted);
|
|
146
|
+
color: var(--fg);
|
|
147
|
+
}
|
|
148
|
+
|
|
85
149
|
.cp-list { list-style: none; }
|
|
86
150
|
.cp-list-item {
|
|
87
151
|
padding: 12px 16px;
|
|
@@ -266,6 +330,25 @@
|
|
|
266
330
|
border-radius: var(--radius-md);
|
|
267
331
|
white-space: pre-wrap;
|
|
268
332
|
}
|
|
333
|
+
.cp-mermaid-fallback {
|
|
334
|
+
background: var(--bg-paper);
|
|
335
|
+
border: 1px solid var(--border);
|
|
336
|
+
border-radius: var(--radius-md);
|
|
337
|
+
padding: 12px;
|
|
338
|
+
}
|
|
339
|
+
.cp-mermaid-fallback-note {
|
|
340
|
+
color: var(--error-700);
|
|
341
|
+
font-size: 12px;
|
|
342
|
+
margin-bottom: 8px;
|
|
343
|
+
}
|
|
344
|
+
.cp-mermaid-fallback pre {
|
|
345
|
+
margin: 0;
|
|
346
|
+
overflow: auto;
|
|
347
|
+
font-family: var(--font-mono);
|
|
348
|
+
font-size: 12px;
|
|
349
|
+
line-height: 1.5;
|
|
350
|
+
white-space: pre;
|
|
351
|
+
}
|
|
269
352
|
|
|
270
353
|
/* ═══════════════════════════════════════════════════════════════════════════
|
|
271
354
|
cp-c-* Structured Component Library
|
package/public/claude-plans.html
CHANGED
|
@@ -42,10 +42,32 @@
|
|
|
42
42
|
<span class="cp-rail-title">Plans</span>
|
|
43
43
|
<span class="cp-count" id="cp-count">0</span>
|
|
44
44
|
</div>
|
|
45
|
+
<div class="cp-search">
|
|
46
|
+
<input
|
|
47
|
+
type="search"
|
|
48
|
+
class="cp-search-input"
|
|
49
|
+
id="cp-search-input"
|
|
50
|
+
placeholder="Search plans… (press / to focus)"
|
|
51
|
+
aria-label="Search plans by title, slug, status, or content"
|
|
52
|
+
autocomplete="off"
|
|
53
|
+
spellcheck="false"
|
|
54
|
+
/>
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
class="cp-search-clear"
|
|
58
|
+
id="cp-search-clear"
|
|
59
|
+
aria-label="Clear search"
|
|
60
|
+
title="Clear (Esc)"
|
|
61
|
+
hidden
|
|
62
|
+
>×</button>
|
|
63
|
+
</div>
|
|
45
64
|
<div class="cp-rail-empty" id="cp-rail-empty">
|
|
46
65
|
Waiting for plans. Run <code>push_plan</code> from a Claude Code
|
|
47
66
|
session to populate this list.
|
|
48
67
|
</div>
|
|
68
|
+
<div class="cp-rail-no-match" id="cp-rail-no-match" hidden>
|
|
69
|
+
No plans match <span class="cp-rail-no-match-q" id="cp-rail-no-match-q"></span>.
|
|
70
|
+
</div>
|
|
49
71
|
<ul class="cp-list" id="cp-list"></ul>
|
|
50
72
|
</aside>
|
|
51
73
|
|
|
@@ -73,7 +95,7 @@
|
|
|
73
95
|
|
|
74
96
|
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
|
|
75
97
|
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.5/dist/purify.min.js"></script>
|
|
76
|
-
<script src="https://cdn.jsdelivr.net/npm/mermaid@10.9.
|
|
98
|
+
<script src="https://cdn.jsdelivr.net/npm/mermaid@10.9.6/dist/mermaid.min.js"></script>
|
|
77
99
|
<script src="claude-plans.js"></script>
|
|
78
100
|
</body>
|
|
79
101
|
</html>
|
package/public/claude-plans.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/* /claude-plans page logic.
|
|
2
2
|
* - Fetches initial state from REST.
|
|
3
3
|
* - Subscribes to /api/claude-plans/stream (SSE) for live updates.
|
|
4
|
-
* - Renders markdown via marked + DOMPurify, Mermaid via mermaid.
|
|
4
|
+
* - Renders markdown via marked + DOMPurify, Mermaid via mermaid.parse()/render()
|
|
5
|
+
* with a graceful raw-source fallback when a diagram fails to parse.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
(function () {
|
|
@@ -16,7 +17,10 @@
|
|
|
16
17
|
window.mermaid.initialize({
|
|
17
18
|
startOnLoad: false,
|
|
18
19
|
theme: currentTheme() === "dark" ? "dark" : "default",
|
|
19
|
-
securityLevel: "strict"
|
|
20
|
+
securityLevel: "strict",
|
|
21
|
+
// Never inject mermaid's "Syntax error in text" bomb SVG into the page.
|
|
22
|
+
// Bad sources are handled by renderMermaid's graceful fallback instead.
|
|
23
|
+
suppressErrorRendering: true
|
|
20
24
|
});
|
|
21
25
|
}
|
|
22
26
|
}
|
|
@@ -30,7 +34,8 @@
|
|
|
30
34
|
plans: new Map(), // slug -> plan
|
|
31
35
|
artifacts: new Map(), // slug -> Map<artifactSlug, artifact>
|
|
32
36
|
selectedSlug: null,
|
|
33
|
-
activeTab: "plan"
|
|
37
|
+
activeTab: "plan",
|
|
38
|
+
query: "" // lowercased search query; "" means show all
|
|
34
39
|
};
|
|
35
40
|
|
|
36
41
|
var els = {
|
|
@@ -38,6 +43,10 @@
|
|
|
38
43
|
list: document.getElementById("cp-list"),
|
|
39
44
|
count: document.getElementById("cp-count"),
|
|
40
45
|
railEmpty: document.getElementById("cp-rail-empty"),
|
|
46
|
+
railNoMatch: document.getElementById("cp-rail-no-match"),
|
|
47
|
+
railNoMatchQ: document.getElementById("cp-rail-no-match-q"),
|
|
48
|
+
searchInput: document.getElementById("cp-search-input"),
|
|
49
|
+
searchClear: document.getElementById("cp-search-clear"),
|
|
41
50
|
detailEmpty: document.getElementById("cp-detail-empty"),
|
|
42
51
|
detailBody: document.getElementById("cp-detail-body"),
|
|
43
52
|
detailTitle: document.getElementById("cp-detail-title"),
|
|
@@ -49,12 +58,46 @@
|
|
|
49
58
|
tabs: document.querySelectorAll(".cp-tab")
|
|
50
59
|
};
|
|
51
60
|
|
|
52
|
-
|
|
61
|
+
// True when plan matches q across title, slug, status, plan markdown/html,
|
|
62
|
+
// pr title, and every artifact title + content. Lets you find a plan by any
|
|
63
|
+
// snippet that appears anywhere in or attached to it.
|
|
64
|
+
function planMatchesQuery(plan, q) {
|
|
65
|
+
if (!q) return true;
|
|
66
|
+
var haystacks = [
|
|
67
|
+
plan.title,
|
|
68
|
+
plan.slug,
|
|
69
|
+
plan.status,
|
|
70
|
+
plan.pr_title,
|
|
71
|
+
plan.content_md,
|
|
72
|
+
plan.content_html
|
|
73
|
+
];
|
|
74
|
+
var bucket = state.artifacts.get(plan.slug);
|
|
75
|
+
if (bucket) {
|
|
76
|
+
bucket.forEach(function (a) {
|
|
77
|
+
haystacks.push(a.title);
|
|
78
|
+
haystacks.push(a.content);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
for (var i = 0; i < haystacks.length; i++) {
|
|
82
|
+
var h = haystacks[i];
|
|
83
|
+
if (h && String(h).toLowerCase().indexOf(q) !== -1) return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function allPlansSorted() {
|
|
53
89
|
return Array.from(state.plans.values()).sort(function (a, b) {
|
|
54
90
|
return (b.updated_at || "").localeCompare(a.updated_at || "");
|
|
55
91
|
});
|
|
56
92
|
}
|
|
57
93
|
|
|
94
|
+
function sortedPlans() {
|
|
95
|
+
var q = state.query;
|
|
96
|
+
var all = allPlansSorted();
|
|
97
|
+
if (!q) return all;
|
|
98
|
+
return all.filter(function (p) { return planMatchesQuery(p, q); });
|
|
99
|
+
}
|
|
100
|
+
|
|
58
101
|
function sortedArtifacts(slug) {
|
|
59
102
|
var map = state.artifacts.get(slug);
|
|
60
103
|
if (!map) return [];
|
|
@@ -99,27 +142,62 @@
|
|
|
99
142
|
target.innerHTML = window.DOMPurify.sanitize(html);
|
|
100
143
|
}
|
|
101
144
|
|
|
145
|
+
// Strip a wrapping markdown code fence and normalize whitespace/line endings.
|
|
146
|
+
// LLM-authored sources often arrive fenced (```mermaid … ```) or with CRLF/BOM,
|
|
147
|
+
// which the parser rejects on line 1.
|
|
148
|
+
function preprocessMermaid(src) {
|
|
149
|
+
var s = src == null ? "" : String(src);
|
|
150
|
+
s = s.replace(/\r\n/g, "\n").replace(/^\uFEFF/, "");
|
|
151
|
+
s = s.replace(/^\s*```[^\n]*\n/, "").replace(/\n```\s*$/, "");
|
|
152
|
+
return s.trim();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Graceful fallback: show the raw source instead of mermaid's "Syntax error"
|
|
156
|
+
// bomb so one bad diagram never poisons the page.
|
|
157
|
+
function renderMermaidFallback(source, target) {
|
|
158
|
+
target.classList.remove("cp-mermaid");
|
|
159
|
+
target.classList.add("cp-mermaid-fallback");
|
|
160
|
+
target.textContent = "";
|
|
161
|
+
var note = document.createElement("div");
|
|
162
|
+
note.className = "cp-mermaid-fallback-note";
|
|
163
|
+
note.textContent = "⚠ Diagram failed to render — showing source";
|
|
164
|
+
var pre = document.createElement("pre");
|
|
165
|
+
var code = document.createElement("code");
|
|
166
|
+
code.textContent = source;
|
|
167
|
+
pre.appendChild(code);
|
|
168
|
+
target.appendChild(note);
|
|
169
|
+
target.appendChild(pre);
|
|
170
|
+
}
|
|
171
|
+
|
|
102
172
|
function renderMermaid(source, target) {
|
|
173
|
+
target.classList.remove("cp-mermaid-fallback", "cp-mermaid-error");
|
|
103
174
|
target.classList.add("cp-mermaid");
|
|
104
175
|
target.textContent = "";
|
|
105
176
|
if (!window.mermaid || typeof window.mermaid.render !== "function") {
|
|
106
|
-
target
|
|
177
|
+
renderMermaidFallback(source, target);
|
|
107
178
|
return;
|
|
108
179
|
}
|
|
180
|
+
var clean = preprocessMermaid(source);
|
|
109
181
|
var id = "mmd-" + Math.random().toString(36).slice(2, 10);
|
|
110
|
-
|
|
111
|
-
window.mermaid.render(id,
|
|
182
|
+
var doRender = function () {
|
|
183
|
+
window.mermaid.render(id, clean).then(
|
|
112
184
|
function (out) { target.innerHTML = out.svg; },
|
|
113
|
-
function (
|
|
114
|
-
target.classList.remove("cp-mermaid");
|
|
115
|
-
target.classList.add("cp-mermaid-error");
|
|
116
|
-
target.textContent = "mermaid error: " + (err && err.message ? err.message : String(err));
|
|
117
|
-
}
|
|
185
|
+
function () { renderMermaidFallback(source, target); }
|
|
118
186
|
);
|
|
187
|
+
};
|
|
188
|
+
try {
|
|
189
|
+
// Validate before rendering. suppressErrors makes parse resolve false
|
|
190
|
+
// (instead of throwing) on bad source, so we can fall back cleanly.
|
|
191
|
+
if (typeof window.mermaid.parse === "function") {
|
|
192
|
+
window.mermaid.parse(clean, { suppressErrors: true }).then(
|
|
193
|
+
function (ok) { if (ok) { doRender(); } else { renderMermaidFallback(source, target); } },
|
|
194
|
+
function () { renderMermaidFallback(source, target); }
|
|
195
|
+
);
|
|
196
|
+
} else {
|
|
197
|
+
doRender();
|
|
198
|
+
}
|
|
119
199
|
} catch (err) {
|
|
120
|
-
target
|
|
121
|
-
target.classList.add("cp-mermaid-error");
|
|
122
|
-
target.textContent = "mermaid error: " + err.message;
|
|
200
|
+
renderMermaidFallback(source, target);
|
|
123
201
|
}
|
|
124
202
|
}
|
|
125
203
|
|
|
@@ -203,13 +281,25 @@
|
|
|
203
281
|
|
|
204
282
|
function renderRail() {
|
|
205
283
|
var plans = sortedPlans();
|
|
206
|
-
|
|
284
|
+
var totalPlans = state.plans.size;
|
|
285
|
+
var isFiltering = !!state.query;
|
|
286
|
+
els.count.textContent = isFiltering
|
|
287
|
+
? plans.length + " / " + totalPlans
|
|
288
|
+
: String(totalPlans);
|
|
207
289
|
if (plans.length === 0) {
|
|
208
|
-
els.railEmpty.style.display = "block";
|
|
209
290
|
els.list.innerHTML = "";
|
|
291
|
+
if (isFiltering && totalPlans > 0) {
|
|
292
|
+
els.railEmpty.style.display = "none";
|
|
293
|
+
els.railNoMatch.hidden = false;
|
|
294
|
+
if (els.railNoMatchQ) els.railNoMatchQ.textContent = '"' + state.query + '"';
|
|
295
|
+
} else {
|
|
296
|
+
els.railEmpty.style.display = "block";
|
|
297
|
+
els.railNoMatch.hidden = true;
|
|
298
|
+
}
|
|
210
299
|
return;
|
|
211
300
|
}
|
|
212
301
|
els.railEmpty.style.display = "none";
|
|
302
|
+
els.railNoMatch.hidden = true;
|
|
213
303
|
els.list.innerHTML = "";
|
|
214
304
|
plans.forEach(function (plan) {
|
|
215
305
|
var artifactCount = (state.artifacts.get(plan.slug) || new Map()).size;
|
|
@@ -389,6 +479,69 @@
|
|
|
389
479
|
btn.addEventListener("click", function () { setActiveTab(btn.dataset.tab); });
|
|
390
480
|
});
|
|
391
481
|
|
|
482
|
+
/* ─── Search ───────────────────────────────────────────────────────────────
|
|
483
|
+
* Filter the rail by title/slug/status/content (plan + artifacts).
|
|
484
|
+
* 120ms debounce keeps typing snappy even with hundreds of plans.
|
|
485
|
+
*
|
|
486
|
+
* Selection rule: when filtering narrows the rail, only auto-select a new
|
|
487
|
+
* plan if the currently selected one is filtered out — picking the first
|
|
488
|
+
* visible match. Don't yank the user off a plan they're already reading.
|
|
489
|
+
*/
|
|
490
|
+
var searchDebounce = null;
|
|
491
|
+
function applyQuery(raw) {
|
|
492
|
+
var q = (raw == null ? "" : String(raw)).trim().toLowerCase();
|
|
493
|
+
state.query = q;
|
|
494
|
+
if (els.searchClear) els.searchClear.hidden = q.length === 0;
|
|
495
|
+
renderRail();
|
|
496
|
+
if (q) {
|
|
497
|
+
var visible = sortedPlans();
|
|
498
|
+
var stillVisible = state.selectedSlug &&
|
|
499
|
+
visible.some(function (p) { return p.slug === state.selectedSlug; });
|
|
500
|
+
if (!stillVisible && visible.length > 0) {
|
|
501
|
+
selectPlan(visible[0].slug);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function setupSearch() {
|
|
507
|
+
if (els.searchInput) {
|
|
508
|
+
els.searchInput.addEventListener("input", function (e) {
|
|
509
|
+
var v = e.target.value;
|
|
510
|
+
if (searchDebounce) clearTimeout(searchDebounce);
|
|
511
|
+
searchDebounce = setTimeout(function () { applyQuery(v); }, 120);
|
|
512
|
+
});
|
|
513
|
+
els.searchInput.addEventListener("keydown", function (e) {
|
|
514
|
+
if (e.key === "Escape") {
|
|
515
|
+
e.preventDefault();
|
|
516
|
+
els.searchInput.value = "";
|
|
517
|
+
applyQuery("");
|
|
518
|
+
els.searchInput.blur();
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
if (els.searchClear) {
|
|
523
|
+
els.searchClear.addEventListener("click", function () {
|
|
524
|
+
if (els.searchInput) els.searchInput.value = "";
|
|
525
|
+
applyQuery("");
|
|
526
|
+
if (els.searchInput) els.searchInput.focus();
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
// Press "/" anywhere (except in another input) to focus the search box.
|
|
530
|
+
document.addEventListener("keydown", function (e) {
|
|
531
|
+
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
532
|
+
var t = e.target;
|
|
533
|
+
var tag = t && t.tagName;
|
|
534
|
+
var isEditable = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" ||
|
|
535
|
+
(t && t.isContentEditable);
|
|
536
|
+
if (isEditable) return;
|
|
537
|
+
e.preventDefault();
|
|
538
|
+
if (els.searchInput) {
|
|
539
|
+
els.searchInput.focus();
|
|
540
|
+
els.searchInput.select();
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
392
545
|
function upsertPlan(plan) {
|
|
393
546
|
state.plans.set(plan.slug, plan);
|
|
394
547
|
renderRail();
|
|
@@ -510,6 +663,7 @@
|
|
|
510
663
|
|
|
511
664
|
document.addEventListener("DOMContentLoaded", function () {
|
|
512
665
|
setupTheme();
|
|
666
|
+
setupSearch();
|
|
513
667
|
setActiveTab("plan");
|
|
514
668
|
loadInitial().then(startStream);
|
|
515
669
|
});
|