@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
@@ -2,8 +2,10 @@
2
2
  * The browser review client, served as `/client.js`.
3
3
  *
4
4
  * Kept as one self-contained script (like `file-browse/client.ts`) with no
5
- * build step. It renders the plan from the JSON data island using DOM text
6
- * nodes only — plan and comment text never becomes markup — renders each block
5
+ * build step. Its document half — the inline and block Markdown rendering — is
6
+ * `web-markdown.ts`, a fragment of the same IIFE interpolated below. It renders
7
+ * the plan from the JSON data island using DOM text nodes only — plan and
8
+ * comment text never becomes markup — renders each block
7
9
  * with its comment threads indented directly beneath it, highlights code by
8
10
  * copying only `tok-*` spans through `DOMParser`, builds the section outline
9
11
  * and review facts, and saves the annotation list back to the server. An
@@ -11,7 +13,11 @@
11
13
  * gone; the explicit buttons are the primary save/cancel path.
12
14
  */
13
15
 
14
- import { keysDialogScript, THEME_TOGGLE_JS } from "../../lib/web/index.ts";
16
+ import { dialogScript, keysDialogScript, THEME_TOGGLE_JS } from "../../lib/web/index.ts";
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";
20
+ import { WEB_MARKDOWN_JS } from "./web-markdown.ts";
15
21
 
16
22
  export const REVIEW_CLIENT_JS = String.raw`(function () {
17
23
  "use strict";
@@ -26,6 +32,18 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
26
32
  }
27
33
  var token = typeof data.token === "string" ? data.token : "";
28
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;
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.";
29
47
  var annotations = Array.isArray(data.annotations) ? data.annotations.slice() : [];
30
48
  var saved = false;
31
49
  var approved = false;
@@ -44,6 +62,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
44
62
  var stateEl = document.getElementById("review-state");
45
63
  var approveButton = document.getElementById("approve");
46
64
  var saveButton = document.getElementById("save");
65
+ var postButton = document.getElementById("post");
47
66
  var cancelButton = document.getElementById("cancel");
48
67
  var outlineList = document.getElementById("outline-list");
49
68
  var progressEl = document.getElementById("progress-fill");
@@ -54,386 +73,25 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
54
73
  return node;
55
74
  }
56
75
 
57
- function setStatus(text, kind) {
58
- if (!statusEl) return;
59
- statusEl.textContent = text || "";
60
- statusEl.title = text || "";
61
- statusEl.className = kind ? "bf-status bf-status--" + kind : "bf-status";
62
- }
76
+ ${WEB_CHROME_JS}
63
77
 
64
- // The always-visible header chip: connection, comment count, and whether
65
- // there are edits the file does not have yet.
66
- function updateState() {
67
- if (!stateEl) return;
68
- var label;
69
- var tone = "";
70
- if (approved) {
71
- label = "Approved";
72
- tone = "success";
73
- } else if (saved) {
74
- label = "Saved";
75
- tone = "success";
76
- } else if (closed) {
77
- // Settled by the user, not by a dropped stream: never flash Offline.
78
- label = "Closed";
79
- } else if (connected === true) {
80
- label = "Connected";
81
- tone = "success";
82
- } else if (everConnected) {
83
- // Only after the stream has opened once: an error before that is a
84
- // transient initial connect, so keep showing Connecting rather than Offline.
85
- label = "Offline";
86
- tone = "danger";
87
- } else {
88
- label = "Connecting\u2026";
89
- }
90
- var count = annotations.length;
91
- label += " \u00b7 " + count + (count === 1 ? " comment" : " comments");
92
- if (dirty && !saved && !approved && !closed) {
93
- label += " \u00b7 unsaved";
94
- if (!tone) tone = "warn";
95
- }
96
- stateEl.textContent = label;
97
- stateEl.className = "bf-chip bf-chip--accent review-state" + (tone ? " bf-chip--" + tone : "");
98
- }
78
+ ${WEB_COMMENTS_JS}
99
79
 
100
- // A link is rendered only for an http(s) or mailto target. Resolving against
101
- // the review origin is what catches a target that only looks relative: the
102
- // URL parser strips a tab or newline, so "/\t/evil.com" is cross-origin once
103
- // it is parsed, and a disguised target must stay text.
104
- function safeHref(url, base) {
105
- var trimmed = String(url).trim();
106
- if (!trimmed || /^\/\//.test(trimmed)) return null;
107
- var resolved;
108
- try { resolved = new URL(trimmed, base); } catch (error) { return null; }
109
- if (resolved.protocol === "mailto:") return trimmed;
110
- if (resolved.protocol !== "http:" && resolved.protocol !== "https:") return null;
111
- if (resolved.username || resolved.password) return null;
112
- if (resolved.origin === new URL(base).origin) return resolved.href;
113
- return /^https?:\/\//i.test(trimmed) ? resolved.href : null;
114
- }
115
-
116
- // Inline token order matters: code, emphasis, and links are matched before a
117
- // bare URL, so a URL inside a link target or a code span is never re-split.
118
- var INLINE = /(\x60[^\x60]+\x60|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\)|(?:https?:\/\/|www\.)[^\s<]+)/g;
119
-
120
- function renderInline(container, text) {
121
- String(text).split(INLINE).forEach(function (part) {
122
- if (!part) return;
123
- var match;
124
- if ((match = /^\x60([^\x60]+)\x60$/.exec(part))) {
125
- var code = el("code"); code.textContent = match[1]; container.appendChild(code); return;
126
- }
127
- if ((match = /^\*\*([^*]+)\*\*$/.exec(part))) {
128
- var strong = el("strong"); strong.textContent = match[1]; container.appendChild(strong); return;
129
- }
130
- if ((match = /^\*([^*]+)\*$/.exec(part))) {
131
- var em = el("em"); em.textContent = match[1]; container.appendChild(em); return;
132
- }
133
- if ((match = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(part))) {
134
- var href = safeHref(match[2], location.href);
135
- if (!href) { container.appendChild(document.createTextNode(part)); return; }
136
- var link = el("a"); link.textContent = match[1]; link.href = href; link.target = "_blank"; link.rel = "noreferrer noopener";
137
- container.appendChild(link); return;
138
- }
139
- // GFM extended autolink: a bare http(s) or www. URL in running text.
140
- if ((match = /^(?:https?:\/\/|www\.)[^\s<]+$/.exec(part))) {
141
- var url = match[0].replace(/[.,;:!?*_~]+$/, "");
142
- while (url.charAt(url.length - 1) === ")" && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) {
143
- url = url.slice(0, -1).replace(/[.,;:!?*_~]+$/, "");
144
- }
145
- var dot = url.indexOf(".");
146
- var target = /^www\./i.test(url) ? "http://" + url : url;
147
- var safe = dot > 0 && /\.[^./]/.test(url.slice(dot)) ? safeHref(target, location.href) : null;
148
- if (safe) {
149
- var auto = el("a");
150
- auto.textContent = url;
151
- auto.href = safe;
152
- auto.target = "_blank";
153
- auto.rel = "noreferrer noopener";
154
- container.appendChild(auto);
155
- return;
156
- }
157
- }
158
- container.appendChild(document.createTextNode(part));
159
- });
160
- }
161
-
162
- // Copy only text and tok-* spans out of server-highlighted HTML, so a code
163
- // line can never introduce markup even though it arrives as a string.
164
- function copyTokens(from, to) {
165
- Array.prototype.forEach.call(from.childNodes, function (node) {
166
- if (node.nodeType === 3) { to.appendChild(document.createTextNode(node.nodeValue)); return; }
167
- if (node.nodeType === 1 && node.tagName === "SPAN" && /^tok-/.test(node.className || "")) {
168
- var span = el("span", node.className);
169
- copyTokens(node, span);
170
- to.appendChild(span);
171
- return;
172
- }
173
- to.appendChild(document.createTextNode(node.textContent || ""));
174
- });
175
- }
176
-
177
- function codeBody(lines) {
178
- var body = Array.isArray(lines) ? lines.slice() : [];
179
- if (body.length && /^ {0,3}(\x60{3,}|~{3,})/.test(body[0])) body.shift();
180
- if (body.length && /^ {0,3}(\x60{3,}|~{3,})/.test(body[body.length - 1])) body.pop();
181
- return body;
182
- }
183
-
184
- function renderCode(wrap, block) {
185
- var body = codeBody(block.lines);
186
- var highlighted = data.code && data.code[String(block.start)];
187
- var pre = el("pre", "bf-code");
188
- var code = el("code");
189
- if (Array.isArray(highlighted)) {
190
- body.forEach(function (line, index) {
191
- var span = el("span", "bf-code__line");
192
- var html = highlighted[index];
193
- if (html) {
194
- try { copyTokens(new DOMParser().parseFromString(html, "text/html").body, span); }
195
- catch (error) { span.textContent = line; }
196
- } else {
197
- span.textContent = line;
198
- }
199
- code.appendChild(span);
200
- if (index < body.length - 1) code.appendChild(document.createTextNode("\n"));
201
- });
202
- } else {
203
- code.textContent = body.join("\n");
204
- }
205
- pre.appendChild(code);
206
- wrap.appendChild(pre);
207
- }
208
-
209
- // A GFM table: split each pipe row into cells and honour the alignment row.
210
- function splitRow(line) {
211
- var text = String(line).trim();
212
- if (text.charAt(0) === "|") text = text.slice(1);
213
- if (text.charAt(text.length - 1) === "|") text = text.slice(0, -1);
214
- return text.split("|").map(function (cell) { return cell.trim(); });
215
- }
216
-
217
- function columnAlign(delim) {
218
- return splitRow(delim).map(function (cell) {
219
- var left = cell.charAt(0) === ":";
220
- var right = cell.charAt(cell.length - 1) === ":";
221
- return left && right ? "center" : right ? "right" : left ? "left" : "";
222
- });
223
- }
224
-
225
- function renderTable(wrap, block) {
226
- var rows = block.lines.map(splitRow);
227
- var align = columnAlign(block.lines[1] || "");
228
- var table = el("table", "review-table");
229
- var thead = el("thead");
230
- var headRow = el("tr");
231
- rows[0].forEach(function (cell, index) {
232
- var th = el("th");
233
- if (align[index]) th.style.textAlign = align[index];
234
- renderInline(th, cell);
235
- headRow.appendChild(th);
236
- });
237
- thead.appendChild(headRow);
238
- table.appendChild(thead);
239
- var tbody = el("tbody");
240
- rows.slice(2).forEach(function (cells) {
241
- var tr = el("tr");
242
- rows[0].forEach(function (unused, index) {
243
- var td = el("td");
244
- if (align[index]) td.style.textAlign = align[index];
245
- renderInline(td, cells[index] || "");
246
- tr.appendChild(td);
247
- });
248
- tbody.appendChild(tr);
249
- });
250
- table.appendChild(tbody);
251
- var scroll = el("div", "review-table-wrap");
252
- scroll.appendChild(table);
253
- wrap.appendChild(scroll);
254
- }
255
-
256
- function renderBlockContent(wrap, block) {
257
- var lines = Array.isArray(block.lines) ? block.lines : [];
258
- if (block.kind === "heading") {
259
- var text = lines.join(" ").replace(/^\s*#{1,6}\s*/, "");
260
- var level = Math.min(6, Math.max(1, (lines[0].match(/^ {0,3}(#{1,6})/) || ["", "#"])[1].length));
261
- var heading = el("h" + level);
262
- renderInline(heading, text);
263
- wrap.appendChild(heading);
264
- return;
265
- }
266
- if (block.kind === "code") { renderCode(wrap, block); return; }
267
- if (block.kind === "table") { renderTable(wrap, block); return; }
268
- if (block.kind === "hr") { wrap.appendChild(el("hr")); return; }
269
- if (block.kind === "quote") {
270
- var quote = el("blockquote");
271
- renderInline(quote, lines.map(function (line) { return line.replace(/^\s*>\s?/, ""); }).join("\n"));
272
- wrap.appendChild(quote);
273
- return;
274
- }
275
- if (block.kind === "list") {
276
- var items = [];
277
- lines.forEach(function (line) {
278
- var match = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
279
- if (!match) {
280
- // A following unmarked line is a CommonMark lazy continuation of the
281
- // item above; join it so the paragraph hangs under the item's text.
282
- var last = items[items.length - 1];
283
- if (last) { if (line.trim()) last.rest += " " + line.trim(); return; }
284
- var plain = el("p"); renderInline(plain, line); wrap.appendChild(plain); return;
285
- }
286
- var row = el("div", "bf-block__li");
287
- row.style.paddingLeft = String(Math.floor(match[1].replace(/\t/g, " ").length / 2) * 16) + "px";
288
- var markerText = match[2];
289
- var rest = match[3];
290
- var box = /^\[([ xX])\]\s*(.*)$/.exec(rest);
291
- var marker = el("span", "marker");
292
- if (box) {
293
- var done = box[1].toLowerCase() === "x";
294
- markerText = done ? "\u2713" : "\u2610";
295
- marker.className = done ? "bf-block__marker bf-block__marker--done" : "bf-block__marker";
296
- rest = box[2];
297
- }
298
- marker.textContent = markerText;
299
- var body = el("span");
300
- row.appendChild(marker); row.appendChild(body); wrap.appendChild(row);
301
- items.push({ body: body, rest: rest });
302
- });
303
- // Render each body once, after its continuation lines are collected.
304
- items.forEach(function (item) { renderInline(item.body, item.rest); });
305
- return;
306
- }
307
- var paragraph = el("p");
308
- renderInline(paragraph, lines.join(" "));
309
- wrap.appendChild(paragraph);
310
- }
80
+ ${WEB_COLLAB_JS}
311
81
 
82
+ ${WEB_MARKDOWN_JS}
312
83
  function closeComposer() {
313
84
  var open = planEl ? planEl.querySelector(".bf-composer") : null;
314
85
  if (open && open.parentNode) open.parentNode.removeChild(open);
315
86
  Array.prototype.forEach.call(planEl ? planEl.querySelectorAll(".bf-block--composing") : [], function (block) {
316
87
  block.classList.remove("bf-block--composing");
317
88
  });
318
- }
319
-
320
- function selectionIn(wrap) {
321
- var selection = window.getSelection && window.getSelection();
322
- if (!selection || selection.isCollapsed) return "";
323
- var range = selection.rangeCount ? selection.getRangeAt(0) : null;
324
- if (!range || !wrap.contains(range.commonAncestorContainer)) return "";
325
- return selection.toString().replace(/\s+/g, " ").trim().slice(0, limits.quote);
326
- }
327
-
328
- function commentsBoxAfter(wrap) {
329
- var next = wrap.nextElementSibling;
330
- if (next && next.classList && next.classList.contains("bf-comments")) return next;
331
- var box = el("div", "bf-comments");
332
- wrap.insertAdjacentElement("afterend", box);
333
- return box;
334
- }
335
-
336
- // Enter submits a comment; Shift+Enter is left for the browser.
337
- function submitOnEnter(area, submit) {
338
- area.addEventListener("keydown", function (event) {
339
- // An IME uses Enter to commit a composition; submitting then would send a
340
- // half-typed comment.
341
- if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
342
- event.preventDefault();
343
- submit();
344
- });
345
- }
346
-
347
- function openComposer(wrap, block, quote) {
348
- closeComposer();
349
- wrap.classList.add("bf-block--composing");
350
- var composer = el("div", "bf-composer");
351
- if (quote) {
352
- var chip = el("div", "bf-quote-chip");
353
- chip.textContent = "\u201c" + quote + "\u201d";
354
- composer.appendChild(chip);
355
- }
356
- var area = el("textarea", "bf-textarea");
357
- area.placeholder = "Leave feedback on this part of the plan (Enter to add)";
358
- area.setAttribute("aria-label", "Comment");
359
- area.maxLength = limits.comment;
360
- composer.appendChild(area);
361
- var row = el("div", "bf-composer__row");
362
- var cancel = el("button"); cancel.type = "button"; cancel.className = "bf-btn"; cancel.textContent = "Cancel";
363
- cancel.addEventListener("click", closeComposer);
364
- var add = el("button"); add.type = "button"; add.className = "bf-btn bf-btn--primary"; add.textContent = "Add comment";
365
- var submit = function () {
366
- var comment = area.value.replace(/\s+/g, " ").trim();
367
- if (!comment) { area.focus(); return; }
368
- if (annotations.length >= limits.comments) { setStatus("Comment limit reached (" + limits.comments + ").", "error"); return; }
369
- annotations.push(quote ? { anchor: block.start, quote: quote, comment: comment } : { anchor: block.start, comment: comment });
370
- dirty = true;
371
- renderPlan();
372
- };
373
- add.addEventListener("click", submit);
374
- submitOnEnter(area, submit);
375
- row.appendChild(cancel); row.appendChild(add); composer.appendChild(row);
376
- commentsBoxAfter(wrap).appendChild(composer);
377
- area.focus();
378
- }
379
-
380
- function startEdit(card, index) {
381
- var annotation = annotations[index];
382
- card.textContent = "";
383
- var area = el("textarea", "bf-textarea");
384
- area.value = annotation.comment;
385
- area.setAttribute("aria-label", "Edit comment");
386
- area.maxLength = limits.comment;
387
- card.appendChild(area);
388
- var row = el("div", "bf-comment__row");
389
- var cancel = el("button"); cancel.type = "button"; cancel.className = "bf-btn"; cancel.textContent = "Cancel";
390
- cancel.addEventListener("click", renderPlan);
391
- var apply = el("button"); apply.type = "button"; apply.className = "bf-btn bf-btn--primary"; apply.textContent = "Save";
392
- apply.addEventListener("click", function () {
393
- var comment = area.value.replace(/\s+/g, " ").trim();
394
- if (!comment) { area.focus(); return; }
395
- annotation.comment = comment;
396
- dirty = true;
397
- renderPlan();
398
- });
399
- submitOnEnter(area, function () { apply.click(); });
400
- row.appendChild(cancel); row.appendChild(apply); card.appendChild(row);
401
- area.focus();
402
- }
403
-
404
- function renderCommentCards(box, list) {
405
- list.forEach(function (annotation) {
406
- var index = annotations.indexOf(annotation);
407
- var card = el("div", "bf-comment");
408
- var head = el("div", "bf-comment__head");
409
- var label = el("span", "bf-comment__label"); label.textContent = "Review";
410
- head.appendChild(label);
411
- head.appendChild(el("span", "bf-comment__spacer"));
412
- var actions = el("div", "bf-comment__actions");
413
- var edit = el("button"); edit.type = "button"; edit.className = "bf-btn"; edit.textContent = "Edit";
414
- edit.addEventListener("click", function () { startEdit(card, index); });
415
- var remove = el("button"); remove.type = "button"; remove.className = "bf-btn"; remove.textContent = "Delete";
416
- remove.addEventListener("click", function () { annotations.splice(index, 1); dirty = true; renderPlan(); });
417
- actions.appendChild(edit); actions.appendChild(remove); head.appendChild(actions);
418
- card.appendChild(head);
419
- if (annotation.quote) {
420
- var quote = el("div", "bf-comment__quote");
421
- quote.textContent = "\u201c" + annotation.quote + "\u201d";
422
- card.appendChild(quote);
423
- }
424
- var body = el("div", "bf-comment__body");
425
- body.textContent = annotation.comment;
426
- card.appendChild(body);
427
- box.appendChild(card);
428
- });
429
- }
430
-
431
- function renderComments(container, anchor) {
432
- var mine = annotations.filter(function (annotation) { return annotation.anchor === anchor; });
433
- if (mine.length === 0) return;
434
- var box = el("div", "bf-comments");
435
- renderCommentCards(box, mine);
436
- container.appendChild(box);
89
+ // The only status a composer leaves behind is the refusal to leave it; once
90
+ // the draft is gone that sentence is stale.
91
+ if (statusEl && statusEl.textContent === DRAFT_REFUSAL) setStatus("");
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();
437
95
  }
438
96
 
439
97
  function observeSections() {
@@ -501,6 +159,9 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
501
159
  // The server counts the same step list approval seeds, so this never
502
160
  // disagrees with the todo list over a checkbox outside the Steps section.
503
161
  var steps = data.steps || { done: 0, total: 0 };
162
+ // A plan with no Steps section has no progress to show; 0/0 is noise.
163
+ var stepFact = document.getElementById("step-fact");
164
+ if (stepFact) stepFact.hidden = !steps.total;
504
165
  var stepFill = document.getElementById("step-fill");
505
166
  if (stepFill) stepFill.style.width = (steps.total > 0 ? Math.round((steps.done / steps.total) * 100) : 0) + "%";
506
167
  var stepCount = document.getElementById("step-count");
@@ -540,19 +201,36 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
540
201
  setActive(activeIndex + delta);
541
202
  }
542
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
+
543
218
  function commentOnActive() {
544
- var blocks = blockElements();
545
- if (blocks.length === 0) return;
219
+ if (blockElements().length === 0) return;
546
220
  if (activeIndex < 0) setActive(0);
547
221
  var wrap = blockElements()[Math.max(0, activeIndex)];
548
222
  if (!wrap) return;
549
- var line = Number(wrap.getAttribute("data-line"));
550
- var block = (data.blocks || []).filter(function (item) { return item.start === line; })[0];
551
- if (block) openComposer(wrap, block, "");
223
+ var target = blockAtLine(Number(wrap.getAttribute("data-line")));
224
+ if (target) openComposer(target.wrap, target.block, "");
552
225
  }
553
226
 
554
227
  function renderPlan() {
555
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));
556
234
  closeComposer();
557
235
  pendingQuote = null;
558
236
  var scrollTop = planEl.scrollTop;
@@ -604,83 +282,52 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
604
282
  // place rather than scrolling, so j/k resume where the reviewer was.
605
283
  var active = activeIndex >= 0 ? blockElements()[activeIndex] : null;
606
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
+ }
607
291
  renderFacts();
608
292
  observeSections();
609
293
  updateState();
610
294
  }
611
295
 
612
- function renderHeader() {
613
- var titleEl = document.getElementById("plan-title");
614
- if (titleEl && data.title) {
615
- titleEl.textContent = data.title;
616
- titleEl.title = data.title;
617
- }
618
- if (data.title) document.title = "Plan review \u00b7 " + data.title;
619
- }
620
-
621
- function updateProgress() {
622
- if (!progressEl) return;
623
- var doc = document.scrollingElement || document.documentElement;
624
- var top = doc ? doc.scrollTop : 0;
625
- var height = doc ? doc.scrollHeight : 0;
626
- var view = doc ? doc.clientHeight : 0;
627
- if (planEl && planEl.scrollHeight > planEl.clientHeight + 4) {
628
- top = planEl.scrollTop; height = planEl.scrollHeight; view = planEl.clientHeight;
629
- }
630
- var max = height - view;
631
- var percent = max > 0 ? Math.min(100, Math.max(0, (top / max) * 100)) : 0;
632
- progressEl.style.width = percent + "%";
633
- }
634
-
635
- function lockActions() {
636
- if (approveButton) approveButton.disabled = true;
637
- if (saveButton) saveButton.disabled = true;
638
- if (cancelButton) cancelButton.disabled = true;
639
- }
640
-
641
- // A conflict means the file moved under the page; only a reload can re-anchor.
642
- function offerReload() {
643
- if (document.getElementById("reload")) return;
644
- var controls = document.querySelector(".review-controls");
645
- if (!controls) return;
646
- var reload = el("button");
647
- reload.type = "button";
648
- reload.id = "reload";
649
- reload.className = "bf-btn bf-btn--primary";
650
- reload.textContent = "Reload";
651
- reload.addEventListener("click", function () { window.location.reload(); });
652
- controls.appendChild(reload);
653
- }
654
296
 
655
- // The stream exists only to notice a closed tab. Once the review is settled it
656
- // has no job, and leaving it open retries a dead port until the tab closes.
657
- function settleStream() {
658
- if (stream && stream.close) { stream.close(); stream = null; }
659
- }
660
-
661
- // Save and approve share the same POST shape; only the route, the settled
662
- // flag, and the wording differ. Both persist the current annotations.
663
- function submit(url, okText, failText, mark) {
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;
664
303
  fetch(url + "?token=" + encodeURIComponent(token), {
665
304
  method: "POST",
666
305
  headers: { "Content-Type": "application/json" },
667
- body: JSON.stringify({ annotations: annotations })
306
+ body: JSON.stringify({ annotations: payload })
668
307
  }).then(function (response) {
669
308
  if (response.ok) {
670
- mark();
671
309
  dirty = false;
672
- settleStream();
310
+ clearDraft();
673
311
  setStatus(okText, "saved");
674
- lockActions();
312
+ if (onOk) onOk();
675
313
  updateState();
676
- return;
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 () {});
677
319
  }
678
320
  if (response.status === 409) {
679
- setStatus("The plan changed on disk \u2014 reload to review again.", "error");
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.
680
323
  if (approveButton) approveButton.disabled = true;
681
324
  if (saveButton) saveButton.disabled = true;
325
+ if (postButton) postButton.disabled = true;
682
326
  offerReload();
683
- 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
+ );
684
331
  }
685
332
  // Surface the server's own reason (a malformed comment, a stale body)
686
333
  // rather than a bare status code.
@@ -693,20 +340,55 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
693
340
  });
694
341
  }
695
342
 
343
+ // A composer holds text the page has not committed, so the ways off the page
344
+ // refuse while it does: leaving would drop what the reviewer typed.
345
+ function draftTextarea() {
346
+ var open = planEl ? planEl.querySelector(".bf-composer") : null;
347
+ var area = open ? open.querySelector("textarea") : null;
348
+ return area && area.value.trim() ? area : null;
349
+ }
350
+ function guardDraft() {
351
+ var area = draftTextarea();
352
+ if (!area) return false;
353
+ setStatus(DRAFT_REFUSAL, "error");
354
+ area.focus();
355
+ return true;
356
+ }
357
+
696
358
  function save() {
697
359
  if (saved || approved || closed) return;
360
+ if (guardDraft()) return;
698
361
  setStatus("Saving\u2026");
699
- submit("/api/feedback", "Saved \u2014 return to Pi to continue.", "Could not save: ", function () { saved = true; });
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);
700
376
  }
701
377
 
702
378
  function approve() {
703
379
  if (saved || approved || closed) return;
704
380
  setStatus("Approving\u2026");
705
- submit("/api/approve", "Approved \u2014 return to Pi to continue.", "Could not approve: ", function () { approved = true; });
381
+ submit("/api/approve", "Approved \u2014 return to Pi to continue.", "Could not approve: ", function () {
382
+ approved = true;
383
+ settleStream();
384
+ lockActions();
385
+ });
706
386
  }
707
387
 
708
388
  function cancel() {
709
- if (saved || approved || closed) return;
389
+ // Closing the review is the owner's decision; a reviewer closes their tab.
390
+ if (role !== "owner" || saved || approved || closed) return;
391
+ if (guardDraft()) return;
710
392
  closed = true;
711
393
  settleStream();
712
394
  lockActions();
@@ -718,10 +400,53 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
718
400
  });
719
401
  }
720
402
 
721
- if (approveButton) approveButton.addEventListener("click", approve);
403
+ if (postButton) postButton.addEventListener("click", postComments);
722
404
  if (saveButton) saveButton.addEventListener("click", save);
723
405
  if (cancelButton) cancelButton.addEventListener("click", cancel);
724
406
 
407
+ // Approving runs the plan and cannot be undone from the page, so the header
408
+ // button and the a key open the confirmation instead of posting. The dialog
409
+ // script is the same one the shortcut sheet uses: Tab trapped, Esc, focus back
410
+ // on whatever opened it.
411
+ ${dialogScript({ dialogId: "approve-dialog", closeId: "approve-cancel", globalName: "bfApprove" })}
412
+ var approveDialog = document.getElementById("approve-dialog");
413
+
414
+ function askApprove() {
415
+ if (saved || approved || closed) return;
416
+ if (guardDraft()) return;
417
+ if (!approveDialog || !window.bfApprove) { approve(); return; }
418
+ window.bfApprove.open();
419
+ }
420
+ function confirmApprove() {
421
+ if (window.bfApprove) window.bfApprove.close();
422
+ approve();
423
+ }
424
+ if (approveButton) approveButton.addEventListener("click", askApprove);
425
+ var approveConfirm = document.getElementById("approve-confirm");
426
+ if (approveConfirm) approveConfirm.addEventListener("click", confirmApprove);
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
+
725
450
  // Remember a selection made inside a block so clicking the rail button can
726
451
  // still quote it after the button press has collapsed the selection.
727
452
  document.addEventListener("mouseup", function (event) {
@@ -731,7 +456,8 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
731
456
  pendingQuote = wrap && quote ? { wrap: wrap, quote: quote } : null;
732
457
  });
733
458
 
734
- // The sheet's own wiring (trigger, close button, backdrop) is shared with browse.
459
+ // The sheet's own wiring — trigger, close button, backdrop, Tab trap, and Esc —
460
+ // is shared with browse; the page keeps only its own keymap.
735
461
  ${keysDialogScript("shortcuts")}
736
462
 
737
463
  function typing(target) {
@@ -741,9 +467,8 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
741
467
 
742
468
  document.addEventListener("keydown", function (event) {
743
469
  if (event.key === "Escape") {
744
- // Esc peels one layer at a time: the composer, then the sheet, then the review.
470
+ // The sheet owns its own Esc; this peels the composer, then the review.
745
471
  if (planEl && planEl.querySelector(".bf-composer")) { closeComposer(); return; }
746
- if (window.bfKeys.isOpen()) { window.bfKeys.close(); return; }
747
472
  cancel();
748
473
  return;
749
474
  }
@@ -755,8 +480,8 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
755
480
  if (event.key === "j") { event.preventDefault(); moveActive(1); return; }
756
481
  if (event.key === "k") { event.preventDefault(); moveActive(-1); return; }
757
482
  if (event.key === "c") { event.preventDefault(); commentOnActive(); return; }
758
- if (event.key === "a" && approveButton) { event.preventDefault(); approve(); return; }
759
- if (event.key === "s") { event.preventDefault(); save(); return; }
483
+ if (event.key === "a" && approveButton) { event.preventDefault(); askApprove(); return; }
484
+ if (event.key === "s") { event.preventDefault(); if (saveButton) save(); else postComments(); return; }
760
485
  });
761
486
 
762
487
  // A closed tab is detected by the stream below: the server sees the last
@@ -772,6 +497,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
772
497
  try {
773
498
  stream = new EventSource("/events?token=" + encodeURIComponent(token));
774
499
  stream.addEventListener("open", function () { connected = true; everConnected = true; updateState(); });
500
+ stream.addEventListener("message", function (event) { refreshFromServer(event); });
775
501
  stream.addEventListener("error", function () {
776
502
  // The server ends the stream once the review is settled; keep showing the
777
503
  // terminal state rather than Offline.
@@ -786,9 +512,14 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
786
512
 
787
513
  ${THEME_TOGGLE_JS}
788
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();
789
518
  renderHeader();
519
+ renderName();
790
520
  renderOutline();
791
521
  renderPlan();
522
+ reopenComposer(restoredTyping);
792
523
  updateProgress();
793
524
  })();
794
525
  `;