@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
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The review page's comment threads: selection quotes, the composer, the cards.
|
|
3
|
+
*
|
|
4
|
+
* A fragment of the same IIFE `web-client.ts` ships (`WEB_COMMENTS_JS` is
|
|
5
|
+
* interpolated into `REVIEW_CLIENT_JS`), kept apart so what a comment *is* — its
|
|
6
|
+
* anchor, its author, and who may edit it — is read in one place. It mutates the
|
|
7
|
+
* page's `annotations` array and asks the page to repaint; it owns no state.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const WEB_COMMENTS_JS = String.raw` function selectionIn(wrap) {
|
|
11
|
+
var selection = window.getSelection && window.getSelection();
|
|
12
|
+
if (!selection || selection.isCollapsed) return "";
|
|
13
|
+
var range = selection.rangeCount ? selection.getRangeAt(0) : null;
|
|
14
|
+
if (!range || !wrap.contains(range.commonAncestorContainer)) return "";
|
|
15
|
+
return selection.toString().replace(/\s+/g, " ").trim().slice(0, limits.quote);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function commentsBoxAfter(wrap) {
|
|
19
|
+
var next = wrap.nextElementSibling;
|
|
20
|
+
if (next && next.classList && next.classList.contains("bf-comments")) return next;
|
|
21
|
+
var box = el("div", "bf-comments");
|
|
22
|
+
wrap.insertAdjacentElement("afterend", box);
|
|
23
|
+
return box;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Enter submits a comment; Shift+Enter is left for the browser.
|
|
27
|
+
function submitOnEnter(area, submit) {
|
|
28
|
+
area.addEventListener("keydown", function (event) {
|
|
29
|
+
// An IME uses Enter to commit a composition; submitting then would send a
|
|
30
|
+
// half-typed comment.
|
|
31
|
+
if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
|
|
32
|
+
event.preventDefault();
|
|
33
|
+
submit();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// One seam for "this writer changed a comment": the unsaved marker now, and the
|
|
38
|
+
// draft this tab keeps across a reload beside it.
|
|
39
|
+
function markDirty() {
|
|
40
|
+
dirty = true;
|
|
41
|
+
saveDraft();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The draft is this tab's own: the comments this writer added and has not saved,
|
|
45
|
+
// and the text in a composer that has not become a comment yet, keyed by the plan
|
|
46
|
+
// revision the server rendered. It never travels to the server, and nothing
|
|
47
|
+
// another writer added is kept here.
|
|
48
|
+
var DRAFT_KEY = "bf-review-draft";
|
|
49
|
+
var DRAFT_LIMIT = 131072;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The open composer's block line, text, and the selection it quotes, or null when
|
|
53
|
+
* nothing is being typed. The quote is half of what the comment will say, so a
|
|
54
|
+
* draft that keeps the text and drops the quote would restore a different note.
|
|
55
|
+
*/
|
|
56
|
+
function typingOf() {
|
|
57
|
+
var area = draftTextarea();
|
|
58
|
+
var wrap = area && area.closest ? area.closest(".bf-block") : null;
|
|
59
|
+
var anchor = wrap ? Number(wrap.getAttribute("data-line")) : -1;
|
|
60
|
+
if (!area || !(anchor >= 0) || area.value === "") return null;
|
|
61
|
+
var composer = area.closest ? area.closest(".bf-composer") : null;
|
|
62
|
+
return { anchor: anchor, text: area.value, quote: (composer && composer.getAttribute("data-quote")) || "" };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function saveDraft() {
|
|
66
|
+
if (saved || approved || closed) return;
|
|
67
|
+
var mine = annotations.filter(isMine);
|
|
68
|
+
var typing = typingOf();
|
|
69
|
+
if (mine.length === 0 && !typing) { clearDraft(); return; }
|
|
70
|
+
try {
|
|
71
|
+
var record = { rev: String(data.rev || ""), mine: mine };
|
|
72
|
+
if (typing) record.typing = typing;
|
|
73
|
+
var text = JSON.stringify(record);
|
|
74
|
+
if (text.length > DRAFT_LIMIT) return;
|
|
75
|
+
window.sessionStorage.setItem(DRAFT_KEY, text);
|
|
76
|
+
} catch (error) {}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function clearDraft() {
|
|
80
|
+
try { window.sessionStorage.removeItem(DRAFT_KEY); } catch (error) {}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Put this tab's unsaved work back: after a reload of the same plan, and after
|
|
85
|
+
* another writer's comment arrived and the island was re-read. A draft for a
|
|
86
|
+
* different plan revision is stale, so it is dropped rather than re-anchored.
|
|
87
|
+
* Returns the composer text to reopen, which needs the blocks to have rendered.
|
|
88
|
+
*/
|
|
89
|
+
function restoreDraft() {
|
|
90
|
+
var raw = null;
|
|
91
|
+
try { raw = window.sessionStorage.getItem(DRAFT_KEY); } catch (error) { raw = null; }
|
|
92
|
+
if (!raw) return null;
|
|
93
|
+
var draft = null;
|
|
94
|
+
try { draft = JSON.parse(raw); } catch (error) { draft = null; }
|
|
95
|
+
var mine = draft && Array.isArray(draft.mine) ? draft.mine : [];
|
|
96
|
+
var stored = draft && draft.typing && typeof draft.typing.text === "string" ? draft.typing : null;
|
|
97
|
+
// Rebuilt field by field: storage is hand-editable, and a chip rendered from a
|
|
98
|
+
// non-string quote would read as [object Object].
|
|
99
|
+
var typing =
|
|
100
|
+
stored && typeof stored.anchor === "number"
|
|
101
|
+
? { anchor: stored.anchor, text: stored.text, quote: typeof stored.quote === "string" ? stored.quote : "" }
|
|
102
|
+
: null;
|
|
103
|
+
if (!draft || draft.rev !== String(data.rev || "") || (mine.length === 0 && !typing)) {
|
|
104
|
+
clearDraft();
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (mine.length > 0) {
|
|
108
|
+
annotations = annotations.filter(function (annotation) { return !isMine(annotation); }).concat(mine);
|
|
109
|
+
dirty = true;
|
|
110
|
+
}
|
|
111
|
+
return typing;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function openComposer(wrap, block, quote, initial) {
|
|
115
|
+
closeComposer();
|
|
116
|
+
wrap.classList.add("bf-block--composing");
|
|
117
|
+
var composer = el("div", "bf-composer");
|
|
118
|
+
if (quote) {
|
|
119
|
+
var chip = el("div", "bf-quote-chip");
|
|
120
|
+
chip.textContent = "\u201c" + quote + "\u201d";
|
|
121
|
+
composer.appendChild(chip);
|
|
122
|
+
// The chip is the quote's only record; the draft reads it back from here.
|
|
123
|
+
composer.setAttribute("data-quote", quote);
|
|
124
|
+
}
|
|
125
|
+
var area = el("textarea", "bf-textarea");
|
|
126
|
+
area.placeholder = "Leave feedback on this part of the plan (Enter to add)";
|
|
127
|
+
area.setAttribute("aria-label", "Comment");
|
|
128
|
+
area.maxLength = limits.comment;
|
|
129
|
+
if (initial) area.value = initial;
|
|
130
|
+
// Every keystroke is this tab's draft, so a reload while typing costs nothing.
|
|
131
|
+
area.addEventListener("input", function () { saveDraft(); });
|
|
132
|
+
composer.appendChild(area);
|
|
133
|
+
var row = el("div", "bf-composer__row");
|
|
134
|
+
var cancel = el("button"); cancel.type = "button"; cancel.className = "bf-btn"; cancel.textContent = "Cancel";
|
|
135
|
+
cancel.addEventListener("click", closeComposer);
|
|
136
|
+
var add = el("button"); add.type = "button"; add.className = "bf-btn bf-btn--primary"; add.textContent = "Add comment";
|
|
137
|
+
var submit = function () {
|
|
138
|
+
if (closed) { setStatus("The review is closed; this comment was not added.", "error"); return; }
|
|
139
|
+
if (role === "reviewer" && !reviewerName) { askName(); return; }
|
|
140
|
+
var comment = area.value.replace(/\s+/g, " ").trim();
|
|
141
|
+
if (!comment) { area.focus(); return; }
|
|
142
|
+
if (annotations.length >= limits.comments) { setStatus("Comment limit reached (" + limits.comments + ").", "error"); return; }
|
|
143
|
+
var entry = quote ? { anchor: block.start, quote: quote, comment: comment } : { anchor: block.start, comment: comment };
|
|
144
|
+
if (role === "reviewer") entry.author = reviewerName;
|
|
145
|
+
annotations.push(entry);
|
|
146
|
+
// Close first: the text is now a comment, so the draft keeps one or the other.
|
|
147
|
+
closeComposer();
|
|
148
|
+
markDirty();
|
|
149
|
+
setStatus("Comment added.");
|
|
150
|
+
renderPlan();
|
|
151
|
+
};
|
|
152
|
+
add.addEventListener("click", submit);
|
|
153
|
+
submitOnEnter(area, submit);
|
|
154
|
+
row.appendChild(cancel); row.appendChild(add); composer.appendChild(row);
|
|
155
|
+
commentsBoxAfter(wrap).appendChild(composer);
|
|
156
|
+
area.focus();
|
|
157
|
+
// A restored draft resumes where it left off, with the caret at its end.
|
|
158
|
+
if (initial) area.setSelectionRange(area.value.length, area.value.length);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function startEdit(card, index) {
|
|
162
|
+
var annotation = annotations[index];
|
|
163
|
+
card.textContent = "";
|
|
164
|
+
var area = el("textarea", "bf-textarea");
|
|
165
|
+
area.value = annotation.comment;
|
|
166
|
+
area.setAttribute("aria-label", "Edit comment");
|
|
167
|
+
area.maxLength = limits.comment;
|
|
168
|
+
card.appendChild(area);
|
|
169
|
+
var row = el("div", "bf-comment__row");
|
|
170
|
+
var cancel = el("button"); cancel.type = "button"; cancel.className = "bf-btn"; cancel.textContent = "Cancel";
|
|
171
|
+
cancel.addEventListener("click", renderPlan);
|
|
172
|
+
var apply = el("button"); apply.type = "button"; apply.className = "bf-btn bf-btn--primary"; apply.textContent = "Save";
|
|
173
|
+
apply.addEventListener("click", function () {
|
|
174
|
+
var comment = area.value.replace(/\s+/g, " ").trim();
|
|
175
|
+
if (!comment) { area.focus(); return; }
|
|
176
|
+
annotation.comment = comment;
|
|
177
|
+
markDirty();
|
|
178
|
+
setStatus("Comment updated.");
|
|
179
|
+
renderPlan();
|
|
180
|
+
});
|
|
181
|
+
submitOnEnter(area, function () { apply.click(); });
|
|
182
|
+
row.appendChild(cancel); row.appendChild(apply); card.appendChild(row);
|
|
183
|
+
area.focus();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Deleting a comment is one click, so it is undoable rather than confirmed: the
|
|
187
|
+
// removed note is held for a few seconds and offered back in the header.
|
|
188
|
+
var undo = null;
|
|
189
|
+
|
|
190
|
+
function clearUndo() {
|
|
191
|
+
if (undo && undo.timer) window.clearTimeout(undo.timer);
|
|
192
|
+
if (undo && undo.button && undo.button.parentNode) undo.button.parentNode.removeChild(undo.button);
|
|
193
|
+
undo = null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function offerUndo(index, annotation) {
|
|
197
|
+
var controls = document.querySelector(".review-controls");
|
|
198
|
+
clearUndo();
|
|
199
|
+
if (!controls) return;
|
|
200
|
+
var button = el("button", "bf-btn");
|
|
201
|
+
button.type = "button";
|
|
202
|
+
button.id = "undo-delete";
|
|
203
|
+
button.textContent = "Undo delete";
|
|
204
|
+
button.addEventListener("click", function () {
|
|
205
|
+
annotations.splice(Math.min(index, annotations.length), 0, annotation);
|
|
206
|
+
markDirty();
|
|
207
|
+
clearUndo();
|
|
208
|
+
renderPlan();
|
|
209
|
+
setStatus("Comment restored.", "saved");
|
|
210
|
+
});
|
|
211
|
+
controls.appendChild(button);
|
|
212
|
+
undo = { button: button, timer: window.setTimeout(clearUndo, 6000) };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function removeComment(index) {
|
|
216
|
+
var annotation = annotations[index];
|
|
217
|
+
if (!annotation) return;
|
|
218
|
+
annotations.splice(index, 1);
|
|
219
|
+
markDirty();
|
|
220
|
+
renderPlan();
|
|
221
|
+
offerUndo(index, annotation);
|
|
222
|
+
setStatus("Comment deleted.");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// A comment belongs to whoever wrote it: an untagged one is the owner's, a
|
|
226
|
+
// tagged one is that reviewer's. Everyone else's is read-only on this page.
|
|
227
|
+
function isMine(annotation) {
|
|
228
|
+
if (role === "owner") return !annotation.author;
|
|
229
|
+
return !!reviewerName && annotation.author === reviewerName;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function renderCommentCards(box, list) {
|
|
233
|
+
list.forEach(function (annotation) {
|
|
234
|
+
var index = annotations.indexOf(annotation);
|
|
235
|
+
var card = el("div", "bf-comment");
|
|
236
|
+
var head = el("div", "bf-comment__head");
|
|
237
|
+
var label = el("span", annotation.author ? "bf-comment__label bf-comment__label--author" : "bf-comment__label");
|
|
238
|
+
label.textContent = annotation.author ? "@" + annotation.author : "Review";
|
|
239
|
+
head.appendChild(label);
|
|
240
|
+
head.appendChild(el("span", "bf-comment__spacer"));
|
|
241
|
+
if (isMine(annotation)) {
|
|
242
|
+
var actions = el("div", "bf-comment__actions");
|
|
243
|
+
var edit = el("button"); edit.type = "button"; edit.className = "bf-btn"; edit.textContent = "Edit";
|
|
244
|
+
edit.addEventListener("click", function () { startEdit(card, index); });
|
|
245
|
+
var remove = el("button"); remove.type = "button"; remove.className = "bf-btn"; remove.textContent = "Delete";
|
|
246
|
+
remove.addEventListener("click", function () { removeComment(index); });
|
|
247
|
+
actions.appendChild(edit); actions.appendChild(remove); head.appendChild(actions);
|
|
248
|
+
}
|
|
249
|
+
card.appendChild(head);
|
|
250
|
+
if (annotation.quote) {
|
|
251
|
+
var quote = el("div", "bf-comment__quote");
|
|
252
|
+
quote.textContent = "\u201c" + annotation.quote + "\u201d";
|
|
253
|
+
card.appendChild(quote);
|
|
254
|
+
}
|
|
255
|
+
var body = el("div", "bf-comment__body");
|
|
256
|
+
body.textContent = annotation.comment;
|
|
257
|
+
card.appendChild(body);
|
|
258
|
+
box.appendChild(card);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function renderComments(container, anchor) {
|
|
263
|
+
var mine = annotations.filter(function (annotation) { return annotation.anchor === anchor; });
|
|
264
|
+
if (mine.length === 0) return;
|
|
265
|
+
var box = el("div", "bf-comments");
|
|
266
|
+
renderCommentCards(box, mine);
|
|
267
|
+
container.appendChild(box);
|
|
268
|
+
}
|
|
269
|
+
`;
|
|
@@ -25,8 +25,11 @@ import {
|
|
|
25
25
|
WEB_LAYOUT_CSS,
|
|
26
26
|
WEB_THEME_CSS,
|
|
27
27
|
} from "../../lib/web/index.ts";
|
|
28
|
+
import { escapeHtml } from "../../lib/escape.ts";
|
|
28
29
|
import type { PlanBlock } from "./blocks.ts";
|
|
29
30
|
import type { PlanAnnotation } from "./feedback.ts";
|
|
31
|
+
import type { ReviewRole } from "./types.ts";
|
|
32
|
+
import type { ShareState } from "./web-share.ts";
|
|
30
33
|
|
|
31
34
|
/** A heading the outline can jump to. */
|
|
32
35
|
export interface ReviewOutlineItem {
|
|
@@ -67,8 +70,29 @@ export interface ReviewPageData {
|
|
|
67
70
|
limits: { comments: number; comment: number; quote: number };
|
|
68
71
|
/** Whether the review may approve the plan (only the `exit_plan_mode` flow). */
|
|
69
72
|
canApprove: boolean;
|
|
73
|
+
/** Who is looking: `owner` may approve, `reviewer` may only comment. */
|
|
74
|
+
role: ReviewRole;
|
|
75
|
+
/**
|
|
76
|
+
* The name a reviewer's link posts under, once it has been bound. Absent for the
|
|
77
|
+
* owner and for a reviewer that has not written yet, which is what tells the page
|
|
78
|
+
* whether its name control is still a choice.
|
|
79
|
+
*/
|
|
80
|
+
author?: string;
|
|
81
|
+
/**
|
|
82
|
+
* One line the owner needs before acting, such as why approval happens in the
|
|
83
|
+
* terminal; absent when the page needs no caveat.
|
|
84
|
+
*/
|
|
85
|
+
note?: string;
|
|
86
|
+
/** A running share, so a reload re-renders the link the owner already minted. */
|
|
87
|
+
share?: ShareState;
|
|
70
88
|
/** Per-session secret the client echoes on every request. */
|
|
71
89
|
token: string;
|
|
90
|
+
/**
|
|
91
|
+
* A short digest of the plan text the page rendered. It keys the unsaved draft a
|
|
92
|
+
* tab keeps for itself, so a reload restores the comments this writer has not
|
|
93
|
+
* saved yet, and a plan that changed underneath drops them instead.
|
|
94
|
+
*/
|
|
95
|
+
rev: string;
|
|
72
96
|
}
|
|
73
97
|
|
|
74
98
|
/** Page styles: the shared base + theme + shell + components, then the review. */
|
|
@@ -76,6 +100,15 @@ export const REVIEW_CSS = `${WEB_BASE_CSS}
|
|
|
76
100
|
${WEB_THEME_CSS}
|
|
77
101
|
${WEB_LAYOUT_CSS}
|
|
78
102
|
${WEB_COMPONENTS_CSS}
|
|
103
|
+
.review-share__list{list-style:none;margin:0 0 var(--space-3);padding:0;display:flex;flex-direction:column;gap:var(--space-2)}
|
|
104
|
+
.review-share__row{display:flex;align-items:center;gap:var(--space-2);padding:var(--space-2) var(--space-3);border:1px solid var(--border);border-radius:var(--radius-md)}
|
|
105
|
+
.review-share__url{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:var(--fs-sm) var(--font-mono);color:var(--text)}
|
|
106
|
+
.review-share__who{flex:0 0 auto;font:var(--fs-xs) var(--font-mono);color:var(--muted)}
|
|
107
|
+
.review-share__who--bound{color:var(--accent)}
|
|
108
|
+
.review-share__actions{display:flex;flex:0 0 auto;gap:4px}
|
|
109
|
+
.review-share__actions .bf-btn{padding:1px 8px;font-size:var(--fs-xs)}
|
|
110
|
+
.review-share__qr{display:flex;justify-content:center;margin:0 0 var(--space-3)}
|
|
111
|
+
.review-share__qr svg{width:168px;height:168px;background:#fff;border-radius:var(--radius-md);padding:var(--space-1)}
|
|
79
112
|
.bf-review{--review-measure:1012px;--review-rail:26px;--review-gutter:34px;flex:1 1 auto;padding:var(--space-5) var(--space-6) 120px}
|
|
80
113
|
/* The document is one content column at GitHub's Markdown body width, with the
|
|
81
114
|
comment rail inside it, so a plan reads the way the same file does on GitHub. */
|
|
@@ -83,9 +116,9 @@ ${WEB_COMPONENTS_CSS}
|
|
|
83
116
|
hairline reading-progress bar pinned to the header's bottom edge. */
|
|
84
117
|
.review-bar{align-items:center;gap:var(--space-2) var(--space-4)}
|
|
85
118
|
.review-bar .bf-brand{flex:0 0 auto}
|
|
86
|
-
.review-heading{display:flex;align-items:center;flex:1 1
|
|
87
|
-
.review-title{display:block;flex:1 1 auto;min-width:
|
|
88
|
-
.review-state{flex:0 0 auto;white-space:nowrap}
|
|
119
|
+
.review-heading{display:flex;align-items:center;flex:1 1 240px;min-width:0;overflow:hidden;border-left:1px solid var(--border);padding-left:var(--space-4)}
|
|
120
|
+
.review-title{display:block;flex:1 1 auto;min-width:8ch;font-size:var(--fs-lg);font-weight:var(--fw-semibold);line-height:var(--lh-tight);color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
121
|
+
.review-state{flex:0 0 auto;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
89
122
|
.review-status{flex:0 0 auto;max-width:42ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
90
123
|
.review-status:empty{display:none}
|
|
91
124
|
.review-controls{display:inline-flex;align-items:center;gap:var(--space-2);flex:0 0 auto}
|
|
@@ -150,6 +183,8 @@ ${WEB_COMPONENTS_CSS}
|
|
|
150
183
|
.bf-comment{padding:var(--space-3) var(--space-4)}
|
|
151
184
|
.bf-comment__head{display:flex;align-items:center;gap:var(--space-2);margin-bottom:6px}
|
|
152
185
|
.bf-comment__label{font:var(--fw-semibold) var(--fs-xs) var(--font-mono);color:var(--warn);text-transform:uppercase;letter-spacing:.06em}
|
|
186
|
+
/* A reviewer's name is a name, not a label: keep its own capitalisation. */
|
|
187
|
+
.bf-comment__label--author{text-transform:none;letter-spacing:0}
|
|
153
188
|
.bf-comment__spacer{flex:1 1 auto}
|
|
154
189
|
.bf-comment__actions{display:flex;gap:4px}
|
|
155
190
|
.bf-comment__actions .bf-btn{padding:1px 8px;font-size:var(--fs-xs)}
|
|
@@ -171,12 +206,21 @@ ${WEB_COMPONENTS_CSS}
|
|
|
171
206
|
.bf-review{padding:var(--space-4) 18px 80px}
|
|
172
207
|
.review-nav__title{display:none}
|
|
173
208
|
}
|
|
174
|
-
|
|
209
|
+
/* The controls are the widest thing in the bar — seven of them once a review can
|
|
210
|
+
be shared — so the title moves to its own row while they still need a row of
|
|
211
|
+
their own, rather than being squeezed to nothing beside it. */
|
|
212
|
+
@media (max-width:1120px){
|
|
175
213
|
.review-bar{align-items:flex-start}
|
|
176
214
|
.review-heading{flex:1 1 100%;order:3;border-left:0;padding-left:0;margin-top:var(--space-1)}
|
|
177
215
|
.review-controls{order:2;margin-left:auto}
|
|
178
216
|
.review-status{order:4;flex:1 1 100%;max-width:none;white-space:normal}
|
|
179
217
|
}
|
|
218
|
+
/* Below a phone's width the controls wrap instead of running off the page. */
|
|
219
|
+
@media (max-width:640px){
|
|
220
|
+
.review-controls{flex-wrap:wrap;justify-content:flex-end;max-width:100%}
|
|
221
|
+
.review-heading{gap:var(--space-2)}
|
|
222
|
+
.review-title{min-width:0}
|
|
223
|
+
}
|
|
180
224
|
@media (prefers-reduced-motion:no-preference){.bf-block{transition:background var(--motion-fast) var(--motion-ease)}}
|
|
181
225
|
@media (hover:none){.bf-block__add{opacity:1}}
|
|
182
226
|
/* On paper the document is the whole page: no comment rail to hover, no composer,
|
|
@@ -195,21 +239,26 @@ ${WEB_COMPONENTS_CSS}
|
|
|
195
239
|
* for first and defers to the shortcut sheet (`?`) for the rest, so the line does
|
|
196
240
|
* not have to grow every time a binding is added.
|
|
197
241
|
*/
|
|
198
|
-
function reviewHints(canApprove: boolean): string {
|
|
242
|
+
function reviewHints(role: ReviewRole, canApprove: boolean): string {
|
|
199
243
|
const approve = canApprove ? `, <kbd class="bf-kbd">a</kbd> to approve` : "";
|
|
200
|
-
|
|
244
|
+
const save = role === "owner" ? "save" : "post";
|
|
245
|
+
return `<p class="review-hints">Press <kbd class="bf-kbd">j</kbd>/<kbd class="bf-kbd">k</kbd> to move, <kbd class="bf-kbd">c</kbd> to comment${approve}, <kbd class="bf-kbd">s</kbd> to ${save}, <kbd class="bf-kbd">?</kbd> for all shortcuts.</p>`;
|
|
201
246
|
}
|
|
202
247
|
|
|
203
248
|
/** The shortcut sheet's rows: the review's own keys, led by approve when offered. */
|
|
204
|
-
function reviewKeys(canApprove: boolean): KeyHint[] {
|
|
249
|
+
function reviewKeys(role: ReviewRole, canApprove: boolean): KeyHint[] {
|
|
205
250
|
const rows: KeyHint[] = [];
|
|
206
251
|
if (canApprove) rows.push({ keys: "a", label: "Approve and run the plan" });
|
|
207
252
|
rows.push(
|
|
208
253
|
{ keys: ["j", "k"], label: "Next and previous block" },
|
|
209
254
|
{ keys: "c", label: "Comment on the current block" },
|
|
210
255
|
{ keys: "Enter", label: "Add the comment you are writing" },
|
|
211
|
-
|
|
212
|
-
|
|
256
|
+
role === "owner"
|
|
257
|
+
? { keys: "s", label: "Save feedback and return to Pi" }
|
|
258
|
+
: { keys: "s", label: "Post your comments to the plan" },
|
|
259
|
+
role === "owner"
|
|
260
|
+
? { keys: "Esc", label: "Close the composer, the sheet, or the review" }
|
|
261
|
+
: { keys: "Esc", label: "Close the composer or this sheet" },
|
|
213
262
|
{ keys: "?", label: "Toggle this sheet" },
|
|
214
263
|
);
|
|
215
264
|
return rows;
|
|
@@ -231,6 +280,33 @@ function approveDialog(): string {
|
|
|
231
280
|
</div>`;
|
|
232
281
|
}
|
|
233
282
|
|
|
283
|
+
/**
|
|
284
|
+
* The owner's Share dialog: one row per reviewer link, the QR of the link just
|
|
285
|
+
* minted, and the two actions that change the set. The list is empty on purpose —
|
|
286
|
+
* the client fills it after the server mints a token, so a link never travels in
|
|
287
|
+
* the page source.
|
|
288
|
+
*/
|
|
289
|
+
function shareDialog(): string {
|
|
290
|
+
return `<div class="bf-dialog" id="share-dialog" hidden>
|
|
291
|
+
<div class="bf-dialog__panel" role="dialog" aria-modal="true" aria-labelledby="share-title">
|
|
292
|
+
<div class="bf-dialog__head"><h2 id="share-title">Share this review</h2><span class="bf-spacer"></span><button type="button" class="bf-btn" id="share-close">Close</button></div>
|
|
293
|
+
<div class="bf-dialog__body"><ul class="review-share__list" id="share-links"></ul><div class="review-share__qr" id="share-qr"></div><p class="bf-dialog__hint" id="share-note"></p></div>
|
|
294
|
+
<div class="bf-dialog__actions"><button type="button" class="bf-btn" id="share-stop">Stop sharing</button><button type="button" class="bf-btn bf-btn--primary" id="share-new">New link</button></div>
|
|
295
|
+
</div>
|
|
296
|
+
</div>`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The reviewer's name dialog: one line, which the link keeps after the first post. */
|
|
300
|
+
function nameDialog(): string {
|
|
301
|
+
return `<div class="bf-dialog" id="name-dialog" hidden>
|
|
302
|
+
<div class="bf-dialog__panel" role="dialog" aria-modal="true" aria-labelledby="name-title">
|
|
303
|
+
<div class="bf-dialog__head"><h2 id="name-title">Your name</h2></div>
|
|
304
|
+
<div class="bf-dialog__body"><p>Your comments are signed with this name, and this link keeps it after your first post.</p><p class="bf-field"><input class="bf-input" id="name-input" maxlength="40" placeholder="alice" aria-labelledby="name-title"></p></div>
|
|
305
|
+
<div class="bf-dialog__actions"><button type="button" class="bf-btn" id="name-cancel">Cancel</button><button type="button" class="bf-btn bf-btn--primary" id="name-save">Save name</button></div>
|
|
306
|
+
</div>
|
|
307
|
+
</div>`;
|
|
308
|
+
}
|
|
309
|
+
|
|
234
310
|
/**
|
|
235
311
|
* Render the review page for `data`. The markup is a fixed shell; the client
|
|
236
312
|
* fills every dynamic field, so this function is the only place that needs the
|
|
@@ -246,18 +322,29 @@ export function renderReviewPage(data: ReviewPageData): string {
|
|
|
246
322
|
<div class="bf-panel review-panel">
|
|
247
323
|
${reviewFacts()}
|
|
248
324
|
<nav class="review-nav" aria-labelledby="outline-title"><p class="review-nav__title" id="outline-title">Sections</p><ul id="outline-list"></ul></nav>
|
|
249
|
-
${
|
|
325
|
+
${data.note ? `<p class="review-hint">${escapeHtml(data.note)}</p>` : ""}
|
|
326
|
+
${reviewHints(data.role, data.canApprove)}
|
|
250
327
|
</div>
|
|
251
328
|
</details>
|
|
252
329
|
`
|
|
253
330
|
: "";
|
|
254
|
-
// Approve is the decisive action, so it takes the primary button and
|
|
255
|
-
// demoted; a comment-only review has no approve button and
|
|
331
|
+
// Approve is the decisive action, so it takes the primary button and the
|
|
332
|
+
// owner's Save is demoted; a comment-only review has no approve button and
|
|
333
|
+
// keeps Save primary. A reviewer's one write action is Post, and nothing on
|
|
334
|
+
// their page closes the review or mints another link.
|
|
335
|
+
const isOwner = data.role === "owner";
|
|
256
336
|
const approveButton = data.canApprove
|
|
257
337
|
? `
|
|
258
338
|
<button type="button" class="bf-btn bf-btn--primary" id="approve">Approve & run</button>`
|
|
259
339
|
: "";
|
|
260
|
-
const
|
|
340
|
+
const saveControl = isOwner
|
|
341
|
+
? `<button type="button" class="${data.canApprove ? "bf-btn" : "bf-btn bf-btn--primary"}" id="save">Save feedback</button>`
|
|
342
|
+
: `<button type="button" class="bf-btn bf-btn--primary" id="post">Post comments</button>`;
|
|
343
|
+
const closeControl = isOwner ? `<button type="button" class="bf-btn" id="cancel">Back to Pi</button>` : "";
|
|
344
|
+
// Sharing belongs to the owner; a reviewer gets the name they comment under.
|
|
345
|
+
const shareControl = isOwner
|
|
346
|
+
? `<button type="button" class="bf-btn" id="share">Share</button>`
|
|
347
|
+
: `<button type="button" class="bf-btn" id="reviewer-name" hidden></button>`;
|
|
261
348
|
return `<!doctype html>
|
|
262
349
|
<html lang="en">
|
|
263
350
|
<head>
|
|
@@ -274,16 +361,15 @@ ${brandLockup("plan review")}
|
|
|
274
361
|
<span class="bf-status review-status" id="status" role="status" aria-live="polite"></span>
|
|
275
362
|
<div class="review-controls">
|
|
276
363
|
${themeToggle()}
|
|
277
|
-
<button type="button" class="bf-btn bf-btn--icon" id="shortcuts" aria-label="Keyboard shortcuts" title="Keyboard shortcuts">?</button>${approveButton}
|
|
278
|
-
${saveButton}
|
|
279
|
-
<button type="button" class="bf-btn" id="cancel">Back to Pi</button>
|
|
364
|
+
<button type="button" class="bf-btn bf-btn--icon" id="shortcuts" aria-label="Keyboard shortcuts" title="Keyboard shortcuts">?</button>${shareControl}${approveButton}${saveControl}${closeControl}
|
|
280
365
|
</div>
|
|
281
366
|
</div><div class="review-progress" aria-hidden="true"><span class="review-progress__fill" id="progress-fill"></span></div></header>
|
|
282
367
|
<div class="${bodyClass}">
|
|
283
368
|
${outlinePanel}<main class="bf-app__main bf-review" id="plan" aria-label="Plan"></main>
|
|
284
369
|
</div>
|
|
285
|
-
${keysSheet(reviewKeys(data.canApprove))}
|
|
370
|
+
${keysSheet(reviewKeys(data.role, data.canApprove))}
|
|
286
371
|
${data.canApprove ? approveDialog() : ""}
|
|
372
|
+
${data.role === "owner" ? shareDialog() : nameDialog()}
|
|
287
373
|
<script type="application/json" id="review-data">${island}</script>
|
|
288
374
|
<script src="/client.js" defer></script>
|
|
289
375
|
</body>
|