@gavin-hu/my-pi-agent 0.16.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.
Files changed (200) hide show
  1. package/README.md +29 -15
  2. package/README.zh-CN.md +27 -15
  3. package/extensions/bytefly/README.md +195 -115
  4. package/extensions/bytefly/footer/collect/entries.ts +1 -6
  5. package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
  6. package/extensions/bytefly/footer/price/price.ts +12 -8
  7. package/extensions/bytefly/footer/register.ts +13 -1
  8. package/extensions/bytefly/footer/types.ts +11 -0
  9. package/extensions/bytefly/footer/unpriced.ts +36 -0
  10. package/extensions/bytefly/header/component.ts +15 -29
  11. package/extensions/bytefly/header/identity.ts +71 -0
  12. package/extensions/bytefly/header/register.ts +9 -3
  13. package/extensions/bytefly/index.ts +13 -5
  14. package/extensions/bytefly/message/format.ts +20 -13
  15. package/extensions/bytefly/message/register.ts +8 -7
  16. package/extensions/bytefly/notify/types.ts +1 -1
  17. package/extensions/bytefly/panel/commands.ts +46 -25
  18. package/extensions/bytefly/panel/format.ts +49 -14
  19. package/extensions/bytefly/panel/join.ts +35 -9
  20. package/extensions/bytefly/panel/settings.ts +29 -39
  21. package/extensions/bytefly/panel/skills.ts +51 -0
  22. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
  23. package/extensions/bytefly/panel/tui/frame.ts +9 -1
  24. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  25. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  26. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  27. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  28. package/extensions/bytefly/panel/tui.ts +223 -331
  29. package/extensions/bytefly/panel/types.ts +26 -8
  30. package/extensions/bytefly/picker/discovery.ts +32 -27
  31. package/extensions/bytefly/picker/resources.ts +16 -8
  32. package/extensions/bytefly/picker/runtime.ts +62 -34
  33. package/extensions/bytefly/picker/toggle.ts +16 -10
  34. package/extensions/bytefly/picker/types.ts +31 -13
  35. package/extensions/bytefly/update/format.ts +66 -8
  36. package/extensions/bytefly/update/patch.ts +66 -0
  37. package/extensions/bytefly/update/register.ts +4 -11
  38. package/extensions/bytefly/update/release.ts +65 -0
  39. package/extensions/bytefly/update/state.ts +14 -0
  40. package/extensions/bytefly/update/suppress.ts +47 -29
  41. package/extensions/bytefly/update/types.ts +15 -5
  42. package/extensions/check/tui.ts +2 -0
  43. package/extensions/context/README.md +21 -11
  44. package/extensions/context/format.ts +10 -8
  45. package/extensions/context/report.ts +21 -8
  46. package/extensions/context/tui.ts +26 -10
  47. package/extensions/context/types.ts +5 -3
  48. package/extensions/file-browse/README.md +89 -22
  49. package/extensions/file-browse/chrome.ts +83 -0
  50. package/extensions/file-browse/client.ts +74 -459
  51. package/extensions/file-browse/config.ts +12 -3
  52. package/extensions/file-browse/diff-html.ts +94 -0
  53. package/extensions/file-browse/html.ts +26 -152
  54. package/extensions/file-browse/live-client.ts +47 -1
  55. package/extensions/file-browse/live.ts +35 -14
  56. package/extensions/file-browse/palette.ts +224 -0
  57. package/extensions/file-browse/rev.ts +42 -0
  58. package/extensions/file-browse/router.ts +27 -6
  59. package/extensions/file-browse/server.ts +7 -0
  60. package/extensions/file-browse/styles.ts +10 -3
  61. package/extensions/file-browse/tree.ts +381 -0
  62. package/extensions/file-read/README.md +4 -1
  63. package/extensions/file-read/extract/odt.ts +7 -5
  64. package/extensions/goal/plan-route.ts +8 -2
  65. package/extensions/goal/tools.ts +6 -11
  66. package/extensions/job/README.md +7 -5
  67. package/extensions/job/index.ts +35 -52
  68. package/extensions/job/tools.ts +51 -77
  69. package/extensions/memory/README.md +3 -3
  70. package/extensions/memory/tools.ts +4 -6
  71. package/extensions/memory/tui.ts +5 -1
  72. package/extensions/plan/README.md +333 -84
  73. package/extensions/plan/commands.ts +73 -7
  74. package/extensions/plan/feedback.ts +300 -63
  75. package/extensions/plan/index.ts +1 -1
  76. package/extensions/plan/plan-index.ts +4 -2
  77. package/extensions/plan/plan-text.ts +18 -1
  78. package/extensions/plan/plans.ts +184 -58
  79. package/extensions/plan/review-prompt.ts +93 -0
  80. package/extensions/plan/review.ts +95 -95
  81. package/extensions/plan/runtime.ts +4 -2
  82. package/extensions/plan/split.ts +87 -0
  83. package/extensions/plan/steps.ts +123 -107
  84. package/extensions/plan/tools/cancel.ts +79 -0
  85. package/extensions/plan/tools/enter.ts +17 -12
  86. package/extensions/plan/tools/exit.ts +148 -59
  87. package/extensions/plan/tools/steps.ts +7 -19
  88. package/extensions/plan/tools/write.ts +29 -31
  89. package/extensions/plan/tools.ts +6 -3
  90. package/extensions/plan/tui.ts +60 -15
  91. package/extensions/plan/types.ts +45 -3
  92. package/extensions/plan/web-chrome.ts +102 -0
  93. package/extensions/plan/web-client.ts +177 -446
  94. package/extensions/plan/web-collab.ts +207 -0
  95. package/extensions/plan/web-comments.ts +269 -0
  96. package/extensions/plan/web-markdown.ts +222 -0
  97. package/extensions/plan/web-page.ts +131 -19
  98. package/extensions/plan/web-router.ts +148 -32
  99. package/extensions/plan/web-share-session.ts +197 -0
  100. package/extensions/plan/web-share.ts +169 -0
  101. package/extensions/plan/web-streams.ts +124 -0
  102. package/extensions/plan/web-tokens.ts +63 -0
  103. package/extensions/plan/web.ts +193 -45
  104. package/extensions/question/README.md +3 -0
  105. package/extensions/question/dialogs.ts +91 -1
  106. package/extensions/question/tools.ts +2 -1
  107. package/extensions/rewind/checklist.ts +4 -0
  108. package/extensions/rewind/review-tui.ts +3 -1
  109. package/extensions/rewind/rewind-tui.ts +4 -0
  110. package/extensions/session-bus/README.md +247 -0
  111. package/extensions/session-bus/commands.ts +165 -0
  112. package/extensions/session-bus/format.ts +124 -0
  113. package/extensions/session-bus/index.ts +69 -0
  114. package/extensions/session-bus/notice.ts +58 -0
  115. package/extensions/session-bus/protocol.ts +134 -0
  116. package/extensions/session-bus/result.ts +243 -0
  117. package/extensions/session-bus/rows.ts +169 -0
  118. package/extensions/session-bus/runtime.ts +575 -0
  119. package/extensions/session-bus/store.ts +329 -0
  120. package/extensions/session-bus/team.ts +102 -0
  121. package/extensions/session-bus/tools.ts +227 -0
  122. package/extensions/session-bus/tui.ts +394 -0
  123. package/extensions/session-bus/types.ts +98 -0
  124. package/extensions/subagent/README.md +26 -13
  125. package/extensions/subagent/format.ts +72 -25
  126. package/extensions/subagent/index.ts +21 -47
  127. package/extensions/subagent/orchestrate.ts +18 -41
  128. package/extensions/subagent/render.ts +29 -42
  129. package/extensions/subagent/stream.ts +24 -18
  130. package/extensions/subagent/types.ts +7 -0
  131. package/extensions/todo/tools.ts +5 -6
  132. package/extensions/todo/tui.ts +5 -1
  133. package/extensions/watch/README.md +9 -5
  134. package/extensions/watch/index.ts +25 -44
  135. package/extensions/watch/tools.ts +31 -59
  136. package/extensions/wechat/README.md +45 -36
  137. package/extensions/wechat/bridge.ts +46 -48
  138. package/extensions/wechat/client.ts +1 -4
  139. package/extensions/wechat/commands.ts +3 -3
  140. package/extensions/wechat/deliver.ts +57 -37
  141. package/extensions/wechat/interaction.ts +54 -18
  142. package/extensions/wechat/login-screen.ts +2 -3
  143. package/extensions/wechat/notices.ts +9 -7
  144. package/extensions/wechat/peer-worker.ts +19 -34
  145. package/extensions/wechat/presence.ts +3 -4
  146. package/extensions/wechat/prompt.ts +53 -23
  147. package/extensions/wechat/reply-pipeline.ts +12 -4
  148. package/extensions/wechat/slash.ts +14 -0
  149. package/extensions/wechat/status-screen.ts +2 -2
  150. package/extensions/wechat/tools.ts +3 -11
  151. package/extensions/worktree/README.md +48 -12
  152. package/extensions/worktree/commands.ts +109 -65
  153. package/extensions/worktree/enter.ts +1 -1
  154. package/extensions/worktree/exit.ts +11 -1
  155. package/extensions/worktree/git.ts +5 -0
  156. package/extensions/worktree/lifecycle.ts +9 -1
  157. package/extensions/worktree/registry.ts +11 -5
  158. package/extensions/worktree/screen.ts +263 -0
  159. package/extensions/worktree/status.ts +84 -40
  160. package/lib/README.md +12 -7
  161. package/lib/claims/overlap.ts +43 -0
  162. package/lib/config.ts +12 -0
  163. package/lib/format.ts +6 -2
  164. package/lib/http/router.ts +40 -13
  165. package/lib/http/server.ts +27 -13
  166. package/lib/interaction.ts +28 -1
  167. package/lib/notice.ts +29 -2
  168. package/lib/plan-entry.ts +7 -2
  169. package/lib/plans/sections.ts +172 -0
  170. package/lib/plans/setting.ts +52 -0
  171. package/lib/plans/split.ts +215 -0
  172. package/lib/plans/store.ts +19 -0
  173. package/lib/plans/team.ts +151 -0
  174. package/lib/qr.ts +64 -0
  175. package/lib/repo-key.ts +74 -0
  176. package/lib/report.ts +117 -25
  177. package/lib/requirements.ts +1 -2
  178. package/lib/tool-result.ts +54 -4
  179. package/lib/tui/README.md +30 -8
  180. package/lib/tui/chrome.ts +27 -6
  181. package/lib/tui/dock-screen.ts +91 -19
  182. package/lib/tui/gauge.ts +9 -5
  183. package/lib/tui/line-pane.ts +15 -4
  184. package/lib/tui/tool.ts +23 -4
  185. package/lib/usage.ts +26 -0
  186. package/lib/web/README.md +131 -0
  187. package/lib/web/base.ts +19 -2
  188. package/lib/web/components.ts +10 -6
  189. package/lib/web/layout.ts +2 -0
  190. package/lib/web/markup.ts +2 -1
  191. package/lib/web/runtime.ts +103 -25
  192. package/lib/web/tokens.ts +23 -7
  193. package/package.json +5 -2
  194. package/skills/changelog/SKILL.md +57 -45
  195. package/skills/init/SKILL.md +80 -45
  196. package/skills/init/references/agents-md-quality.md +26 -2
  197. package/skills/init/references/interview-questions.md +36 -14
  198. package/skills/next/SKILL.md +209 -0
  199. package/skills/parallel-dev/SKILL.md +150 -0
  200. package/extensions/wechat/qrcode.ts +0 -46
@@ -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
+ `;
@@ -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
+ `;