@gavin-hu/my-pi-agent 0.17.0 → 0.18.0
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/README.md +17 -9
- package/README.zh-CN.md +16 -9
- package/extensions/bytefly/README.md +25 -18
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/settings.ts +25 -18
- package/extensions/file-browse/README.md +60 -21
- package/extensions/file-browse/chrome.ts +83 -0
- package/extensions/file-browse/client.ts +62 -0
- package/extensions/file-browse/config.ts +12 -3
- package/extensions/file-browse/diff-html.ts +94 -0
- package/extensions/file-browse/html.ts +19 -147
- package/extensions/file-browse/live-client.ts +47 -1
- package/extensions/file-browse/live.ts +35 -14
- package/extensions/file-browse/rev.ts +42 -0
- package/extensions/file-browse/router.ts +25 -4
- package/extensions/file-browse/server.ts +7 -0
- package/extensions/file-browse/styles.ts +2 -0
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/job/README.md +7 -5
- package/extensions/job/index.ts +15 -7
- package/extensions/memory/README.md +3 -3
- package/extensions/plan/README.md +280 -86
- package/extensions/plan/commands.ts +56 -5
- package/extensions/plan/feedback.ts +300 -63
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/plan-index.ts +1 -1
- package/extensions/plan/plan-text.ts +18 -1
- package/extensions/plan/plans.ts +165 -57
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +78 -98
- package/extensions/plan/runtime.ts +1 -1
- package/extensions/plan/split.ts +87 -0
- package/extensions/plan/steps.ts +80 -93
- package/extensions/plan/tools/enter.ts +8 -3
- package/extensions/plan/tools/exit.ts +101 -23
- package/extensions/plan/tools/write.ts +8 -3
- package/extensions/plan/tools.ts +1 -1
- package/extensions/plan/tui.ts +51 -8
- package/extensions/plan/types.ts +37 -3
- package/extensions/plan/web-chrome.ts +2 -1
- package/extensions/plan/web-client.ts +121 -177
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-page.ts +103 -17
- package/extensions/plan/web-router.ts +146 -30
- package/extensions/plan/web-share-session.ts +197 -0
- package/extensions/plan/web-share.ts +169 -0
- package/extensions/plan/web-streams.ts +124 -0
- package/extensions/plan/web-tokens.ts +63 -0
- package/extensions/plan/web.ts +193 -45
- package/extensions/question/README.md +3 -0
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/session-bus/README.md +247 -0
- package/extensions/session-bus/commands.ts +165 -0
- package/extensions/session-bus/format.ts +124 -0
- package/extensions/session-bus/index.ts +69 -0
- package/extensions/session-bus/notice.ts +58 -0
- package/extensions/session-bus/protocol.ts +134 -0
- package/extensions/session-bus/result.ts +243 -0
- package/extensions/session-bus/rows.ts +169 -0
- package/extensions/session-bus/runtime.ts +575 -0
- package/extensions/session-bus/store.ts +329 -0
- package/extensions/session-bus/team.ts +102 -0
- package/extensions/session-bus/tools.ts +227 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/README.md +1 -1
- package/extensions/watch/README.md +9 -5
- package/extensions/watch/index.ts +15 -9
- package/extensions/wechat/README.md +20 -33
- package/extensions/wechat/bridge.ts +8 -8
- package/extensions/wechat/commands.ts +2 -2
- package/extensions/wechat/deliver.ts +57 -37
- package/extensions/wechat/interaction.ts +31 -16
- package/extensions/wechat/notices.ts +7 -7
- package/extensions/wechat/peer-worker.ts +19 -34
- package/extensions/wechat/presence.ts +3 -4
- package/extensions/wechat/prompt.ts +39 -24
- package/extensions/wechat/reply-pipeline.ts +12 -4
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/status.ts +1 -3
- package/lib/README.md +6 -2
- package/lib/claims/overlap.ts +43 -0
- package/lib/http/router.ts +40 -13
- package/lib/http/server.ts +27 -13
- package/lib/interaction.ts +10 -0
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/plans/sections.ts +172 -0
- package/lib/plans/setting.ts +52 -0
- package/lib/plans/split.ts +215 -0
- package/lib/plans/store.ts +19 -0
- package/lib/plans/team.ts +151 -0
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +52 -33
- package/lib/requirements.ts +1 -2
- package/lib/tui/README.md +16 -2
- package/lib/tui/dock-screen.ts +85 -17
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/web/components.ts +1 -1
- package/package.json +3 -2
- package/skills/init/SKILL.md +80 -45
- package/skills/init/references/agents-md-quality.md +26 -2
- package/skills/init/references/interview-questions.md +36 -14
- package/skills/next/SKILL.md +78 -53
- package/skills/parallel-dev/SKILL.md +150 -0
- package/extensions/plan/config.ts +0 -27
- package/extensions/wechat/qrcode.ts +0 -46
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
import { dialogScript, keysDialogScript, THEME_TOGGLE_JS } from "../../lib/web/index.ts";
|
|
17
17
|
import { WEB_CHROME_JS } from "./web-chrome.ts";
|
|
18
|
+
import { WEB_COMMENTS_JS } from "./web-comments.ts";
|
|
19
|
+
import { WEB_COLLAB_JS } from "./web-collab.ts";
|
|
18
20
|
import { WEB_MARKDOWN_JS } from "./web-markdown.ts";
|
|
19
21
|
|
|
20
22
|
export const REVIEW_CLIENT_JS = String.raw`(function () {
|
|
@@ -30,7 +32,18 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
|
|
|
30
32
|
}
|
|
31
33
|
var token = typeof data.token === "string" ? data.token : "";
|
|
32
34
|
var limits = data.limits || fallback.limits;
|
|
35
|
+
// Who the server says is looking: the owner approves, a reviewer comments.
|
|
36
|
+
var role = data.role === "reviewer" ? "reviewer" : "owner";
|
|
37
|
+
var reviewerName = typeof data.author === "string" ? data.author : "";
|
|
38
|
+
if (!reviewerName) {
|
|
39
|
+
try { reviewerName = window.localStorage.getItem("bf-review-name") || ""; } catch (error) { reviewerName = ""; }
|
|
40
|
+
}
|
|
41
|
+
// Set once the server has bound a name to this link, which makes it final.
|
|
42
|
+
var reviewerBound = typeof data.author === "string" && !!data.author;
|
|
33
43
|
var DRAFT_REFUSAL = "Add or discard the comment you are writing.";
|
|
44
|
+
// What a body-less conflict says. A 409 the server explained is reported in its
|
|
45
|
+
// own words: a plan that moved and a link bound to another name both land here.
|
|
46
|
+
var PLAN_MOVED = "The plan changed on disk \u2014 reload to review again.";
|
|
34
47
|
var annotations = Array.isArray(data.annotations) ? data.annotations.slice() : [];
|
|
35
48
|
var saved = false;
|
|
36
49
|
var approved = false;
|
|
@@ -49,6 +62,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
|
|
|
49
62
|
var stateEl = document.getElementById("review-state");
|
|
50
63
|
var approveButton = document.getElementById("approve");
|
|
51
64
|
var saveButton = document.getElementById("save");
|
|
65
|
+
var postButton = document.getElementById("post");
|
|
52
66
|
var cancelButton = document.getElementById("cancel");
|
|
53
67
|
var outlineList = document.getElementById("outline-list");
|
|
54
68
|
var progressEl = document.getElementById("progress-fill");
|
|
@@ -61,6 +75,10 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
|
|
|
61
75
|
|
|
62
76
|
${WEB_CHROME_JS}
|
|
63
77
|
|
|
78
|
+
${WEB_COMMENTS_JS}
|
|
79
|
+
|
|
80
|
+
${WEB_COLLAB_JS}
|
|
81
|
+
|
|
64
82
|
${WEB_MARKDOWN_JS}
|
|
65
83
|
function closeComposer() {
|
|
66
84
|
var open = planEl ? planEl.querySelector(".bf-composer") : null;
|
|
@@ -71,164 +89,9 @@ ${WEB_MARKDOWN_JS}
|
|
|
71
89
|
// The only status a composer leaves behind is the refusal to leave it; once
|
|
72
90
|
// the draft is gone that sentence is stale.
|
|
73
91
|
if (statusEl && statusEl.textContent === DRAFT_REFUSAL) setStatus("");
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
var selection = window.getSelection && window.getSelection();
|
|
78
|
-
if (!selection || selection.isCollapsed) return "";
|
|
79
|
-
var range = selection.rangeCount ? selection.getRangeAt(0) : null;
|
|
80
|
-
if (!range || !wrap.contains(range.commonAncestorContainer)) return "";
|
|
81
|
-
return selection.toString().replace(/\s+/g, " ").trim().slice(0, limits.quote);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function commentsBoxAfter(wrap) {
|
|
85
|
-
var next = wrap.nextElementSibling;
|
|
86
|
-
if (next && next.classList && next.classList.contains("bf-comments")) return next;
|
|
87
|
-
var box = el("div", "bf-comments");
|
|
88
|
-
wrap.insertAdjacentElement("afterend", box);
|
|
89
|
-
return box;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Enter submits a comment; Shift+Enter is left for the browser.
|
|
93
|
-
function submitOnEnter(area, submit) {
|
|
94
|
-
area.addEventListener("keydown", function (event) {
|
|
95
|
-
// An IME uses Enter to commit a composition; submitting then would send a
|
|
96
|
-
// half-typed comment.
|
|
97
|
-
if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
|
|
98
|
-
event.preventDefault();
|
|
99
|
-
submit();
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function openComposer(wrap, block, quote) {
|
|
104
|
-
closeComposer();
|
|
105
|
-
wrap.classList.add("bf-block--composing");
|
|
106
|
-
var composer = el("div", "bf-composer");
|
|
107
|
-
if (quote) {
|
|
108
|
-
var chip = el("div", "bf-quote-chip");
|
|
109
|
-
chip.textContent = "\u201c" + quote + "\u201d";
|
|
110
|
-
composer.appendChild(chip);
|
|
111
|
-
}
|
|
112
|
-
var area = el("textarea", "bf-textarea");
|
|
113
|
-
area.placeholder = "Leave feedback on this part of the plan (Enter to add)";
|
|
114
|
-
area.setAttribute("aria-label", "Comment");
|
|
115
|
-
area.maxLength = limits.comment;
|
|
116
|
-
composer.appendChild(area);
|
|
117
|
-
var row = el("div", "bf-composer__row");
|
|
118
|
-
var cancel = el("button"); cancel.type = "button"; cancel.className = "bf-btn"; cancel.textContent = "Cancel";
|
|
119
|
-
cancel.addEventListener("click", closeComposer);
|
|
120
|
-
var add = el("button"); add.type = "button"; add.className = "bf-btn bf-btn--primary"; add.textContent = "Add comment";
|
|
121
|
-
var submit = function () {
|
|
122
|
-
var comment = area.value.replace(/\s+/g, " ").trim();
|
|
123
|
-
if (!comment) { area.focus(); return; }
|
|
124
|
-
if (annotations.length >= limits.comments) { setStatus("Comment limit reached (" + limits.comments + ").", "error"); return; }
|
|
125
|
-
annotations.push(quote ? { anchor: block.start, quote: quote, comment: comment } : { anchor: block.start, comment: comment });
|
|
126
|
-
dirty = true;
|
|
127
|
-
renderPlan();
|
|
128
|
-
};
|
|
129
|
-
add.addEventListener("click", submit);
|
|
130
|
-
submitOnEnter(area, submit);
|
|
131
|
-
row.appendChild(cancel); row.appendChild(add); composer.appendChild(row);
|
|
132
|
-
commentsBoxAfter(wrap).appendChild(composer);
|
|
133
|
-
area.focus();
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function startEdit(card, index) {
|
|
137
|
-
var annotation = annotations[index];
|
|
138
|
-
card.textContent = "";
|
|
139
|
-
var area = el("textarea", "bf-textarea");
|
|
140
|
-
area.value = annotation.comment;
|
|
141
|
-
area.setAttribute("aria-label", "Edit comment");
|
|
142
|
-
area.maxLength = limits.comment;
|
|
143
|
-
card.appendChild(area);
|
|
144
|
-
var row = el("div", "bf-comment__row");
|
|
145
|
-
var cancel = el("button"); cancel.type = "button"; cancel.className = "bf-btn"; cancel.textContent = "Cancel";
|
|
146
|
-
cancel.addEventListener("click", renderPlan);
|
|
147
|
-
var apply = el("button"); apply.type = "button"; apply.className = "bf-btn bf-btn--primary"; apply.textContent = "Save";
|
|
148
|
-
apply.addEventListener("click", function () {
|
|
149
|
-
var comment = area.value.replace(/\s+/g, " ").trim();
|
|
150
|
-
if (!comment) { area.focus(); return; }
|
|
151
|
-
annotation.comment = comment;
|
|
152
|
-
dirty = true;
|
|
153
|
-
renderPlan();
|
|
154
|
-
});
|
|
155
|
-
submitOnEnter(area, function () { apply.click(); });
|
|
156
|
-
row.appendChild(cancel); row.appendChild(apply); card.appendChild(row);
|
|
157
|
-
area.focus();
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Deleting a comment is one click, so it is undoable rather than confirmed: the
|
|
161
|
-
// removed note is held for a few seconds and offered back in the header.
|
|
162
|
-
var undo = null;
|
|
163
|
-
|
|
164
|
-
function clearUndo() {
|
|
165
|
-
if (undo && undo.timer) window.clearTimeout(undo.timer);
|
|
166
|
-
if (undo && undo.button && undo.button.parentNode) undo.button.parentNode.removeChild(undo.button);
|
|
167
|
-
undo = null;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function offerUndo(index, annotation) {
|
|
171
|
-
var controls = document.querySelector(".review-controls");
|
|
172
|
-
clearUndo();
|
|
173
|
-
if (!controls) return;
|
|
174
|
-
var button = el("button", "bf-btn");
|
|
175
|
-
button.type = "button";
|
|
176
|
-
button.id = "undo-delete";
|
|
177
|
-
button.textContent = "Undo delete";
|
|
178
|
-
button.addEventListener("click", function () {
|
|
179
|
-
annotations.splice(Math.min(index, annotations.length), 0, annotation);
|
|
180
|
-
dirty = true;
|
|
181
|
-
clearUndo();
|
|
182
|
-
renderPlan();
|
|
183
|
-
setStatus("Comment restored.", "saved");
|
|
184
|
-
});
|
|
185
|
-
controls.appendChild(button);
|
|
186
|
-
undo = { button: button, timer: window.setTimeout(clearUndo, 6000) };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function removeComment(index) {
|
|
190
|
-
var annotation = annotations[index];
|
|
191
|
-
if (!annotation) return;
|
|
192
|
-
annotations.splice(index, 1);
|
|
193
|
-
dirty = true;
|
|
194
|
-
renderPlan();
|
|
195
|
-
offerUndo(index, annotation);
|
|
196
|
-
setStatus("Comment deleted.");
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function renderCommentCards(box, list) {
|
|
200
|
-
list.forEach(function (annotation) {
|
|
201
|
-
var index = annotations.indexOf(annotation);
|
|
202
|
-
var card = el("div", "bf-comment");
|
|
203
|
-
var head = el("div", "bf-comment__head");
|
|
204
|
-
var label = el("span", "bf-comment__label"); label.textContent = "Review";
|
|
205
|
-
head.appendChild(label);
|
|
206
|
-
head.appendChild(el("span", "bf-comment__spacer"));
|
|
207
|
-
var actions = el("div", "bf-comment__actions");
|
|
208
|
-
var edit = el("button"); edit.type = "button"; edit.className = "bf-btn"; edit.textContent = "Edit";
|
|
209
|
-
edit.addEventListener("click", function () { startEdit(card, index); });
|
|
210
|
-
var remove = el("button"); remove.type = "button"; remove.className = "bf-btn"; remove.textContent = "Delete";
|
|
211
|
-
remove.addEventListener("click", function () { removeComment(index); });
|
|
212
|
-
actions.appendChild(edit); actions.appendChild(remove); head.appendChild(actions);
|
|
213
|
-
card.appendChild(head);
|
|
214
|
-
if (annotation.quote) {
|
|
215
|
-
var quote = el("div", "bf-comment__quote");
|
|
216
|
-
quote.textContent = "\u201c" + annotation.quote + "\u201d";
|
|
217
|
-
card.appendChild(quote);
|
|
218
|
-
}
|
|
219
|
-
var body = el("div", "bf-comment__body");
|
|
220
|
-
body.textContent = annotation.comment;
|
|
221
|
-
card.appendChild(body);
|
|
222
|
-
box.appendChild(card);
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function renderComments(container, anchor) {
|
|
227
|
-
var mine = annotations.filter(function (annotation) { return annotation.anchor === anchor; });
|
|
228
|
-
if (mine.length === 0) return;
|
|
229
|
-
var box = el("div", "bf-comments");
|
|
230
|
-
renderCommentCards(box, mine);
|
|
231
|
-
container.appendChild(box);
|
|
92
|
+
// Closing is how a half-typed comment is discarded, so the stored draft follows
|
|
93
|
+
// it. A close with nothing open must not rewrite — and so drop — a restored one.
|
|
94
|
+
if (open) saveDraft();
|
|
232
95
|
}
|
|
233
96
|
|
|
234
97
|
function observeSections() {
|
|
@@ -338,19 +201,36 @@ ${WEB_MARKDOWN_JS}
|
|
|
338
201
|
setActive(activeIndex + delta);
|
|
339
202
|
}
|
|
340
203
|
|
|
204
|
+
/** The rendered block and its island block for a source line, when both exist. */
|
|
205
|
+
function blockAtLine(line) {
|
|
206
|
+
var wrap = planEl ? planEl.querySelector('.bf-block[data-line="' + line + '"]') : null;
|
|
207
|
+
var block = (data.blocks || []).filter(function (item) { return item.start === line; })[0];
|
|
208
|
+
return wrap && block ? { wrap: wrap, block: block } : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Reopen the composer a reload interrupted, once its block has rendered. */
|
|
212
|
+
function reopenComposer(typing) {
|
|
213
|
+
if (!typing || saved || approved || closed) return;
|
|
214
|
+
var target = blockAtLine(typing.anchor);
|
|
215
|
+
if (target) openComposer(target.wrap, target.block, typing.quote || "", typing.text);
|
|
216
|
+
}
|
|
217
|
+
|
|
341
218
|
function commentOnActive() {
|
|
342
|
-
|
|
343
|
-
if (blocks.length === 0) return;
|
|
219
|
+
if (blockElements().length === 0) return;
|
|
344
220
|
if (activeIndex < 0) setActive(0);
|
|
345
221
|
var wrap = blockElements()[Math.max(0, activeIndex)];
|
|
346
222
|
if (!wrap) return;
|
|
347
|
-
var
|
|
348
|
-
|
|
349
|
-
if (block) openComposer(wrap, block, "");
|
|
223
|
+
var target = blockAtLine(Number(wrap.getAttribute("data-line")));
|
|
224
|
+
if (target) openComposer(target.wrap, target.block, "");
|
|
350
225
|
}
|
|
351
226
|
|
|
352
227
|
function renderPlan() {
|
|
353
228
|
if (!planEl) return;
|
|
229
|
+
// The DOM below is thrown away and rebuilt. Focus is only put back when it was
|
|
230
|
+
// inside this document to begin with: a repaint the stream caused must never
|
|
231
|
+
// pull the keyboard away from the page the reviewer is working in.
|
|
232
|
+
var focused = document.activeElement;
|
|
233
|
+
var keepFocus = !!(focused && focused !== planEl && planEl.contains(focused));
|
|
354
234
|
closeComposer();
|
|
355
235
|
pendingQuote = null;
|
|
356
236
|
var scrollTop = planEl.scrollTop;
|
|
@@ -402,35 +282,52 @@ ${WEB_MARKDOWN_JS}
|
|
|
402
282
|
// place rather than scrolling, so j/k resume where the reviewer was.
|
|
403
283
|
var active = activeIndex >= 0 ? blockElements()[activeIndex] : null;
|
|
404
284
|
if (active) active.classList.add("bf-block--active");
|
|
285
|
+
// The focused element went with the old DOM, so put the tab stop back on the
|
|
286
|
+
// block's comment control: that is where adding, editing, or deleting left it.
|
|
287
|
+
if (keepFocus && active) {
|
|
288
|
+
var stop = active.querySelector(".bf-block__add");
|
|
289
|
+
if (stop) stop.focus();
|
|
290
|
+
}
|
|
405
291
|
renderFacts();
|
|
406
292
|
observeSections();
|
|
407
293
|
updateState();
|
|
408
294
|
}
|
|
409
295
|
|
|
410
296
|
|
|
411
|
-
// Save and
|
|
412
|
-
//
|
|
413
|
-
|
|
297
|
+
// Save, approve, and post share the same POST shape; what differs is whether
|
|
298
|
+
// the review settles. Only the owner's save and approve resolve the session,
|
|
299
|
+
// so only their callback marks the page terminal. A reviewer posts only their
|
|
300
|
+
// own comments, so a page showing everyone's cannot re-post another writer's.
|
|
301
|
+
function submit(url, okText, failText, onOk) {
|
|
302
|
+
var payload = role === "reviewer" ? annotations.filter(isMine) : annotations;
|
|
414
303
|
fetch(url + "?token=" + encodeURIComponent(token), {
|
|
415
304
|
method: "POST",
|
|
416
305
|
headers: { "Content-Type": "application/json" },
|
|
417
|
-
body: JSON.stringify({ annotations:
|
|
306
|
+
body: JSON.stringify({ annotations: payload })
|
|
418
307
|
}).then(function (response) {
|
|
419
308
|
if (response.ok) {
|
|
420
|
-
mark();
|
|
421
309
|
dirty = false;
|
|
422
|
-
|
|
310
|
+
clearDraft();
|
|
423
311
|
setStatus(okText, "saved");
|
|
424
|
-
|
|
312
|
+
if (onOk) onOk();
|
|
425
313
|
updateState();
|
|
426
|
-
|
|
314
|
+
// The server binds a reviewer's name to her link on the first post; adopt it
|
|
315
|
+
// so a second tab of the same link posts under the same name.
|
|
316
|
+
return response.json().then(function (payload) {
|
|
317
|
+
if (role === "reviewer" && payload && typeof payload.author === "string" && payload.author) adoptName(payload.author);
|
|
318
|
+
}, function () {});
|
|
427
319
|
}
|
|
428
320
|
if (response.status === 409) {
|
|
429
|
-
|
|
321
|
+
// A conflict is either a plan that moved under the page or a link already
|
|
322
|
+
// bound to another name; a reload fixes both, and the server names which.
|
|
430
323
|
if (approveButton) approveButton.disabled = true;
|
|
431
324
|
if (saveButton) saveButton.disabled = true;
|
|
325
|
+
if (postButton) postButton.disabled = true;
|
|
432
326
|
offerReload();
|
|
433
|
-
return
|
|
327
|
+
return response.json().then(
|
|
328
|
+
function (payload) { setStatus(payload && payload.error ? payload.error : PLAN_MOVED, "error"); },
|
|
329
|
+
function () { setStatus(PLAN_MOVED, "error"); }
|
|
330
|
+
);
|
|
434
331
|
}
|
|
435
332
|
// Surface the server's own reason (a malformed comment, a stale body)
|
|
436
333
|
// rather than a bare status code.
|
|
@@ -462,17 +359,35 @@ ${WEB_MARKDOWN_JS}
|
|
|
462
359
|
if (saved || approved || closed) return;
|
|
463
360
|
if (guardDraft()) return;
|
|
464
361
|
setStatus("Saving\u2026");
|
|
465
|
-
submit("/api/feedback", "Saved \u2014 return to Pi to continue.", "Could not save: ", function () {
|
|
362
|
+
submit("/api/feedback", "Saved \u2014 return to Pi to continue.", "Could not save: ", function () {
|
|
363
|
+
saved = true;
|
|
364
|
+
settleStream();
|
|
365
|
+
lockActions();
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// A reviewer's write never settles the page: the server keeps the review open,
|
|
370
|
+
// so the page keeps following it and she can keep editing her own comments.
|
|
371
|
+
function postComments() {
|
|
372
|
+
if (closed) return;
|
|
373
|
+
if (guardDraft()) return;
|
|
374
|
+
setStatus("Posting\u2026");
|
|
375
|
+
submit("/api/comment", "Comments posted \u2014 the owner sees them now.", "Could not post: ", null);
|
|
466
376
|
}
|
|
467
377
|
|
|
468
378
|
function approve() {
|
|
469
379
|
if (saved || approved || closed) return;
|
|
470
380
|
setStatus("Approving\u2026");
|
|
471
|
-
submit("/api/approve", "Approved \u2014 return to Pi to continue.", "Could not approve: ", function () {
|
|
381
|
+
submit("/api/approve", "Approved \u2014 return to Pi to continue.", "Could not approve: ", function () {
|
|
382
|
+
approved = true;
|
|
383
|
+
settleStream();
|
|
384
|
+
lockActions();
|
|
385
|
+
});
|
|
472
386
|
}
|
|
473
387
|
|
|
474
388
|
function cancel() {
|
|
475
|
-
|
|
389
|
+
// Closing the review is the owner's decision; a reviewer closes their tab.
|
|
390
|
+
if (role !== "owner" || saved || approved || closed) return;
|
|
476
391
|
if (guardDraft()) return;
|
|
477
392
|
closed = true;
|
|
478
393
|
settleStream();
|
|
@@ -485,6 +400,7 @@ ${WEB_MARKDOWN_JS}
|
|
|
485
400
|
});
|
|
486
401
|
}
|
|
487
402
|
|
|
403
|
+
if (postButton) postButton.addEventListener("click", postComments);
|
|
488
404
|
if (saveButton) saveButton.addEventListener("click", save);
|
|
489
405
|
if (cancelButton) cancelButton.addEventListener("click", cancel);
|
|
490
406
|
|
|
@@ -509,6 +425,28 @@ ${WEB_MARKDOWN_JS}
|
|
|
509
425
|
var approveConfirm = document.getElementById("approve-confirm");
|
|
510
426
|
if (approveConfirm) approveConfirm.addEventListener("click", confirmApprove);
|
|
511
427
|
|
|
428
|
+
// The share and name dialogs. The fragment above holds their behaviour; this
|
|
429
|
+
// is the wiring, which needs the page's own element handles.
|
|
430
|
+
var shareButton = document.getElementById("share");
|
|
431
|
+
var shareDialogNode = document.getElementById("share-dialog");
|
|
432
|
+
var shareLinksEl = document.getElementById("share-links");
|
|
433
|
+
var shareQrEl = document.getElementById("share-qr");
|
|
434
|
+
var shareStop = document.getElementById("share-stop");
|
|
435
|
+
var shareNew = document.getElementById("share-new");
|
|
436
|
+
var shareNote = document.getElementById("share-note");
|
|
437
|
+
var nameButton = document.getElementById("reviewer-name");
|
|
438
|
+
var nameInput = document.getElementById("name-input");
|
|
439
|
+
|
|
440
|
+
if (shareButton) shareButton.addEventListener("click", shareNow);
|
|
441
|
+
if (shareStop) shareStop.addEventListener("click", stopSharing);
|
|
442
|
+
if (shareNew) shareNew.addEventListener("click", newLink);
|
|
443
|
+
${dialogScript({ dialogId: "share-dialog", closeId: "share-close", globalName: "bfShare" })}
|
|
444
|
+
|
|
445
|
+
if (nameButton) nameButton.addEventListener("click", askName);
|
|
446
|
+
var nameSave = document.getElementById("name-save");
|
|
447
|
+
if (nameSave) nameSave.addEventListener("click", saveName);
|
|
448
|
+
${dialogScript({ dialogId: "name-dialog", closeId: "name-cancel", globalName: "bfName" })}
|
|
449
|
+
|
|
512
450
|
// Remember a selection made inside a block so clicking the rail button can
|
|
513
451
|
// still quote it after the button press has collapsed the selection.
|
|
514
452
|
document.addEventListener("mouseup", function (event) {
|
|
@@ -543,7 +481,7 @@ ${WEB_MARKDOWN_JS}
|
|
|
543
481
|
if (event.key === "k") { event.preventDefault(); moveActive(-1); return; }
|
|
544
482
|
if (event.key === "c") { event.preventDefault(); commentOnActive(); return; }
|
|
545
483
|
if (event.key === "a" && approveButton) { event.preventDefault(); askApprove(); return; }
|
|
546
|
-
if (event.key === "s") { event.preventDefault(); save(); return; }
|
|
484
|
+
if (event.key === "s") { event.preventDefault(); if (saveButton) save(); else postComments(); return; }
|
|
547
485
|
});
|
|
548
486
|
|
|
549
487
|
// A closed tab is detected by the stream below: the server sees the last
|
|
@@ -559,6 +497,7 @@ ${WEB_MARKDOWN_JS}
|
|
|
559
497
|
try {
|
|
560
498
|
stream = new EventSource("/events?token=" + encodeURIComponent(token));
|
|
561
499
|
stream.addEventListener("open", function () { connected = true; everConnected = true; updateState(); });
|
|
500
|
+
stream.addEventListener("message", function (event) { refreshFromServer(event); });
|
|
562
501
|
stream.addEventListener("error", function () {
|
|
563
502
|
// The server ends the stream once the review is settled; keep showing the
|
|
564
503
|
// terminal state rather than Offline.
|
|
@@ -573,9 +512,14 @@ ${WEB_MARKDOWN_JS}
|
|
|
573
512
|
|
|
574
513
|
${THEME_TOGGLE_JS}
|
|
575
514
|
|
|
515
|
+
// A reload during a review keeps this tab's unsaved work: the comments it added
|
|
516
|
+
// and the composer it was typing into, which needs the blocks rendered first.
|
|
517
|
+
var restoredTyping = restoreDraft();
|
|
576
518
|
renderHeader();
|
|
519
|
+
renderName();
|
|
577
520
|
renderOutline();
|
|
578
521
|
renderPlan();
|
|
522
|
+
reopenComposer(restoredTyping);
|
|
579
523
|
updateProgress();
|
|
580
524
|
})();
|
|
581
525
|
`;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared review's client half: who is looking, and what everyone else does.
|
|
3
|
+
*
|
|
4
|
+
* A fragment of the same IIFE `web-client.ts` ships (`WEB_COLLAB_JS` is
|
|
5
|
+
* interpolated into `REVIEW_CLIENT_JS`), kept apart so the reviewer identity, the
|
|
6
|
+
* share dialog, and the stream-driven refetch are read together and away from
|
|
7
|
+
* the document rendering. It owns no state of its own: it reads the page's
|
|
8
|
+
* `data`, `annotations`, `role`, and `reviewerName` and calls the page's own
|
|
9
|
+
* `renderPlan` / `renderFacts` / `updateState` / `setStatus`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const WEB_COLLAB_JS = String.raw` /**
|
|
13
|
+
* Sharing: the owner mints one link per person, re-reads the set, and revokes a
|
|
14
|
+
* single link without touching the others. The QR is the server's inline SVG,
|
|
15
|
+
* parsed and imported rather than pasted, so the page keeps its one rule: no
|
|
16
|
+
* text ever becomes HTML.
|
|
17
|
+
*/
|
|
18
|
+
function setQr(svg) {
|
|
19
|
+
if (!shareQrEl) return;
|
|
20
|
+
while (shareQrEl.firstChild) shareQrEl.removeChild(shareQrEl.firstChild);
|
|
21
|
+
if (!svg) return;
|
|
22
|
+
var parsed = new DOMParser().parseFromString(svg, "image/svg+xml");
|
|
23
|
+
var root = parsed && parsed.documentElement;
|
|
24
|
+
if (root && root.nodeName === "svg") shareQrEl.appendChild(document.importNode(root, true));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One row per link: its URL, who holds it, and the two things to do with it. */
|
|
28
|
+
function renderShareLinks(state) {
|
|
29
|
+
if (!shareLinksEl) return;
|
|
30
|
+
while (shareLinksEl.firstChild) shareLinksEl.removeChild(shareLinksEl.firstChild);
|
|
31
|
+
var links = state && state.links ? state.links : [];
|
|
32
|
+
links.forEach(function (link) {
|
|
33
|
+
var row = el("li", "review-share__row");
|
|
34
|
+
var url = el("span", "review-share__url");
|
|
35
|
+
url.textContent = link.url;
|
|
36
|
+
url.title = link.url;
|
|
37
|
+
var who = el("span", link.name ? "review-share__who review-share__who--bound" : "review-share__who");
|
|
38
|
+
who.textContent = link.name ? "@" + link.name : "unnamed";
|
|
39
|
+
var actions = el("span", "review-share__actions");
|
|
40
|
+
actions.appendChild(rowButton("Copy", function () { copyLink(link.url); }));
|
|
41
|
+
actions.appendChild(rowButton("Revoke", function () { revokeLink(link.id); }));
|
|
42
|
+
row.appendChild(url);
|
|
43
|
+
row.appendChild(who);
|
|
44
|
+
row.appendChild(actions);
|
|
45
|
+
shareLinksEl.appendChild(row);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function rowButton(label, onClick) {
|
|
50
|
+
var button = el("button", "bf-btn");
|
|
51
|
+
button.type = "button";
|
|
52
|
+
button.textContent = label;
|
|
53
|
+
button.addEventListener("click", onClick);
|
|
54
|
+
return button;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function copyLink(url) {
|
|
58
|
+
if (!navigator.clipboard || !navigator.clipboard.writeText) return;
|
|
59
|
+
navigator.clipboard.writeText(url).then(function () { setStatus("Link copied."); }, function () {});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function showShare(state, svg) {
|
|
63
|
+
if (!shareDialogNode) return;
|
|
64
|
+
renderShareLinks(state);
|
|
65
|
+
setQr(svg);
|
|
66
|
+
if (shareStop) shareStop.disabled = !(state && state.sharing);
|
|
67
|
+
if (shareNote) {
|
|
68
|
+
shareNote.textContent = state && state.reason
|
|
69
|
+
? state.reason
|
|
70
|
+
: "One link per person: each link posts under the name it binds. Only you can approve.";
|
|
71
|
+
}
|
|
72
|
+
if (window.bfShare) window.bfShare.open();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** POST one share action, then render whatever set the server answered with. */
|
|
76
|
+
function shareAction(route, body, failText) {
|
|
77
|
+
fetch(route + "?token=" + encodeURIComponent(token), {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: { "Content-Type": "application/json" },
|
|
80
|
+
body: JSON.stringify(body || {})
|
|
81
|
+
}).then(function (response) {
|
|
82
|
+
return response.json().then(function (payload) {
|
|
83
|
+
if (!response.ok) throw new Error(payload && payload.error ? payload.error : "server returned " + response.status);
|
|
84
|
+
if (payload && payload.share) showShare(payload.share, payload.svg);
|
|
85
|
+
return payload;
|
|
86
|
+
});
|
|
87
|
+
}).catch(function (error) { setStatus(failText + error.message, "error"); });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function shareNow() {
|
|
91
|
+
shareAction("/api/share", null, "Could not share: ");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function newLink() {
|
|
95
|
+
shareAction("/api/share/link", null, "Could not mint a link: ");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function revokeLink(id) {
|
|
99
|
+
shareAction("/api/share/revoke", { id: id }, "Could not revoke that link: ");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function stopSharing() {
|
|
103
|
+
fetch("/api/share/stop?token=" + encodeURIComponent(token), { method: "POST" })
|
|
104
|
+
.then(function (response) { return response.json(); })
|
|
105
|
+
.then(function (payload) {
|
|
106
|
+
if (payload && payload.share) showShare(payload.share, null);
|
|
107
|
+
setStatus("Reviewers can no longer open this review.");
|
|
108
|
+
})
|
|
109
|
+
.catch(function () { setStatus("Could not stop sharing.", "error"); });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A reviewer names herself once. The server binds that name to her link on the
|
|
114
|
+
* first post, and a bound link cannot rename itself, so the page shows the name
|
|
115
|
+
* it posts under and stops offering the dialog.
|
|
116
|
+
*/
|
|
117
|
+
function renderName() {
|
|
118
|
+
if (!nameButton) return;
|
|
119
|
+
if (role !== "reviewer") { nameButton.hidden = true; return; }
|
|
120
|
+
nameButton.hidden = false;
|
|
121
|
+
nameButton.textContent = reviewerName ? "Reviewing as " + reviewerName : "Set your name";
|
|
122
|
+
nameButton.disabled = reviewerBound;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Take the name the server bound to this link, and stop offering the dialog. */
|
|
126
|
+
function adoptName(name) {
|
|
127
|
+
var value = String(name || "").trim().slice(0, 40);
|
|
128
|
+
if (!value) return;
|
|
129
|
+
reviewerBound = true;
|
|
130
|
+
if (value === reviewerName) { renderName(); return; }
|
|
131
|
+
reviewerName = value;
|
|
132
|
+
try { window.localStorage.setItem("bf-review-name", reviewerName); } catch (error) {}
|
|
133
|
+
renderName();
|
|
134
|
+
renderPlan();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function askName() {
|
|
138
|
+
if (reviewerBound) { setStatus("This link already posts as @" + reviewerName + "."); return; }
|
|
139
|
+
if (nameInput) nameInput.value = reviewerName;
|
|
140
|
+
if (window.bfName) window.bfName.open();
|
|
141
|
+
if (nameInput) nameInput.focus();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function saveName() {
|
|
145
|
+
if (reviewerBound) return;
|
|
146
|
+
reviewerName = nameInput ? nameInput.value.trim().slice(0, 40) : "";
|
|
147
|
+
try { window.localStorage.setItem("bf-review-name", reviewerName); } catch (error) {}
|
|
148
|
+
if (window.bfName) window.bfName.close();
|
|
149
|
+
renderName();
|
|
150
|
+
renderPlan();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Another open page changed the review: a comment landed, the plan was saved,
|
|
155
|
+
* or sharing changed. The island is the one contract, so the page re-reads it
|
|
156
|
+
* and re-renders rather than growing a second update path. A draft in progress
|
|
157
|
+
* is never thrown away; the status line says what arrived instead.
|
|
158
|
+
*/
|
|
159
|
+
function refreshFromServer(event) {
|
|
160
|
+
var kind = event && event.data ? String(event.data).trim() : "";
|
|
161
|
+
// A terminal frame is the server saying why this page will not update again;
|
|
162
|
+
// a reviewer has no decision to make, so being told beats reading "Offline".
|
|
163
|
+
if (kind === "closed") {
|
|
164
|
+
if (saved || approved || closed) return;
|
|
165
|
+
closed = true;
|
|
166
|
+
clearDraft();
|
|
167
|
+
settleStream();
|
|
168
|
+
lockActions();
|
|
169
|
+
setStatus("The review is closed.", "info");
|
|
170
|
+
updateState();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (kind === "revoked") {
|
|
174
|
+
if (closed) return;
|
|
175
|
+
closed = true;
|
|
176
|
+
clearDraft();
|
|
177
|
+
settleStream();
|
|
178
|
+
lockActions();
|
|
179
|
+
setStatus("The owner stopped sharing this review \u2014 your comments stay in the plan.", "info");
|
|
180
|
+
updateState();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (saved || approved || closed) return;
|
|
184
|
+
if (draftTextarea()) {
|
|
185
|
+
setStatus("A change arrived; finish or discard your draft to see it.");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
fetch(window.location.pathname + window.location.search)
|
|
189
|
+
.then(function (response) { return response.text(); })
|
|
190
|
+
.then(function (html) {
|
|
191
|
+
var parsed = new DOMParser().parseFromString(html, "text/html");
|
|
192
|
+
var node = parsed.getElementById("review-data");
|
|
193
|
+
if (!node || !node.textContent) return;
|
|
194
|
+
var next = JSON.parse(node.textContent);
|
|
195
|
+
data = next;
|
|
196
|
+
annotations = Array.isArray(next.annotations) ? next.annotations.slice() : [];
|
|
197
|
+
if (typeof next.author === "string" && next.author) adoptName(next.author);
|
|
198
|
+
// This tab's own unsaved comments belong on top of what just arrived.
|
|
199
|
+
restoreDraft();
|
|
200
|
+
renderPlan();
|
|
201
|
+
renderFacts();
|
|
202
|
+
updateState();
|
|
203
|
+
setStatus(kind === "plan" ? "The plan changed on disk; showing the new version." : "Updated with a new comment.");
|
|
204
|
+
})
|
|
205
|
+
.catch(function () {});
|
|
206
|
+
}
|
|
207
|
+
`;
|