@tenonhq/dovetail-dashboard 0.0.38 → 0.0.40
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 +2 -2
- package/public/claude-plans.html +1 -0
- package/public/prompt-editor.html +188 -0
- package/public/prompt-editor.js +342 -0
- package/server.js +120 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tenonhq/dovetail-dashboard",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.40",
|
|
4
4
|
"description": "Update Set Dashboard for Dovetail",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"scripts": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"postpublish": "npm run version:bump"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@tenonhq/dovetail-claude-plans": "~0.0.
|
|
12
|
+
"@tenonhq/dovetail-claude-plans": "~0.0.24",
|
|
13
13
|
"@tenonhq/dovetail-todo": "~0.0.1",
|
|
14
14
|
"axios": "^1.5.1",
|
|
15
15
|
"axios-cookiejar-support": "^4.0.7",
|
package/public/claude-plans.html
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
</div>
|
|
33
33
|
<div class="header-right">
|
|
34
34
|
<a class="cp-nav-link" href="/">← Update Sets</a>
|
|
35
|
+
<a class="cp-nav-link" href="/prompt-editor">Prompt Editor →</a>
|
|
35
36
|
<a class="cp-nav-link" href="/prompt-lints">Prompt Lints
|
|
36
37
|
<span class="cp-lints-badge" id="cp-lints-badge" title="Prompts needing review" hidden>0</span>
|
|
37
38
|
→</a>
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Dovetail — Prompt Editor</title>
|
|
7
|
+
<script>
|
|
8
|
+
/* Apply saved theme before first paint to avoid a flash of light. */
|
|
9
|
+
(function () {
|
|
10
|
+
try {
|
|
11
|
+
var t = localStorage.getItem("cp-theme");
|
|
12
|
+
if (t === "dark" || t === "light") {
|
|
13
|
+
document.documentElement.setAttribute("data-theme", t);
|
|
14
|
+
}
|
|
15
|
+
} catch (e) {}
|
|
16
|
+
})();
|
|
17
|
+
</script>
|
|
18
|
+
<link rel="stylesheet" href="/tokens.css" />
|
|
19
|
+
<link rel="stylesheet" href="/styles.css" />
|
|
20
|
+
<style>
|
|
21
|
+
.pe-body { margin: 0; display: flex; flex-direction: column; height: 100vh; }
|
|
22
|
+
.pe-toolbar {
|
|
23
|
+
display: flex; align-items: center; gap: 12px;
|
|
24
|
+
padding: 10px 16px; border-bottom: 1px solid var(--border, #d8dad7);
|
|
25
|
+
background: var(--bg-paper, #fff);
|
|
26
|
+
}
|
|
27
|
+
.pe-toolbar h2 { font-size: 14px; margin: 0; font-weight: 650; color: var(--fg, #1f2320); }
|
|
28
|
+
.pe-spacer { flex: 1; }
|
|
29
|
+
.pe-hint { font-size: 12px; color: var(--fg-muted, #6b6f6b); }
|
|
30
|
+
.pe-hint code {
|
|
31
|
+
background: var(--bg-subtle, #f1f2f0); padding: 1px 5px; border-radius: 4px;
|
|
32
|
+
font-size: 11px;
|
|
33
|
+
}
|
|
34
|
+
.pe-btn {
|
|
35
|
+
font: inherit; font-size: 13px; cursor: pointer;
|
|
36
|
+
border: 1px solid var(--border, #d8dad7); background: var(--bg-paper, #fff);
|
|
37
|
+
color: var(--fg, #1f2320); padding: 5px 11px; border-radius: 7px;
|
|
38
|
+
}
|
|
39
|
+
.pe-btn:hover { background: var(--bg-subtle, #f1f2f0); }
|
|
40
|
+
.pe-btn-accent { background: var(--accent, #00663c); color: var(--accent-fg, #fff); border-color: transparent; }
|
|
41
|
+
.pe-btn-accent:hover { background: var(--accent-hover, #00552f); }
|
|
42
|
+
|
|
43
|
+
/* Tab bar */
|
|
44
|
+
.pe-tabs {
|
|
45
|
+
display: flex; align-items: stretch; gap: 2px; overflow-x: auto;
|
|
46
|
+
background: var(--bg-subtle, #f1f2f0); border-bottom: 1px solid var(--border, #d8dad7);
|
|
47
|
+
padding: 0 8px; min-height: 40px;
|
|
48
|
+
}
|
|
49
|
+
.pe-tab {
|
|
50
|
+
display: flex; align-items: center; gap: 7px; cursor: pointer;
|
|
51
|
+
padding: 0 12px; font-size: 13px; color: var(--fg-muted, #6b6f6b);
|
|
52
|
+
border: 1px solid transparent; border-bottom: none; white-space: nowrap;
|
|
53
|
+
max-width: 240px; position: relative; top: 1px;
|
|
54
|
+
}
|
|
55
|
+
.pe-tab:hover { color: var(--fg, #1f2320); }
|
|
56
|
+
.pe-tab.is-open {
|
|
57
|
+
background: var(--bg-paper, #fff); color: var(--fg, #1f2320);
|
|
58
|
+
border-color: var(--border, #d8dad7); border-top-left-radius: 8px; border-top-right-radius: 8px;
|
|
59
|
+
font-weight: 600;
|
|
60
|
+
}
|
|
61
|
+
.pe-tab-title { overflow: hidden; text-overflow: ellipsis; max-width: 180px; }
|
|
62
|
+
.pe-tab-dot {
|
|
63
|
+
width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #00663c);
|
|
64
|
+
flex: none; visibility: hidden;
|
|
65
|
+
}
|
|
66
|
+
.pe-tab.is-active .pe-tab-dot { visibility: visible; }
|
|
67
|
+
.pe-tab-close {
|
|
68
|
+
border: none; background: none; cursor: pointer; color: var(--fg-subtle, #999);
|
|
69
|
+
font-size: 15px; line-height: 1; padding: 0 2px; border-radius: 4px;
|
|
70
|
+
}
|
|
71
|
+
.pe-tab-close:hover { background: var(--bg-subtle, #e6e8e5); color: var(--danger, #b3261e); }
|
|
72
|
+
.pe-newtab {
|
|
73
|
+
border: none; background: none; cursor: pointer; font-size: 18px; line-height: 1;
|
|
74
|
+
color: var(--fg-muted, #6b6f6b); padding: 0 12px; align-self: center;
|
|
75
|
+
}
|
|
76
|
+
.pe-newtab:hover { color: var(--accent, #00663c); }
|
|
77
|
+
|
|
78
|
+
/* Editor row */
|
|
79
|
+
.pe-editrow { display: flex; align-items: center; gap: 10px; padding: 8px 16px;
|
|
80
|
+
border-bottom: 1px solid var(--border-subtle, #e6e8e5); background: var(--bg-paper, #fff); }
|
|
81
|
+
.pe-title-input {
|
|
82
|
+
font: inherit; font-size: 14px; font-weight: 600; color: var(--fg, #1f2320);
|
|
83
|
+
border: 1px solid transparent; background: transparent; padding: 4px 6px; border-radius: 6px;
|
|
84
|
+
min-width: 220px; flex: 1; max-width: 460px;
|
|
85
|
+
}
|
|
86
|
+
.pe-title-input:hover { border-color: var(--border, #d8dad7); }
|
|
87
|
+
.pe-title-input:focus { border-color: var(--accent, #00663c); outline: none; background: var(--bg-subtle, #f7f8f6); }
|
|
88
|
+
.pe-save-state { font-size: 12px; color: var(--fg-subtle, #999); min-width: 70px; }
|
|
89
|
+
.pe-view-toggle { display: flex; border: 1px solid var(--border, #d8dad7); border-radius: 7px; overflow: hidden; }
|
|
90
|
+
.pe-view-toggle button {
|
|
91
|
+
font: inherit; font-size: 12px; cursor: pointer; border: none; background: var(--bg-paper, #fff);
|
|
92
|
+
color: var(--fg-muted, #6b6f6b); padding: 4px 10px;
|
|
93
|
+
}
|
|
94
|
+
.pe-view-toggle button.is-on { background: var(--accent, #00663c); color: var(--accent-fg, #fff); }
|
|
95
|
+
|
|
96
|
+
/* Editor body — split */
|
|
97
|
+
.pe-main { flex: 1; display: flex; min-height: 0; }
|
|
98
|
+
.pe-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
|
99
|
+
.pe-pane.is-hidden { display: none; }
|
|
100
|
+
.pe-pane-label {
|
|
101
|
+
font-size: 11px; letter-spacing: .04em; text-transform: uppercase;
|
|
102
|
+
color: var(--fg-subtle, #999); padding: 6px 14px 2px;
|
|
103
|
+
}
|
|
104
|
+
#pe-source {
|
|
105
|
+
flex: 1; resize: none; border: none; outline: none; padding: 12px 16px;
|
|
106
|
+
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 13px; line-height: 1.55;
|
|
107
|
+
color: var(--fg, #1f2320); background: var(--bg, #fff); tab-size: 2;
|
|
108
|
+
}
|
|
109
|
+
.pe-preview {
|
|
110
|
+
flex: 1; overflow: auto; padding: 12px 18px; border-left: 1px solid var(--border-subtle, #e6e8e5);
|
|
111
|
+
background: var(--bg-paper, #fff); color: var(--fg, #1f2320); font-size: 14px; line-height: 1.6;
|
|
112
|
+
}
|
|
113
|
+
.pe-preview pre { background: var(--bg-subtle, #f1f2f0); padding: 10px 12px; border-radius: 8px; overflow: auto; }
|
|
114
|
+
.pe-empty { margin: auto; text-align: center; color: var(--fg-muted, #6b6f6b); padding: 40px; }
|
|
115
|
+
|
|
116
|
+
/* Toast for Claude live-enhance */
|
|
117
|
+
.pe-toast {
|
|
118
|
+
position: fixed; bottom: 18px; right: 18px; z-index: 50;
|
|
119
|
+
background: var(--accent, #00663c); color: var(--accent-fg, #fff);
|
|
120
|
+
padding: 10px 14px; border-radius: 9px; font-size: 13px; box-shadow: 0 6px 24px rgba(0,0,0,.2);
|
|
121
|
+
display: flex; gap: 10px; align-items: center; opacity: 0; transform: translateY(8px);
|
|
122
|
+
transition: opacity .2s, transform .2s; pointer-events: none;
|
|
123
|
+
}
|
|
124
|
+
.pe-toast.is-show { opacity: 1; transform: translateY(0); pointer-events: auto; }
|
|
125
|
+
.pe-toast button {
|
|
126
|
+
font: inherit; font-size: 12px; cursor: pointer; border: 1px solid rgba(255,255,255,.5);
|
|
127
|
+
background: transparent; color: inherit; padding: 3px 9px; border-radius: 6px;
|
|
128
|
+
}
|
|
129
|
+
</style>
|
|
130
|
+
</head>
|
|
131
|
+
<body class="cp-body pe-body">
|
|
132
|
+
<header>
|
|
133
|
+
<div class="header-left">
|
|
134
|
+
<svg class="brand-mark" viewBox="0 0 55 56" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
135
|
+
<path d="M34.3254 13.7847H48.0563H54.9235V0H48.0563H34.3254H20.5946H6.8637H0V13.7847H6.8637H20.5946V27.5693H34.3254V13.7847Z" fill="#00663C"></path>
|
|
136
|
+
<path d="M54.9235 41.354V27.5693H48.0563H34.3254V41.354H20.5946V27.5693H6.8637H0V41.354V55.1387H20.5946H34.3254H54.9235V41.354Z" fill="#00663C"></path>
|
|
137
|
+
</svg>
|
|
138
|
+
<h1><span>Dovetail</span> Prompt Editor</h1>
|
|
139
|
+
</div>
|
|
140
|
+
<div class="header-right">
|
|
141
|
+
<a class="cp-nav-link" href="/">← Update Sets</a>
|
|
142
|
+
<a class="cp-nav-link" href="/claude-plans">Claude Plans →</a>
|
|
143
|
+
<button class="cp-theme-toggle" id="cp-theme-toggle" type="button"
|
|
144
|
+
aria-label="Toggle dark mode" title="Toggle dark mode"></button>
|
|
145
|
+
</div>
|
|
146
|
+
</header>
|
|
147
|
+
|
|
148
|
+
<div class="pe-toolbar">
|
|
149
|
+
<h2>Prompts</h2>
|
|
150
|
+
<span class="pe-spacer"></span>
|
|
151
|
+
<span class="pe-hint">The open tab is what Claude edits — ask a session: <code>enhance the prompt I'm working on</code></span>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<nav class="pe-tabs" id="pe-tabs" aria-label="Prompt tabs"></nav>
|
|
155
|
+
|
|
156
|
+
<div class="pe-editrow" id="pe-editrow" hidden>
|
|
157
|
+
<input class="pe-title-input" id="pe-title" type="text" placeholder="Untitled prompt"
|
|
158
|
+
aria-label="Prompt title" maxlength="200" />
|
|
159
|
+
<span class="pe-save-state" id="pe-save-state"></span>
|
|
160
|
+
<span class="pe-spacer"></span>
|
|
161
|
+
<div class="pe-view-toggle" role="group" aria-label="View mode">
|
|
162
|
+
<button id="pe-view-edit" type="button">Edit</button>
|
|
163
|
+
<button id="pe-view-split" type="button" class="is-on">Split</button>
|
|
164
|
+
<button id="pe-view-preview" type="button">Preview</button>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div class="pe-main" id="pe-main">
|
|
169
|
+
<div class="pe-pane" id="pe-pane-source">
|
|
170
|
+
<div class="pe-pane-label">HTML source</div>
|
|
171
|
+
<textarea id="pe-source" spellcheck="false"
|
|
172
|
+
placeholder="Write your prompt here — HTML/XML prompt-template markup is fine. Then ask a Claude Code session to enhance it; the result appears here live."></textarea>
|
|
173
|
+
</div>
|
|
174
|
+
<div class="pe-pane" id="pe-pane-preview">
|
|
175
|
+
<div class="pe-pane-label">Preview</div>
|
|
176
|
+
<div class="pe-preview" id="pe-preview"></div>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div class="pe-toast" id="pe-toast" role="status">
|
|
181
|
+
<span id="pe-toast-msg">Claude updated this prompt</span>
|
|
182
|
+
<button id="pe-toast-load" type="button">Load it</button>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.5/dist/purify.min.js"></script>
|
|
186
|
+
<script src="/prompt-editor.js"></script>
|
|
187
|
+
</body>
|
|
188
|
+
</html>
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/* Dovetail Prompt Editor — multi-tab HTML prompt drafts.
|
|
2
|
+
*
|
|
3
|
+
* Drafts persist to the claude-plans store via /api/prompt-drafts. The tab you
|
|
4
|
+
* have open is the "active" draft a Claude Code session reads with
|
|
5
|
+
* get_active_prompt_draft; when the session writes an enhanced prompt back with
|
|
6
|
+
* update_prompt_draft, the file changes, the server emits a draft:upsert SSE
|
|
7
|
+
* frame, and we merge it into the matching tab live. */
|
|
8
|
+
(function () {
|
|
9
|
+
"use strict";
|
|
10
|
+
|
|
11
|
+
var state = {
|
|
12
|
+
drafts: [], // [{id,title,content,updated_at,...}]
|
|
13
|
+
activeId: null, // server-side active pointer (the Claude-referenced tab)
|
|
14
|
+
openId: null, // tab currently shown in the editor (mirrors activeId)
|
|
15
|
+
dirty: false, // unsaved local edits in the source textarea
|
|
16
|
+
pendingUpsert: null, // a draft:upsert for the open tab deferred while typing
|
|
17
|
+
view: "split" // edit | split | preview
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
var els = {
|
|
21
|
+
tabs: document.getElementById("pe-tabs"),
|
|
22
|
+
editrow: document.getElementById("pe-editrow"),
|
|
23
|
+
title: document.getElementById("pe-title"),
|
|
24
|
+
saveState: document.getElementById("pe-save-state"),
|
|
25
|
+
source: document.getElementById("pe-source"),
|
|
26
|
+
preview: document.getElementById("pe-preview"),
|
|
27
|
+
paneSource: document.getElementById("pe-pane-source"),
|
|
28
|
+
panePreview: document.getElementById("pe-pane-preview"),
|
|
29
|
+
main: document.getElementById("pe-main"),
|
|
30
|
+
viewEdit: document.getElementById("pe-view-edit"),
|
|
31
|
+
viewSplit: document.getElementById("pe-view-split"),
|
|
32
|
+
viewPreview: document.getElementById("pe-view-preview"),
|
|
33
|
+
toast: document.getElementById("pe-toast"),
|
|
34
|
+
toastMsg: document.getElementById("pe-toast-msg"),
|
|
35
|
+
toastLoad: document.getElementById("pe-toast-load"),
|
|
36
|
+
themeToggle: document.getElementById("cp-theme-toggle")
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ---- API helpers ---------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
function api(method, url, body) {
|
|
42
|
+
var opts = { method: method, headers: {} };
|
|
43
|
+
if (body !== undefined) {
|
|
44
|
+
opts.headers["Content-Type"] = "application/json";
|
|
45
|
+
opts.body = JSON.stringify(body);
|
|
46
|
+
}
|
|
47
|
+
return fetch(url, opts).then(function (res) {
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
return res.json().catch(function () { return {}; }).then(function (e) {
|
|
50
|
+
throw new Error(e.error || e.message || ("HTTP " + res.status));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return res.status === 204 ? null : res.json();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadAll() {
|
|
58
|
+
return api("GET", "/api/prompt-drafts").then(function (data) {
|
|
59
|
+
state.drafts = (data && data.drafts) || [];
|
|
60
|
+
state.activeId = data ? data.active_id : null;
|
|
61
|
+
if (!state.drafts.length) {
|
|
62
|
+
return createDraft();
|
|
63
|
+
}
|
|
64
|
+
// Open the server-active tab, else the first.
|
|
65
|
+
var openTarget = state.activeId && findDraft(state.activeId) ? state.activeId : state.drafts[0].id;
|
|
66
|
+
openDraft(openTarget, true);
|
|
67
|
+
renderTabs();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function findDraft(id) {
|
|
72
|
+
for (var i = 0; i < state.drafts.length; i++) if (state.drafts[i].id === id) return state.drafts[i];
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---- Rendering -----------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
function renderTabs() {
|
|
79
|
+
els.tabs.textContent = "";
|
|
80
|
+
state.drafts.forEach(function (d) {
|
|
81
|
+
var tab = document.createElement("div");
|
|
82
|
+
tab.className = "pe-tab";
|
|
83
|
+
if (d.id === state.openId) tab.classList.add("is-open");
|
|
84
|
+
if (d.id === state.activeId) tab.classList.add("is-active");
|
|
85
|
+
tab.setAttribute("role", "button");
|
|
86
|
+
tab.title = d.id === state.activeId ? "Active — Claude edits this tab" : "Open tab";
|
|
87
|
+
|
|
88
|
+
var dot = document.createElement("span");
|
|
89
|
+
dot.className = "pe-tab-dot";
|
|
90
|
+
dot.title = "Active for Claude";
|
|
91
|
+
tab.appendChild(dot);
|
|
92
|
+
|
|
93
|
+
var title = document.createElement("span");
|
|
94
|
+
title.className = "pe-tab-title";
|
|
95
|
+
title.textContent = d.title || "Untitled prompt";
|
|
96
|
+
tab.appendChild(title);
|
|
97
|
+
|
|
98
|
+
var close = document.createElement("button");
|
|
99
|
+
close.className = "pe-tab-close";
|
|
100
|
+
close.type = "button";
|
|
101
|
+
close.textContent = "×";
|
|
102
|
+
close.title = "Close tab";
|
|
103
|
+
close.setAttribute("aria-label", "Close " + (d.title || "tab"));
|
|
104
|
+
close.addEventListener("click", function (ev) {
|
|
105
|
+
ev.stopPropagation();
|
|
106
|
+
deleteDraft(d.id);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
tab.addEventListener("click", function () { openDraft(d.id); });
|
|
110
|
+
tab.appendChild(close);
|
|
111
|
+
els.tabs.appendChild(tab);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
var add = document.createElement("button");
|
|
115
|
+
add.className = "pe-newtab";
|
|
116
|
+
add.type = "button";
|
|
117
|
+
add.textContent = "+";
|
|
118
|
+
add.title = "New prompt tab";
|
|
119
|
+
add.setAttribute("aria-label", "New prompt tab");
|
|
120
|
+
add.addEventListener("click", function () { createDraft(); });
|
|
121
|
+
els.tabs.appendChild(add);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function renderEditor() {
|
|
125
|
+
var d = findDraft(state.openId);
|
|
126
|
+
if (!d) {
|
|
127
|
+
els.editrow.hidden = true;
|
|
128
|
+
els.main.style.display = "none";
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
els.editrow.hidden = false;
|
|
132
|
+
els.main.style.display = "flex";
|
|
133
|
+
els.title.value = d.title || "";
|
|
134
|
+
els.source.value = d.content || "";
|
|
135
|
+
state.dirty = false;
|
|
136
|
+
setSaveState("saved");
|
|
137
|
+
renderPreview();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function renderPreview() {
|
|
141
|
+
var raw = els.source.value || "";
|
|
142
|
+
var clean = window.DOMPurify ? window.DOMPurify.sanitize(raw) : escapeHtml(raw);
|
|
143
|
+
els.preview.innerHTML = clean ||
|
|
144
|
+
'<div class="pe-empty">Nothing to preview yet.</div>';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function escapeHtml(s) {
|
|
148
|
+
return String(s).replace(/[&<>"']/g, function (c) {
|
|
149
|
+
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function setSaveState(s) {
|
|
154
|
+
if (s === "saving") els.saveState.textContent = "Saving…";
|
|
155
|
+
else if (s === "saved") els.saveState.textContent = "Saved";
|
|
156
|
+
else if (s === "error") els.saveState.textContent = "Save failed";
|
|
157
|
+
else els.saveState.textContent = "";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- Mutations -----------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
function createDraft() {
|
|
163
|
+
return api("POST", "/api/prompt-drafts", { title: "Untitled prompt", content: "" }).then(function (d) {
|
|
164
|
+
state.drafts.push(d);
|
|
165
|
+
openDraft(d.id);
|
|
166
|
+
renderTabs();
|
|
167
|
+
els.title.focus();
|
|
168
|
+
els.title.select();
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function openDraft(id, skipActivate) {
|
|
173
|
+
if (!findDraft(id)) return;
|
|
174
|
+
state.openId = id;
|
|
175
|
+
state.pendingUpsert = null;
|
|
176
|
+
hideToast();
|
|
177
|
+
renderEditor();
|
|
178
|
+
renderTabs();
|
|
179
|
+
// Opening a tab makes it the Claude-referenced active draft.
|
|
180
|
+
if (!skipActivate && id !== state.activeId) {
|
|
181
|
+
api("POST", "/api/prompt-drafts/active", { id: id }).then(function (r) {
|
|
182
|
+
state.activeId = r.active_id;
|
|
183
|
+
renderTabs();
|
|
184
|
+
}).catch(function () {});
|
|
185
|
+
} else if (skipActivate) {
|
|
186
|
+
state.activeId = state.activeId || id;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function deleteDraft(id) {
|
|
191
|
+
var d = findDraft(id);
|
|
192
|
+
var label = d && d.title ? d.title : "this prompt";
|
|
193
|
+
if (!window.confirm("Close and delete “" + label + "”?")) return;
|
|
194
|
+
api("DELETE", "/api/prompt-drafts/" + id).then(function (r) {
|
|
195
|
+
state.drafts = state.drafts.filter(function (x) { return x.id !== id; });
|
|
196
|
+
state.activeId = r.active_id;
|
|
197
|
+
if (state.openId === id) {
|
|
198
|
+
if (r.active_id && findDraft(r.active_id)) openDraft(r.active_id, true);
|
|
199
|
+
else if (state.drafts.length) openDraft(state.drafts[0].id, true);
|
|
200
|
+
else { state.openId = null; renderEditor(); }
|
|
201
|
+
}
|
|
202
|
+
renderTabs();
|
|
203
|
+
}).catch(function (e) { window.alert("Delete failed: " + e.message); });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
var saveTimer = null;
|
|
207
|
+
function scheduleSave() {
|
|
208
|
+
state.dirty = true;
|
|
209
|
+
setSaveState("saving");
|
|
210
|
+
if (saveTimer) clearTimeout(saveTimer);
|
|
211
|
+
saveTimer = setTimeout(flushSave, 600);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function flushSave() {
|
|
215
|
+
var id = state.openId;
|
|
216
|
+
if (!id) return;
|
|
217
|
+
var body = { title: els.title.value, content: els.source.value };
|
|
218
|
+
api("PUT", "/api/prompt-drafts/" + id, body).then(function (d) {
|
|
219
|
+
var local = findDraft(id);
|
|
220
|
+
if (local) { local.title = d.title; local.content = d.content; local.updated_at = d.updated_at; }
|
|
221
|
+
state.dirty = false;
|
|
222
|
+
setSaveState("saved");
|
|
223
|
+
renderTabs(); // reflect a renamed title in its tab
|
|
224
|
+
}).catch(function () { setSaveState("error"); });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---- SSE live-reload -----------------------------------------------------
|
|
228
|
+
|
|
229
|
+
function connectStream() {
|
|
230
|
+
var es = new EventSource("/api/claude-plans/stream");
|
|
231
|
+
es.addEventListener("draft:upsert", function (ev) {
|
|
232
|
+
var d = JSON.parse(ev.data).draft;
|
|
233
|
+
if (!d) return;
|
|
234
|
+
var local = findDraft(d.id);
|
|
235
|
+
if (local) { local.title = d.title; local.updated_at = d.updated_at; }
|
|
236
|
+
else { state.drafts.push(d); }
|
|
237
|
+
renderTabs();
|
|
238
|
+
|
|
239
|
+
if (d.id === state.openId) {
|
|
240
|
+
// Same content we already show (our own autosave echo) — ignore.
|
|
241
|
+
if (d.content === els.source.value) { if (local) local.content = d.content; return; }
|
|
242
|
+
if (state.dirty || document.activeElement === els.source) {
|
|
243
|
+
// Don't clobber in-progress typing — offer to load instead.
|
|
244
|
+
state.pendingUpsert = d;
|
|
245
|
+
showToast("Claude updated this prompt");
|
|
246
|
+
} else {
|
|
247
|
+
if (local) local.content = d.content;
|
|
248
|
+
els.source.value = d.content || "";
|
|
249
|
+
renderPreview();
|
|
250
|
+
flashToast("Claude enhanced this prompt");
|
|
251
|
+
}
|
|
252
|
+
} else if (local) {
|
|
253
|
+
local.content = d.content;
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
es.addEventListener("draft:delete", function (ev) {
|
|
257
|
+
var id = JSON.parse(ev.data).id;
|
|
258
|
+
state.drafts = state.drafts.filter(function (x) { return x.id !== id; });
|
|
259
|
+
if (state.openId === id) {
|
|
260
|
+
state.openId = state.drafts.length ? state.drafts[0].id : null;
|
|
261
|
+
if (state.openId) openDraft(state.openId, true); else renderEditor();
|
|
262
|
+
}
|
|
263
|
+
renderTabs();
|
|
264
|
+
});
|
|
265
|
+
es.addEventListener("draft:active", function (ev) {
|
|
266
|
+
state.activeId = JSON.parse(ev.data).active_id;
|
|
267
|
+
renderTabs();
|
|
268
|
+
});
|
|
269
|
+
es.onerror = function () { /* EventSource auto-reconnects */ };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---- Toast ---------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
var toastTimer = null;
|
|
275
|
+
function showToast(msg) {
|
|
276
|
+
els.toastMsg.textContent = msg;
|
|
277
|
+
els.toastLoad.hidden = false;
|
|
278
|
+
els.toast.classList.add("is-show");
|
|
279
|
+
}
|
|
280
|
+
function flashToast(msg) {
|
|
281
|
+
els.toastMsg.textContent = msg;
|
|
282
|
+
els.toastLoad.hidden = true;
|
|
283
|
+
els.toast.classList.add("is-show");
|
|
284
|
+
if (toastTimer) clearTimeout(toastTimer);
|
|
285
|
+
toastTimer = setTimeout(hideToast, 2600);
|
|
286
|
+
}
|
|
287
|
+
function hideToast() { els.toast.classList.remove("is-show"); }
|
|
288
|
+
|
|
289
|
+
// ---- View toggle ---------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
function setView(v) {
|
|
292
|
+
state.view = v;
|
|
293
|
+
els.paneSource.classList.toggle("is-hidden", v === "preview");
|
|
294
|
+
els.panePreview.classList.toggle("is-hidden", v === "edit");
|
|
295
|
+
els.viewEdit.classList.toggle("is-on", v === "edit");
|
|
296
|
+
els.viewSplit.classList.toggle("is-on", v === "split");
|
|
297
|
+
els.viewPreview.classList.toggle("is-on", v === "preview");
|
|
298
|
+
if (v !== "edit") renderPreview();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---- Theme toggle (shared cp-theme key) ----------------------------------
|
|
302
|
+
|
|
303
|
+
function initTheme() {
|
|
304
|
+
if (!els.themeToggle) return;
|
|
305
|
+
els.themeToggle.addEventListener("click", function () {
|
|
306
|
+
var cur = document.documentElement.getAttribute("data-theme");
|
|
307
|
+
var next = cur === "dark" ? "light" : "dark";
|
|
308
|
+
document.documentElement.setAttribute("data-theme", next);
|
|
309
|
+
try { localStorage.setItem("cp-theme", next); } catch (e) {}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---- Wire up -------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
els.source.addEventListener("input", function () { scheduleSave(); renderPreview(); });
|
|
316
|
+
els.title.addEventListener("input", function () { scheduleSave(); });
|
|
317
|
+
els.viewEdit.addEventListener("click", function () { setView("edit"); });
|
|
318
|
+
els.viewSplit.addEventListener("click", function () { setView("split"); });
|
|
319
|
+
els.viewPreview.addEventListener("click", function () { setView("preview"); });
|
|
320
|
+
els.toastLoad.addEventListener("click", function () {
|
|
321
|
+
if (state.pendingUpsert) {
|
|
322
|
+
var d = state.pendingUpsert;
|
|
323
|
+
var local = findDraft(d.id);
|
|
324
|
+
if (local) local.content = d.content;
|
|
325
|
+
els.source.value = d.content || "";
|
|
326
|
+
state.dirty = false;
|
|
327
|
+
setSaveState("saved");
|
|
328
|
+
renderPreview();
|
|
329
|
+
state.pendingUpsert = null;
|
|
330
|
+
}
|
|
331
|
+
hideToast();
|
|
332
|
+
});
|
|
333
|
+
// Save immediately if the user navigates away mid-edit.
|
|
334
|
+
window.addEventListener("beforeunload", function () { if (state.dirty) flushSave(); });
|
|
335
|
+
|
|
336
|
+
initTheme();
|
|
337
|
+
setView("split");
|
|
338
|
+
loadAll().catch(function (e) {
|
|
339
|
+
els.tabs.innerHTML = '<div class="pe-empty">Could not load drafts: ' + escapeHtml(e.message) + "</div>";
|
|
340
|
+
});
|
|
341
|
+
connectStream();
|
|
342
|
+
})();
|
package/server.js
CHANGED
|
@@ -823,6 +823,14 @@ function isValidSlug(slug) {
|
|
|
823
823
|
return typeof slug === "string" && CLAUDE_PLAN_SLUG.test(slug);
|
|
824
824
|
}
|
|
825
825
|
|
|
826
|
+
// Prompt-draft ids are pd_<8-hex>, written by @tenonhq/dovetail-claude-plans.
|
|
827
|
+
// Re-validated on the read side so a crafted id can't escape the store dir.
|
|
828
|
+
const PROMPT_DRAFT_ID = /^pd_[0-9a-f]{8}$/;
|
|
829
|
+
|
|
830
|
+
function isValidDraftId(id) {
|
|
831
|
+
return typeof id === "string" && PROMPT_DRAFT_ID.test(id);
|
|
832
|
+
}
|
|
833
|
+
|
|
826
834
|
function planFilePath(slug) {
|
|
827
835
|
return path.join(CLAUDE_PLANS_DIR, slug + ".json");
|
|
828
836
|
}
|
|
@@ -938,6 +946,10 @@ function classifyPath(filePath) {
|
|
|
938
946
|
if (parts.length === 2 && parts[0] === "_lint-events" && parts[1].endsWith(".json")) {
|
|
939
947
|
return { kind: "lint", id: parts[1].slice(0, -5) };
|
|
940
948
|
}
|
|
949
|
+
if (parts.length === 2 && parts[0] === "_prompt-drafts" && parts[1].endsWith(".json")) {
|
|
950
|
+
if (parts[1] === "_active.json") return { kind: "draft-active" };
|
|
951
|
+
return { kind: "draft", id: parts[1].slice(0, -5) };
|
|
952
|
+
}
|
|
941
953
|
if (parts.length === 1 && parts[0].endsWith(".json")) {
|
|
942
954
|
return { kind: "plan", slug: parts[0].slice(0, -5) };
|
|
943
955
|
}
|
|
@@ -1015,6 +1027,24 @@ function handleWatcherChange(event, filePath) {
|
|
|
1015
1027
|
}
|
|
1016
1028
|
const lint = safeReadJson(filePath);
|
|
1017
1029
|
if (lint) broadcastClaudePlanEvent("lint:upsert", { lint: lint });
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
if (info.kind === "draft-active") {
|
|
1033
|
+
// The active-tab pointer changed (a draft was made active, or the active
|
|
1034
|
+
// one was deleted and focus advanced). Tell editors which tab is active.
|
|
1035
|
+
const ptr = safeReadJson(filePath);
|
|
1036
|
+
broadcastClaudePlanEvent("draft:active", { active_id: ptr ? ptr.active_id : null });
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
if (info.kind === "draft") {
|
|
1040
|
+
if (event === "unlink") {
|
|
1041
|
+
broadcastClaudePlanEvent("draft:delete", { id: info.id });
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
// Fires when a Claude session writes an enhanced prompt back, or another
|
|
1045
|
+
// editor instance autosaves — the editor merges it into the matching tab.
|
|
1046
|
+
const draft = safeReadJson(filePath);
|
|
1047
|
+
if (draft) broadcastClaudePlanEvent("draft:upsert", { draft: draft });
|
|
1018
1048
|
}
|
|
1019
1049
|
}
|
|
1020
1050
|
|
|
@@ -1043,6 +1073,12 @@ app.get("/claude-plans", function (req, res) {
|
|
|
1043
1073
|
res.sendFile(path.join(__dirname, "public", "claude-plans.html"));
|
|
1044
1074
|
});
|
|
1045
1075
|
|
|
1076
|
+
// Multi-tab HTML prompt editor. Drafts persist to the claude-plans store so a
|
|
1077
|
+
// Claude Code session can read the active tab and write an enhanced prompt back.
|
|
1078
|
+
app.get("/prompt-editor", claudePlansLimiter, function (req, res) {
|
|
1079
|
+
res.sendFile(path.join(__dirname, "public", "prompt-editor.html"));
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1046
1082
|
// Legacy path-segment deep links (/claude-plans/:slug) redirect to the
|
|
1047
1083
|
// query-param form so relative assets resolve against /claude-plans.
|
|
1048
1084
|
app.get("/claude-plans/:slug", function (req, res) {
|
|
@@ -1239,8 +1275,8 @@ function sendTypedError(res, err) {
|
|
|
1239
1275
|
message: err.message
|
|
1240
1276
|
});
|
|
1241
1277
|
}
|
|
1242
|
-
if (err && /
|
|
1243
|
-
return res.status(404).json({ error: "
|
|
1278
|
+
if (err && /not found/i.test(err.message || "")) {
|
|
1279
|
+
return res.status(404).json({ error: "not_found", message: err.message });
|
|
1244
1280
|
}
|
|
1245
1281
|
return res.status(500).json({ error: "internal", message: (err && err.message) || String(err) });
|
|
1246
1282
|
}
|
|
@@ -1374,6 +1410,88 @@ app.delete("/api/claude-plans/:slug", claudePlansLimiter, function (req, res) {
|
|
|
1374
1410
|
}
|
|
1375
1411
|
});
|
|
1376
1412
|
|
|
1413
|
+
// --- Prompt Editor (multi-tab HTML prompt drafts) ---
|
|
1414
|
+
// All writes go through @tenonhq/dovetail-claude-plans' storage layer so the
|
|
1415
|
+
// draft/active-pointer rules live in exactly one place. Each write lands a file
|
|
1416
|
+
// under <root>/_prompt-drafts/, which the chokidar watcher turns into a
|
|
1417
|
+
// draft:upsert / draft:delete / draft:active SSE frame — so the editor (and a
|
|
1418
|
+
// Claude session's enhance write) refresh every connected tab automatically.
|
|
1419
|
+
|
|
1420
|
+
// GET /api/prompt-drafts — list all drafts (oldest-first) + the active id.
|
|
1421
|
+
app.get("/api/prompt-drafts", claudePlansLimiter, function (req, res) {
|
|
1422
|
+
try {
|
|
1423
|
+
res.json(claudePlansLib.listPromptDraftsWithActive());
|
|
1424
|
+
} catch (e) {
|
|
1425
|
+
sendTypedError(res, e);
|
|
1426
|
+
}
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
// POST /api/prompt-drafts — create a new draft (tab). Body: { title?, content? }
|
|
1430
|
+
app.post("/api/prompt-drafts", claudePlansLimiter, express.json(), function (req, res) {
|
|
1431
|
+
try {
|
|
1432
|
+
var body = req.body || {};
|
|
1433
|
+
var draft = claudePlansLib.createPromptDraft({
|
|
1434
|
+
title: body.title,
|
|
1435
|
+
content: body.content,
|
|
1436
|
+
session_id: body.session_id
|
|
1437
|
+
});
|
|
1438
|
+
res.json(draft);
|
|
1439
|
+
} catch (e) {
|
|
1440
|
+
sendTypedError(res, e);
|
|
1441
|
+
}
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
// POST /api/prompt-drafts/active — set the active tab. Body: { id }
|
|
1445
|
+
// Declared before "/:id" so Express doesn't treat "active" as an id.
|
|
1446
|
+
app.post("/api/prompt-drafts/active", claudePlansLimiter, express.json(), function (req, res) {
|
|
1447
|
+
try {
|
|
1448
|
+
var body = req.body || {};
|
|
1449
|
+
if (!isValidDraftId(body.id)) return res.status(400).json({ error: "invalid draft id" });
|
|
1450
|
+
res.json(claudePlansLib.setActivePromptDraft(body.id));
|
|
1451
|
+
} catch (e) {
|
|
1452
|
+
sendTypedError(res, e);
|
|
1453
|
+
}
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
// GET /api/prompt-drafts/:id — read one draft.
|
|
1457
|
+
app.get("/api/prompt-drafts/:id", claudePlansLimiter, function (req, res) {
|
|
1458
|
+
try {
|
|
1459
|
+
var id = req.params.id;
|
|
1460
|
+
if (!isValidDraftId(id)) return res.status(400).json({ error: "invalid draft id" });
|
|
1461
|
+
var draft = claudePlansLib.getPromptDraft(id);
|
|
1462
|
+
if (!draft) return res.status(404).json({ error: "draft not found" });
|
|
1463
|
+
res.json(draft);
|
|
1464
|
+
} catch (e) {
|
|
1465
|
+
sendTypedError(res, e);
|
|
1466
|
+
}
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
// PUT /api/prompt-drafts/:id — autosave title/content. Body: { title?, content? }
|
|
1470
|
+
app.put("/api/prompt-drafts/:id", claudePlansLimiter, express.json(), function (req, res) {
|
|
1471
|
+
try {
|
|
1472
|
+
var id = req.params.id;
|
|
1473
|
+
if (!isValidDraftId(id)) return res.status(400).json({ error: "invalid draft id" });
|
|
1474
|
+
var body = req.body || {};
|
|
1475
|
+
if (body.title === undefined && body.content === undefined) {
|
|
1476
|
+
return res.status(400).json({ error: "nothing to update" });
|
|
1477
|
+
}
|
|
1478
|
+
res.json(claudePlansLib.updatePromptDraft({ id: id, title: body.title, content: body.content }));
|
|
1479
|
+
} catch (e) {
|
|
1480
|
+
sendTypedError(res, e);
|
|
1481
|
+
}
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
// DELETE /api/prompt-drafts/:id — close a tab. Returns the new active id.
|
|
1485
|
+
app.delete("/api/prompt-drafts/:id", claudePlansLimiter, function (req, res) {
|
|
1486
|
+
try {
|
|
1487
|
+
var id = req.params.id;
|
|
1488
|
+
if (!isValidDraftId(id)) return res.status(400).json({ error: "invalid draft id" });
|
|
1489
|
+
res.json(claudePlansLib.deletePromptDraft(id));
|
|
1490
|
+
} catch (e) {
|
|
1491
|
+
sendTypedError(res, e);
|
|
1492
|
+
}
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1377
1495
|
// --- TODO Panel ---
|
|
1378
1496
|
// A drag-to-reorder priority checklist. All writes go through
|
|
1379
1497
|
// @tenonhq/dovetail-todo's storage layer so the ordering/validation rules
|