@gavin-hu/my-pi-agent 0.20.0 → 0.22.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 (298) hide show
  1. package/README.md +46 -30
  2. package/README.zh-CN.md +40 -25
  3. package/assets/screens/board.png +0 -0
  4. package/assets/screens/chrome.png +0 -0
  5. package/assets/screens/file-browse.png +0 -0
  6. package/assets/screens/plan-review.png +0 -0
  7. package/assets/screens/plan.png +0 -0
  8. package/docs/extensions/README.md +22 -23
  9. package/docs/extensions/board.md +397 -168
  10. package/docs/extensions/bytefly.md +234 -197
  11. package/docs/extensions/check.md +15 -17
  12. package/docs/extensions/context.md +35 -15
  13. package/docs/extensions/file-browse.md +57 -63
  14. package/docs/extensions/file-open.md +9 -11
  15. package/docs/extensions/file-read.md +12 -14
  16. package/docs/extensions/goal.md +15 -17
  17. package/docs/extensions/job.md +54 -42
  18. package/docs/extensions/memory.md +17 -19
  19. package/docs/extensions/plan.md +197 -140
  20. package/docs/extensions/question.md +16 -18
  21. package/docs/extensions/rewind.md +17 -19
  22. package/docs/extensions/serve.md +145 -0
  23. package/docs/extensions/session-bus.md +38 -16
  24. package/docs/extensions/subagent.md +23 -25
  25. package/docs/extensions/team.md +83 -44
  26. package/docs/extensions/todo.md +18 -18
  27. package/docs/extensions/watch.md +31 -31
  28. package/docs/extensions/web-access.md +2 -4
  29. package/docs/extensions/wechat.md +34 -35
  30. package/docs/extensions/worktree.md +56 -50
  31. package/extensions/board/commands.ts +79 -25
  32. package/extensions/board/format.ts +43 -24
  33. package/extensions/board/index.ts +17 -4
  34. package/extensions/board/input.ts +4 -9
  35. package/extensions/board/join.ts +5 -20
  36. package/extensions/board/lazy-page.ts +40 -0
  37. package/extensions/board/page.ts +65 -93
  38. package/extensions/board/result.ts +30 -39
  39. package/extensions/board/rows.ts +62 -25
  40. package/extensions/board/schema.ts +49 -11
  41. package/extensions/board/snapshot.ts +8 -6
  42. package/extensions/board/tone.ts +22 -0
  43. package/extensions/board/tools.ts +154 -55
  44. package/extensions/board/transport/router.ts +3 -5
  45. package/extensions/board/transport/server.ts +59 -28
  46. package/extensions/board/types.ts +36 -42
  47. package/extensions/board/view/card.ts +229 -0
  48. package/extensions/board/view/client.ts +35 -16
  49. package/extensions/board/view/dialog.ts +169 -6
  50. package/extensions/board/view/empty.ts +47 -0
  51. package/extensions/board/view/keys.ts +13 -2
  52. package/extensions/board/view/live.ts +90 -28
  53. package/extensions/board/view/names.ts +13 -4
  54. package/extensions/board/view/page.ts +38 -13
  55. package/extensions/board/view/render.ts +219 -277
  56. package/extensions/board/view/styles.ts +130 -32
  57. package/extensions/board/view/team.ts +297 -76
  58. package/extensions/bytefly/block/fallback.ts +1 -1
  59. package/extensions/bytefly/doctor/runtime.ts +5 -2
  60. package/extensions/bytefly/footer/collect/label.ts +60 -0
  61. package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
  62. package/extensions/bytefly/footer/config.ts +7 -8
  63. package/extensions/bytefly/footer/register.ts +33 -4
  64. package/extensions/bytefly/footer/render/footer.ts +18 -4
  65. package/extensions/bytefly/footer/render/key.ts +46 -0
  66. package/extensions/bytefly/footer/render/layout.ts +13 -3
  67. package/extensions/bytefly/footer/render/lines.ts +10 -7
  68. package/extensions/bytefly/footer/render/segments.ts +41 -38
  69. package/extensions/bytefly/footer/render/status.ts +0 -11
  70. package/extensions/bytefly/footer/types.ts +7 -0
  71. package/extensions/bytefly/header/component.ts +4 -20
  72. package/extensions/bytefly/header/config.ts +1 -1
  73. package/extensions/bytefly/index.ts +32 -6
  74. package/extensions/bytefly/mode/editor.ts +115 -0
  75. package/extensions/bytefly/panel/commands.ts +210 -120
  76. package/extensions/bytefly/panel/format.ts +4 -4
  77. package/extensions/bytefly/panel/settings.ts +73 -6
  78. package/extensions/bytefly/panel/tui/keys.ts +2 -6
  79. package/extensions/bytefly/update/cache.ts +1 -1
  80. package/extensions/bytefly/update/register.ts +4 -4
  81. package/extensions/bytefly/working/config.ts +5 -7
  82. package/extensions/bytefly/working/format.ts +10 -13
  83. package/extensions/bytefly/working/register.ts +22 -27
  84. package/extensions/check/commands.ts +3 -2
  85. package/extensions/check/render.ts +10 -1
  86. package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
  87. package/extensions/context/commands.ts +2 -2
  88. package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
  89. package/extensions/file-browse/config/config.ts +4 -19
  90. package/extensions/file-browse/index.ts +89 -79
  91. package/extensions/file-browse/live/live.ts +1 -1
  92. package/extensions/file-browse/transport/router.ts +12 -4
  93. package/extensions/file-browse/transport/server.ts +61 -28
  94. package/extensions/file-browse/view/chrome.ts +4 -4
  95. package/extensions/file-browse/view/client.ts +3 -1
  96. package/extensions/file-browse/view/html.ts +73 -40
  97. package/extensions/file-browse/view/live-client.ts +2 -2
  98. package/extensions/file-browse/view/markdown.ts +13 -14
  99. package/extensions/file-browse/view/palette.ts +2 -2
  100. package/extensions/file-browse/view/raw-html.ts +9 -9
  101. package/extensions/file-browse/view/tree.ts +1 -1
  102. package/extensions/file-browse/view/urls.ts +20 -6
  103. package/extensions/file-read/render.ts +4 -4
  104. package/extensions/goal/plan-route.ts +2 -2
  105. package/extensions/goal/tui.ts +12 -2
  106. package/extensions/job/child.ts +527 -0
  107. package/extensions/job/clock.ts +142 -0
  108. package/extensions/job/commands.ts +4 -2
  109. package/extensions/job/config.ts +8 -1
  110. package/extensions/job/format.ts +22 -1
  111. package/extensions/job/log-writer.ts +13 -2
  112. package/extensions/job/registry.ts +63 -7
  113. package/extensions/job/render.ts +34 -0
  114. package/extensions/job/runtime.ts +296 -655
  115. package/extensions/job/session.ts +132 -19
  116. package/extensions/job/store.ts +7 -7
  117. package/extensions/job/tools.ts +21 -9
  118. package/extensions/job/types.ts +47 -0
  119. package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -99
  120. package/extensions/job/waiters.ts +44 -1
  121. package/extensions/memory/commands.ts +4 -2
  122. package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
  123. package/extensions/plan/commands.ts +12 -9
  124. package/extensions/plan/feedback.ts +62 -16
  125. package/extensions/plan/format.ts +8 -2
  126. package/extensions/plan/git-bash.ts +115 -16
  127. package/extensions/plan/index.ts +58 -51
  128. package/extensions/plan/plan-index.ts +4 -2
  129. package/extensions/plan/plan-text.ts +114 -0
  130. package/extensions/plan/plans.ts +5 -54
  131. package/extensions/plan/policy.ts +8 -10
  132. package/extensions/plan/review-lazy.ts +85 -0
  133. package/extensions/plan/review.ts +2 -2
  134. package/extensions/plan/runtime.ts +61 -41
  135. package/extensions/plan/sections.ts +1 -1
  136. package/extensions/plan/steps.ts +3 -3
  137. package/extensions/plan/todos.ts +1 -1
  138. package/extensions/plan/tools/cancel.ts +27 -62
  139. package/extensions/plan/tools/enter.ts +55 -81
  140. package/extensions/plan/tools/exit.ts +221 -238
  141. package/extensions/plan/tools/refusal.ts +18 -0
  142. package/extensions/plan/tools/steps.ts +75 -92
  143. package/extensions/plan/tools/write.ts +77 -120
  144. package/extensions/plan/tools.ts +188 -30
  145. package/extensions/plan/tui.ts +5 -5
  146. package/extensions/plan/types.ts +48 -5
  147. package/extensions/plan/web-blocks.ts +76 -5
  148. package/extensions/plan/web-chrome.ts +4 -0
  149. package/extensions/plan/web-client.ts +102 -2
  150. package/extensions/plan/web-collab.ts +4 -4
  151. package/extensions/plan/web-comments.ts +39 -8
  152. package/extensions/plan/web-composer.ts +14 -3
  153. package/extensions/plan/web-keys.ts +149 -25
  154. package/extensions/plan/web-lazy.ts +53 -0
  155. package/extensions/plan/web-markdown.ts +10 -68
  156. package/extensions/plan/web-outline.ts +22 -2
  157. package/extensions/plan/web-page.ts +56 -86
  158. package/extensions/plan/web-payload.ts +188 -0
  159. package/extensions/plan/web-plan.ts +23 -13
  160. package/extensions/plan/web-router.ts +18 -9
  161. package/extensions/plan/web-settle.ts +14 -7
  162. package/extensions/plan/web-share-session.ts +24 -55
  163. package/extensions/plan/web-share.ts +19 -37
  164. package/extensions/plan/web-write.ts +112 -0
  165. package/extensions/plan/web.ts +67 -131
  166. package/extensions/question/tools.ts +6 -2
  167. package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
  168. package/extensions/rewind/commands/commands.ts +6 -3
  169. package/extensions/rewind/state/runtime.ts +1 -1
  170. package/extensions/rewind/{view/status.ts → status.ts} +4 -4
  171. package/extensions/rewind/view/review-tui.ts +4 -4
  172. package/extensions/serve/config.ts +39 -0
  173. package/extensions/serve/index.ts +85 -0
  174. package/extensions/serve/registry.ts +260 -0
  175. package/extensions/session-bus/commands.ts +11 -6
  176. package/extensions/session-bus/join-request.ts +17 -9
  177. package/extensions/session-bus/mail-watch.ts +26 -17
  178. package/extensions/session-bus/protocol.ts +15 -3
  179. package/extensions/session-bus/resolve.ts +5 -7
  180. package/extensions/session-bus/result.ts +1 -27
  181. package/extensions/session-bus/runtime.ts +40 -12
  182. package/extensions/session-bus/store.ts +101 -19
  183. package/extensions/session-bus/tools.ts +5 -4
  184. package/extensions/session-bus/types.ts +19 -0
  185. package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
  186. package/extensions/subagent/agents.ts +1 -1
  187. package/extensions/subagent/commands.ts +4 -2
  188. package/extensions/subagent/format.ts +15 -15
  189. package/extensions/subagent/index.ts +1 -1
  190. package/extensions/subagent/render.ts +3 -3
  191. package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
  192. package/extensions/team/commands.ts +24 -5
  193. package/extensions/team/dispatch.ts +18 -4
  194. package/extensions/team/format.ts +17 -2
  195. package/extensions/team/index.ts +9 -8
  196. package/extensions/team/schema.ts +6 -5
  197. package/extensions/team/tools.ts +107 -45
  198. package/extensions/team/types.ts +3 -2
  199. package/extensions/todo/commands.ts +4 -2
  200. package/extensions/todo/format.ts +11 -0
  201. package/extensions/todo/render.ts +57 -0
  202. package/extensions/todo/runtime.ts +1 -1
  203. package/extensions/todo/tools.ts +1 -1
  204. package/extensions/todo/{tui.ts → view/todo-screen.ts} +18 -90
  205. package/extensions/todo/widget.ts +47 -0
  206. package/extensions/watch/commands.ts +4 -2
  207. package/extensions/watch/format.ts +36 -7
  208. package/extensions/watch/render.ts +57 -0
  209. package/extensions/watch/runtime.ts +27 -22
  210. package/extensions/watch/tools.ts +4 -4
  211. package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
  212. package/extensions/web-access/fetch/tool.ts +12 -4
  213. package/extensions/wechat/commands/commands.ts +18 -14
  214. package/extensions/wechat/index.ts +72 -37
  215. package/extensions/wechat/state/credentials.ts +1 -1
  216. package/extensions/wechat/state/state.ts +1 -1
  217. package/extensions/wechat/text/status.ts +112 -0
  218. package/extensions/wechat/types.ts +2 -2
  219. package/extensions/wechat/view/status-screen.ts +4 -103
  220. package/extensions/worktree/commands.ts +11 -9
  221. package/extensions/worktree/enter.ts +2 -2
  222. package/extensions/worktree/exit.ts +2 -2
  223. package/extensions/worktree/index.ts +5 -5
  224. package/extensions/worktree/screen.ts +3 -3
  225. package/extensions/worktree/status.ts +2 -2
  226. package/extensions/worktree/tools.ts +113 -116
  227. package/lib/README.md +28 -13
  228. package/lib/board/actions.ts +111 -0
  229. package/lib/board/artifact.ts +149 -0
  230. package/lib/board/card.ts +163 -52
  231. package/lib/board/config.ts +58 -0
  232. package/lib/board/contract.ts +113 -0
  233. package/lib/board/join.ts +38 -0
  234. package/lib/board/lanes.ts +182 -0
  235. package/lib/board/lead.ts +130 -0
  236. package/lib/board/limits.ts +78 -0
  237. package/lib/board/links.ts +31 -10
  238. package/lib/board/page-pointer.ts +30 -5
  239. package/lib/board/read.ts +83 -0
  240. package/lib/board/reason.ts +102 -0
  241. package/lib/board/round-text.ts +68 -6
  242. package/lib/board/round.ts +31 -15
  243. package/lib/board/rules.ts +151 -307
  244. package/lib/board/seat.ts +35 -0
  245. package/lib/board/session.ts +199 -51
  246. package/lib/board/skipped.ts +22 -0
  247. package/lib/board/steps.ts +487 -0
  248. package/lib/board/store.ts +119 -36
  249. package/lib/board/tone.ts +92 -0
  250. package/lib/board/view.ts +81 -63
  251. package/lib/bus-request.ts +83 -0
  252. package/{extensions/watch → lib}/child.ts +40 -21
  253. package/lib/env.ts +65 -7
  254. package/lib/format.ts +13 -0
  255. package/lib/http/index.ts +17 -11
  256. package/lib/http/mount.ts +142 -0
  257. package/lib/http/port.ts +30 -0
  258. package/lib/http/serve.ts +170 -46
  259. package/lib/http/server.ts +3 -3
  260. package/lib/lazy.ts +45 -0
  261. package/lib/plan-entry.ts +46 -21
  262. package/lib/plans/paths.ts +82 -0
  263. package/lib/policy.ts +9 -6
  264. package/lib/report.ts +3 -2
  265. package/lib/requirements.ts +10 -10
  266. package/lib/session-label.ts +129 -7
  267. package/lib/session-roster.ts +43 -1
  268. package/lib/session-tool.ts +39 -22
  269. package/lib/stray-fields.ts +7 -2
  270. package/lib/tool-names.ts +5 -10
  271. package/lib/tui/README.md +29 -11
  272. package/lib/tui/chrome.ts +16 -15
  273. package/lib/tui/cursor.ts +18 -3
  274. package/lib/tui/glyphs.ts +11 -11
  275. package/lib/tui/render-cache.ts +29 -0
  276. package/lib/tui/tokens.ts +33 -2
  277. package/lib/tui/tool.ts +33 -4
  278. package/lib/web/README.md +21 -0
  279. package/lib/web/client-runtime.ts +27 -1
  280. package/lib/web/components.ts +5 -1
  281. package/lib/web/index.ts +1 -0
  282. package/lib/web/markdown.ts +83 -0
  283. package/lib/web/markup.ts +20 -2
  284. package/lib/web/runtime.ts +9 -10
  285. package/package.json +8 -2
  286. package/skills/init/SKILL.md +5 -5
  287. package/skills/next/SKILL.md +3 -5
  288. package/extensions/board/config.ts +0 -36
  289. package/extensions/board/seat.ts +0 -28
  290. package/lib/tui/index.ts +0 -34
  291. package/skills/project-docs/SKILL.md +0 -133
  292. package/skills/project-docs/references/audit.md +0 -131
  293. package/skills/project-docs/references/changelog.md +0 -105
  294. package/skills/project-docs/references/discovery.md +0 -127
  295. package/skills/project-docs/references/readme.md +0 -80
  296. package/skills/project-docs/references/release.md +0 -84
  297. package/skills/project-docs/references/roadmap.md +0 -93
  298. package/skills/project-docs/scripts/state.ts +0 -316
@@ -1,76 +1,18 @@
1
1
  /**
2
- * The Markdown half of the review client script.
2
+ * The block half of the plan review's Markdown: tables, fenced code, headings, quotes,
3
+ * and lists.
3
4
  *
4
5
  * It is a fragment of the same IIFE `web-client.ts` ships (`WEB_MARKDOWN_JS` is
5
6
  * interpolated into `REVIEW_CLIENT_JS`), kept in its own module so the client's
6
- * event wiring and its document rendering are read one at a time. Every value it
7
- * writes is a DOM text node: plan and comment text never becomes markup, and the
8
- * only HTML it copies is the server's own `tok-*` spans.
7
+ * event wiring and its document rendering are read one at a time. The text inside a
8
+ * block is rendered by `renderInline`, which crosses from `lib/web/markdown.ts` with
9
+ * the board's card notes — one inline grammar and one link policy for both pages.
10
+ *
11
+ * Every value it writes is a DOM text node: plan and comment text never becomes markup,
12
+ * and the only HTML it copies is the server's own `tok-*` spans.
9
13
  */
10
14
 
11
- export const WEB_MARKDOWN_JS = String.raw` // A link is rendered only for an http(s) or mailto target. Resolving against
12
- // the review origin is what catches a target that only looks relative: the
13
- // URL parser strips a tab or newline, so "/\t/evil.com" is cross-origin once
14
- // it is parsed, and a disguised target must stay text.
15
- function safeHref(url, base) {
16
- var trimmed = String(url).trim();
17
- if (!trimmed || /^\/\//.test(trimmed)) return null;
18
- var resolved;
19
- try { resolved = new URL(trimmed, base); } catch (error) { return null; }
20
- if (resolved.protocol === "mailto:") return trimmed;
21
- if (resolved.protocol !== "http:" && resolved.protocol !== "https:") return null;
22
- if (resolved.username || resolved.password) return null;
23
- if (resolved.origin === new URL(base).origin) return resolved.href;
24
- return /^https?:\/\//i.test(trimmed) ? resolved.href : null;
25
- }
26
-
27
- // Inline token order matters: code, emphasis, and links are matched before a
28
- // bare URL, so a URL inside a link target or a code span is never re-split.
29
- var INLINE = /(\x60[^\x60]+\x60|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\)|(?:https?:\/\/|www\.)[^\s<]+)/g;
30
-
31
- function renderInline(container, text) {
32
- String(text).split(INLINE).forEach(function (part) {
33
- if (!part) return;
34
- var match;
35
- if ((match = /^\x60([^\x60]+)\x60$/.exec(part))) {
36
- var code = el("code"); code.textContent = match[1]; container.appendChild(code); return;
37
- }
38
- if ((match = /^\*\*([^*]+)\*\*$/.exec(part))) {
39
- var strong = el("strong"); strong.textContent = match[1]; container.appendChild(strong); return;
40
- }
41
- if ((match = /^\*([^*]+)\*$/.exec(part))) {
42
- var em = el("em"); em.textContent = match[1]; container.appendChild(em); return;
43
- }
44
- if ((match = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(part))) {
45
- var href = safeHref(match[2], location.href);
46
- if (!href) { container.appendChild(document.createTextNode(part)); return; }
47
- var link = el("a"); link.textContent = match[1]; link.href = href; link.target = "_blank"; link.rel = "noreferrer noopener";
48
- container.appendChild(link); return;
49
- }
50
- // GFM extended autolink: a bare http(s) or www. URL in running text.
51
- if ((match = /^(?:https?:\/\/|www\.)[^\s<]+$/.exec(part))) {
52
- var url = match[0].replace(/[.,;:!?*_~]+$/, "");
53
- while (url.charAt(url.length - 1) === ")" && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) {
54
- url = url.slice(0, -1).replace(/[.,;:!?*_~]+$/, "");
55
- }
56
- var dot = url.indexOf(".");
57
- var target = /^www\./i.test(url) ? "http://" + url : url;
58
- var safe = dot > 0 && /\.[^./]/.test(url.slice(dot)) ? safeHref(target, location.href) : null;
59
- if (safe) {
60
- var auto = el("a");
61
- auto.textContent = url;
62
- auto.href = safe;
63
- auto.target = "_blank";
64
- auto.rel = "noreferrer noopener";
65
- container.appendChild(auto);
66
- return;
67
- }
68
- }
69
- container.appendChild(document.createTextNode(part));
70
- });
71
- }
72
-
73
- // Copy only text and tok-* spans out of server-highlighted HTML, so a code
15
+ export const WEB_MARKDOWN_JS = String.raw` // Copy only text and tok-* spans out of server-highlighted HTML, so a code
74
16
  // line can never introduce markup even though it arrives as a string.
75
17
  function copyTokens(from, to) {
76
18
  Array.prototype.forEach.call(from.childNodes, function (node) {
@@ -199,7 +141,7 @@ export const WEB_MARKDOWN_JS = String.raw` // A link is rendered only for an ht
199
141
  var markerText = match[2];
200
142
  var rest = match[3];
201
143
  var box = /^\[([ xX])\]\s*(.*)$/.exec(rest);
202
- var marker = el("span", "marker");
144
+ var marker = el("span", "bf-block__marker");
203
145
  if (box) {
204
146
  var done = box[1].toLowerCase() === "x";
205
147
  markerText = done ? "\u2713" : "\u2610";
@@ -23,6 +23,8 @@ export interface OutlineEntry {
23
23
  line: number;
24
24
  text: string;
25
25
  changed?: boolean;
26
+ /** Content blocks this section no longer holds, when the baseline can say so. */
27
+ removed?: number;
26
28
  }
27
29
 
28
30
  /** What the browser's `IntersectionObserver` answers for a section watcher. */
@@ -73,6 +75,14 @@ export function renderOutline(list: ClientNode | null, entries: readonly Outline
73
75
  dot.title = "Changed since your last review";
74
76
  link.appendChild(dot);
75
77
  }
78
+ if (item.removed !== undefined && item.removed > 0) {
79
+ // A block that left cannot be drawn where it was, so the count is the fact the
80
+ // page can still give: how much shorter this section has become.
81
+ link.title =
82
+ item.removed === 1
83
+ ? "1 block removed since your last review"
84
+ : item.removed + " blocks removed since your last review";
85
+ }
76
86
  const count = el("span", "review-nav__count");
77
87
  count.hidden = true;
78
88
  link.appendChild(count);
@@ -137,6 +147,8 @@ export function observeSections(
137
147
  /** One review comment, as the facts need it: the line it is anchored to. */
138
148
  export interface AnchoredComment {
139
149
  anchor: number;
150
+ /** A settled note counts in the section's thread but not among the open ones. */
151
+ resolved?: true;
140
152
  }
141
153
 
142
154
  /** What a plan's step list says: the numbers the server counted. */
@@ -170,9 +182,17 @@ export function renderFacts(
170
182
  const fill = doc.getElementById("step-fill");
171
183
  if (fill) fill.style.width = (steps.total > 0 ? Math.round((steps.done / steps.total) * 100) : 0) + "%";
172
184
  const count = doc.getElementById("step-count");
173
- if (count) count.textContent = steps.done + "/" + steps.total;
185
+ // Named for what it counts: before approval these ticks are the plan's ambition, not
186
+ // work done, and after it completion belongs to `todo`.
187
+ if (count) count.textContent = steps.total === 0 ? "" : steps.done + "/" + steps.total + " ticked";
174
188
  const total = doc.getElementById("comment-total");
175
- if (total) total.textContent = String(comments.length);
189
+ if (total) {
190
+ // Open first, because a settled note is history: "3 open · 2 resolved" says what is
191
+ // left to answer, and the resolved half stays because the file keeps it.
192
+ const settled = comments.filter((comment) => comment.resolved === true).length;
193
+ const open = comments.length - settled;
194
+ total.textContent = settled === 0 ? String(open) : open + " open · " + settled + " resolved";
195
+ }
176
196
  for (const line of Object.keys(counters)) {
177
197
  const node = counters[Number(line)];
178
198
  if (node) node.hidden = true;
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import {
17
+ baseAttribute,
17
18
  brandLockup,
18
19
  docHead,
19
20
  type KeyHint,
@@ -26,69 +27,14 @@ import {
26
27
  WEB_LAYOUT_CSS,
27
28
  WEB_THEME_CSS,
28
29
  } from "../../lib/web/index.ts";
29
- import type { PlanBlock } from "./blocks.ts";
30
- import type { PlanAnnotation } from "./feedback.ts";
30
+ import { keyRows, type KeySurface } from "./web-keys.ts";
31
31
  import type { ReviewRole } from "./types.ts";
32
- import type { ShareState } from "./web-share.ts";
32
+ import type { ReviewPageData } from "./web-payload.ts";
33
33
 
34
- /** A heading the outline can jump to. */
35
- export interface ReviewOutlineItem {
36
- /** DOM id the client gives the heading. */
37
- id: string;
38
- /** 0-based source line. */
39
- line: number;
40
- /** Heading level, 1-6. */
41
- level: number;
42
- /** Heading text. */
43
- text: string;
44
- /** True when this section changed since the previous review of the plan. */
45
- changed?: boolean;
46
- }
47
-
48
- /** Everything the browser needs to render one plan review. */
49
- export interface ReviewPageData {
50
- /** Plan title: the document's H1, or the file-name slug when it has none. */
51
- title: string;
52
- /** Source-line blocks of the feedback-stripped plan. */
53
- blocks: PlanBlock[];
54
- /** Feedback already stored in the file. */
55
- annotations: PlanAnnotation[];
56
- /** Section outline, in document order. */
57
- outline: ReviewOutlineItem[];
58
- /**
59
- * The plan's step progress, from the same `extractPlanSteps` the approval and
60
- * `set_plan_steps` use: the `Steps` section when there is one, so the page
61
- * agrees with what approval seeds rather than counting every checkbox.
62
- */
63
- steps: { done: number; total: number };
64
- /** Highlighted HTML per line, keyed by the code block's 0-based start line. */
65
- code: Record<string, string[]>;
66
- /**
67
- * The caps the server enforces, so the page does not retype them: comment
68
- * count, comment length, and quote length, from `feedback.ts`.
69
- */
70
- limits: { comments: number; comment: number; quote: number };
71
- /** Whether the review may approve the plan (only the `exit_plan_mode` flow). */
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
- /** A running share, so a reload re-renders the link the owner already minted. */
82
- share?: ShareState;
83
- /** Per-session secret the client echoes on every request. */
84
- token: string;
85
- /**
86
- * A short digest of the plan text the page rendered. It keys the unsaved draft a
87
- * tab keeps for itself, so a reload restores the comments this writer has not
88
- * saved yet, and a plan that changed underneath drops them instead.
89
- */
90
- rev: string;
91
- }
34
+ // The island's two types live with the code that builds it (`web-payload.ts`); they are
35
+ // re-exported here because a page's data and the page's markup are read from the same
36
+ // imports in every caller, and two declarations of one island is how its halves drift.
37
+ export type { ReviewOutlineItem, ReviewPageData } from "./web-payload.ts";
92
38
 
93
39
  /** Page styles: the shared base + theme + shell + components, then the review. */
94
40
  export const REVIEW_CSS = `${WEB_BASE_CSS}
@@ -117,16 +63,24 @@ ${WEB_COMPONENTS_CSS}
117
63
  .review-status{flex:0 0 auto;max-width:42ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
118
64
  .review-status:empty{display:none}
119
65
  .review-controls{display:inline-flex;align-items:center;gap:var(--space-2);flex:0 0 auto}
66
+ .review-caption{margin:0;color:var(--muted);font-size:var(--fs-xs);line-height:var(--lh-snug)}
120
67
  .review-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden}
121
68
  .review-progress__fill{display:block;height:100%;width:0;background:var(--accent);transition:width var(--motion-fast) linear}
122
69
  /* Review panel: the plan's review facts, the section outline, then the keys. */
123
70
  .bf-app__body--single{grid-template-columns:minmax(0,1fr)}
124
71
  .review-panel{display:flex;flex-direction:column;gap:var(--space-3);padding:var(--space-4) var(--space-4) var(--space-2)}
125
72
  .review-facts{display:flex;flex-direction:column;gap:var(--space-2);padding-bottom:var(--space-3);border-bottom:1px solid var(--border)}
126
- .review-fact{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);font-size:var(--fs-sm);color:var(--muted)}
73
+ .review-fact{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:var(--space-3);font-size:var(--fs-sm);color:var(--muted)}
74
+ /* The label is one word or two, never a column: squeezed by the value beside it, it broke
75
+ "Plan steps" over two lines. It holds its line, and the value wraps below instead. */
76
+ .review-fact>span:first-child{white-space:nowrap}
127
77
  .review-fact .bf-progress{color:var(--text)}
128
78
  .review-fact .bf-progress__track{width:96px}
129
79
  .review-fact__value{color:var(--text);font-family:var(--font-mono);font-variant-numeric:tabular-nums}
80
+ .review-view{display:flex;gap:2px;padding:2px;border:1px solid var(--border);border-radius:var(--radius-md)}
81
+ .review-view .bf-btn{flex:1 1 auto;justify-content:center;padding:3px 8px;font-size:var(--fs-xs);border:0;background:none}
82
+ .review-view .bf-btn[aria-pressed="true"]{background:var(--accent-soft);color:var(--accent);font-weight:var(--fw-semibold)}
83
+ .review-view .bf-btn:disabled{opacity:.5;cursor:default}
130
84
  .review-nav{min-width:0}
131
85
  .review-nav__title{margin:0;font:var(--fw-semibold) var(--fs-xs) var(--font-mono);color:var(--muted);text-transform:uppercase;letter-spacing:.06em}
132
86
  .review-nav ul{list-style:none;margin:0;padding:0}
@@ -149,6 +103,10 @@ ${WEB_COMPONENTS_CSS}
149
103
  .bf-block__add:hover{border-color:var(--accent);color:var(--accent)}
150
104
  .bf-block:hover .bf-block__add,.bf-block:focus-within .bf-block__add,.bf-block--active .bf-block__add,.bf-block--commented .bf-block__add{opacity:1}
151
105
  .bf-block__count{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;padding:0 5px;border-radius:var(--radius-pill);background:var(--accent-soft);color:var(--accent);font:var(--fw-semibold) var(--fs-xs)/1 var(--font-mono)}
106
+ /* A block the last review did not hold: the one mark that says "read this line again". The
107
+ rule sits at the text edge, inside the gutter the rail already reserves, so it cannot
108
+ reflow the measure or collide with a comment badge. */
109
+ .bf-block[data-changed]{box-shadow:inset 2px 0 0 var(--accent)}
152
110
  .bf-block:hover,.bf-block--active{background:var(--surface)}
153
111
  .bf-block--active{box-shadow:inset 0 0 0 1px var(--border)}
154
112
  .bf-block h1,.bf-block h2,.bf-block h3,.bf-block h4,.bf-block h5,.bf-block h6{margin:24px 0 8px;line-height:var(--lh-tight)}
@@ -183,6 +141,9 @@ ${WEB_COMPONENTS_CSS}
183
141
  /* A note a refinement carried off its own block: muted, because the complaint is the guess, not the text. */
184
142
  .bf-comment__moved{font:var(--fw-medium) var(--fs-xs) var(--font-mono);color:var(--muted);text-transform:uppercase;letter-spacing:.06em;border:1px solid var(--border);border-radius:var(--radius-sm);padding:0 6px}
185
143
  .bf-comment__spacer{flex:1 1 auto}
144
+ /* A settled note keeps its quote and loses its body: the record stays, the eye moves on. */
145
+ .bf-comment--resolved{border-left-color:var(--success);opacity:.72}
146
+ .bf-comment__resolved{font:var(--fw-medium) var(--fs-xs) var(--font-mono);color:var(--success);text-transform:uppercase;letter-spacing:.06em}
186
147
  .bf-comment__actions{display:flex;gap:4px}
187
148
  .bf-comment__actions .bf-btn{padding:1px 8px;font-size:var(--fs-xs)}
188
149
  .bf-comment__quote{margin-bottom:6px;padding-left:8px;border-left:2px solid var(--border);color:var(--muted);font-size:var(--fs-sm);overflow-wrap:anywhere}
@@ -225,7 +186,12 @@ ${WEB_COMPONENTS_CSS}
225
186
  @media print{
226
187
  .bf-review{padding:0}
227
188
  .bf-review>.bf-block{max-width:none;padding:0 0 6px}
228
- .bf-block__rail,.bf-composer,.bf-block__add,.review-progress{display:none}
189
+ .bf-block__rail,.bf-composer,.bf-block__add,.review-progress,.review-view{display:none}
190
+ /* A printed review is the whole plan, never the view the reader had on screen: the
191
+ base layer's hidden rule is !important, so the un-hide has to be too, and a thread
192
+ the filter hid on screen goes back under its block. */
193
+ .bf-block[hidden]{display:block!important}
194
+ .bf-comments[hidden]{display:flex!important}
229
195
  .bf-block:hover,.bf-block--active{background:none;box-shadow:none}
230
196
  .bf-comments{padding-left:0}
231
197
  .bf-comment{break-inside:avoid}
@@ -237,28 +203,19 @@ ${WEB_COMPONENTS_CSS}
237
203
  * not have to grow every time a binding is added.
238
204
  */
239
205
  function reviewHints(role: ReviewRole, canApprove: boolean): string {
240
- const approve = canApprove ? `, <kbd class="bf-kbd">a</kbd> to approve` : "";
241
- const save = role === "owner" ? "save" : "post";
242
- 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>`;
206
+ const surface: KeySurface = { role, canApprove, hasCommentWalk: true, hasViewCycle: true };
207
+ const parts = keyRows(surface)
208
+ .filter((row) => row.hint !== undefined)
209
+ .map(
210
+ (row) => `${row.keys.map((key) => `<kbd class="bf-kbd">${key}</kbd>`).join("/")} ${row.hint?.(surface) ?? ""}`,
211
+ );
212
+ return `<p class="review-hints">Press ${parts.join(", ")}.</p>`;
243
213
  }
244
214
 
245
- /** The shortcut sheet's rows: the review's own keys, led by approve when offered. */
215
+ /** The shortcut sheet's rows: the same table the handler dispatches through. */
246
216
  function reviewKeys(role: ReviewRole, canApprove: boolean): KeyHint[] {
247
- const rows: KeyHint[] = [];
248
- if (canApprove) rows.push({ keys: "a", label: "Approve and run the plan" });
249
- rows.push(
250
- { keys: ["j", "k"], label: "Next and previous block" },
251
- { keys: "c", label: "Comment on the current block" },
252
- { keys: "Enter", label: "Add the comment you are writing" },
253
- role === "owner"
254
- ? { keys: "s", label: "Save feedback and return to Pi" }
255
- : { keys: "s", label: "Post your comments to the plan" },
256
- role === "owner"
257
- ? { keys: "Esc", label: "Close the composer, the sheet, or the review" }
258
- : { keys: "Esc", label: "Close the composer or this sheet" },
259
- { keys: "?", label: "Toggle this sheet" },
260
- );
261
- return rows;
217
+ const surface: KeySurface = { role, canApprove, hasCommentWalk: true, hasViewCycle: true };
218
+ return keyRows(surface).map((row) => ({ keys: [...row.keys], label: row.label(surface) }));
262
219
  }
263
220
 
264
221
  /**
@@ -311,13 +268,26 @@ function nameDialog(): string {
311
268
  */
312
269
  export function renderReviewPage(data: ReviewPageData): string {
313
270
  const island = JSON.stringify(data).replace(/</g, "\\u003c");
271
+ // One sentence saying what the two decisions do, because the buttons are the same weight
272
+ // and the difference was only ever in the docs. It sits in the panel, not the sticky
273
+ // header: a taller header would hide the section an outline link just jumped to.
274
+ const caption =
275
+ data.role === "owner"
276
+ ? "Approving runs the plan and returns these comments; saving keeps planning."
277
+ : "Your comments are signed with your name and returned to the plan.";
314
278
  const hasOutline = data.outline.length > 0;
315
279
  const bodyClass = hasOutline ? "bf-app__body" : "bf-app__body bf-app__body--single";
316
280
  const outlinePanel = hasOutline
317
281
  ? `<details class="bf-app__sidebar review-outline" open>
318
282
  <summary class="bf-panel__summary">Sections</summary>
319
283
  <div class="bf-panel review-panel">
284
+ <p class="review-caption">${caption}</p>
320
285
  ${reviewFacts()}
286
+ <div class="review-view" role="group" aria-label="What to show" id="review-view">
287
+ <button type="button" class="bf-btn" data-view="all" aria-pressed="true">All</button>
288
+ <button type="button" class="bf-btn" data-view="changed" aria-pressed="false">Changed</button>
289
+ <button type="button" class="bf-btn" data-view="comments" aria-pressed="false">Comments</button>
290
+ </div>
321
291
  <nav class="review-nav" aria-labelledby="outline-title"><p class="review-nav__title" id="outline-title">Sections</p><ul id="outline-list"></ul></nav>
322
292
  ${reviewHints(data.role, data.canApprove)}
323
293
  </div>
@@ -342,9 +312,9 @@ ${reviewHints(data.role, data.canApprove)}
342
312
  ? `<button type="button" class="bf-btn" id="share">Share</button>`
343
313
  : `<button type="button" class="bf-btn" id="reviewer-name" hidden></button>`;
344
314
  return `<!doctype html>
345
- <html lang="en">
315
+ <html lang="en"${baseAttribute(data.base)}>
346
316
  <head>
347
- ${docHead({ title: tabTitle("plan review", data.title), css: REVIEW_CSS })}
317
+ ${docHead({ title: tabTitle("plan review", data.title), css: REVIEW_CSS, base: data.base })}
348
318
  </head>
349
319
  <body class="bf-app">
350
320
  <a class="skip" href="#plan">Skip to the plan</a>
@@ -367,7 +337,7 @@ ${keysSheet(reviewKeys(data.role, data.canApprove))}
367
337
  ${data.canApprove ? approveDialog() : ""}
368
338
  ${data.role === "owner" ? shareDialog() : nameDialog()}
369
339
  <script type="application/json" id="review-data">${island}</script>
370
- <script src="/client.js" defer></script>
340
+ <script src="${data.base}/client.js" defer></script>
371
341
  </body>
372
342
  </html>
373
343
  `;
@@ -379,7 +349,7 @@ ${data.role === "owner" ? shareDialog() : nameDialog()}
379
349
  */
380
350
  function reviewFacts(): string {
381
351
  return `<div class="review-facts" id="review-facts">
382
- <div class="review-fact" id="step-fact"><span>Steps</span><span class="bf-progress"><span class="bf-progress__track"><span class="bf-progress__fill" id="step-fill"></span></span><span class="review-fact__value" id="step-count">0/0</span></span></div>
352
+ <div class="review-fact" id="step-fact"><span>Plan steps</span><span class="bf-progress"><span class="bf-progress__track"><span class="bf-progress__fill" id="step-fill"></span></span><span class="review-fact__value" id="step-count">0/0</span></span></div>
383
353
  <div class="review-fact"><span>Comments</span><span class="review-fact__value" id="comment-total">0</span></div>
384
354
  </div>`;
385
355
  }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The review page's island: every fact the browser renders, built from one read of the
3
+ * plan.
4
+ *
5
+ * The page's markup is `web-page.ts` and its lifetime is `web.ts`; this module is the
6
+ * data between them, so a fact the page grows — a per-block change mark, a count, a
7
+ * flag — is added here and in one client module rather than inside the server's start
8
+ * closure. Everything below is pure: the plan text, the baseline, and who is asking come
9
+ * in, the island goes out.
10
+ */
11
+
12
+ import { basename } from "node:path";
13
+ import { fenceLanguage, highlightLines } from "../../lib/highlight.ts";
14
+ import { codeBody, fenceInfo, type PlanBlock } from "./blocks.ts";
15
+ import {
16
+ MAX_ANNOTATIONS,
17
+ MAX_COMMENT_LENGTH,
18
+ MAX_QUOTE_LENGTH,
19
+ readFeedback,
20
+ type PlanAnnotation,
21
+ } from "./feedback.ts";
22
+ import { sanitizePlanText } from "./plan-text.ts";
23
+ import { blockChangeMarks, changedSections, planDigest, type SectionDigest } from "./plan-text.ts";
24
+ import { extractPlanSteps } from "./steps.ts";
25
+ import { planTitle } from "./plans.ts";
26
+ import type { ReviewCaller, ReviewRole } from "./types.ts";
27
+ import type { ShareState } from "./web-share.ts";
28
+
29
+ /** A heading the outline can jump to. */
30
+ export interface ReviewOutlineItem {
31
+ /** DOM id the client gives the heading. */
32
+ id: string;
33
+ /** 0-based source line. */
34
+ line: number;
35
+ /** Heading level, 1-6. */
36
+ level: number;
37
+ /** Heading text. */
38
+ text: string;
39
+ /** True when this section changed since the previous review of the plan. */
40
+ changed?: boolean;
41
+ /** Content blocks this section no longer holds, when the baseline can say so. */
42
+ removed?: number;
43
+ }
44
+
45
+ /** Everything the browser needs to render one plan review. */
46
+ export interface ReviewPageData {
47
+ /** The mount prefix the review answers under, so its own script is named from there. */
48
+ base: string;
49
+ /** Plan title: the document's H1, or the file-name slug when it has none. */
50
+ title: string;
51
+ /** Source-line blocks of the feedback-stripped plan. */
52
+ blocks: PlanBlock[];
53
+ /** Absolute start lines of the blocks that are new or edited since the previous review. */
54
+ changed?: number[];
55
+ /** Whether the previous review's baseline carried block digests, so `changed` means something. */
56
+ blockMarks?: boolean;
57
+ /** Feedback already stored in the file. */
58
+ annotations: PlanAnnotation[];
59
+ /** Section outline, in document order. */
60
+ outline: ReviewOutlineItem[];
61
+ /**
62
+ * The plan's step progress, from the same `extractPlanSteps` the approval and
63
+ * the `steps` action use: the `Steps` section when there is one, so the page
64
+ * agrees with what approval seeds rather than counting every checkbox.
65
+ */
66
+ steps: { done: number; total: number };
67
+ /** Highlighted HTML per line, keyed by the code block's 0-based start line. */
68
+ code: Record<string, string[]>;
69
+ /**
70
+ * The caps the server enforces, so the page does not retype them: comment
71
+ * count, comment length, and quote length, from `feedback.ts`.
72
+ */
73
+ limits: { comments: number; comment: number; quote: number };
74
+ /** Whether the review may approve the plan (only the `plan` tool's `exit` flow). */
75
+ canApprove: boolean;
76
+ /** Who is looking: `owner` may approve, `reviewer` may only comment. */
77
+ role: ReviewRole;
78
+ /**
79
+ * The name a reviewer's link posts under, once it has been bound. Absent for the
80
+ * owner and for a reviewer that has not written yet, which is what tells the page
81
+ * whether its name control is still a choice.
82
+ */
83
+ author?: string;
84
+ /** A running share, so a reload re-renders the link the owner already minted. */
85
+ share?: ShareState;
86
+ /** Per-session secret the client echoes on every request. */
87
+ token: string;
88
+ /**
89
+ * A short digest of the plan text the page rendered. It keys the unsaved draft a
90
+ * tab keeps for itself, so a reload restores the comments this writer has not
91
+ * saved yet, and a plan that changed underneath drops them instead.
92
+ */
93
+ rev: string;
94
+ }
95
+
96
+ /** What one island build reads. */
97
+ export interface ReviewPayloadInput {
98
+ /** The mount prefix the review answers under, e.g. `/plan/3f9c21aa`. */
99
+ base: string;
100
+ /** The plan's path, for the title's file-name fallback. */
101
+ path: string;
102
+ /** The plan as stored, feedback markers included. */
103
+ content: string;
104
+ /** The section digests the previous review showed, so this page can mark what moved. */
105
+ baseline?: readonly SectionDigest[];
106
+ /** Who is asking; the role decides what the page offers. */
107
+ caller: ReviewCaller;
108
+ /** The session secret the page echoes on every request. */
109
+ token: string;
110
+ /** Whether this review may approve the plan. */
111
+ canApprove: boolean;
112
+ /** The running share, read lazily: sharing can start after the page is first served. */
113
+ share: () => ShareState | undefined;
114
+ }
115
+
116
+ /**
117
+ * Build the island for one page request.
118
+ *
119
+ * The plan is read once, and the blocks, the comments, and the outline all come out of
120
+ * that same parse: an annotation names a block from this read, so the island cannot mark
121
+ * a comment onto a line the page never rendered.
122
+ */
123
+ export function reviewPageData(input: ReviewPayloadInput): ReviewPageData {
124
+ const { caller } = input;
125
+ const role = caller.role;
126
+ // Normalized the way the baseline is, so a block's digest here is the one the baseline
127
+ // wrote: the page and the baseline must read the plan the same way, or every tabbed plan
128
+ // would come back marked as changed. Not stripped, because the comments have to survive
129
+ // for `readFeedback` to parse them.
130
+ const read = readFeedback(sanitizePlanText(input.content));
131
+ const current = read.body;
132
+ const blocks = read.blocks;
133
+ // Per-section change marks, each carrying its own section: the same shape the
134
+ // terminal review renders, so a heading and its mark cannot be paired wrongly.
135
+ const marks = changedSections(input.baseline, current);
136
+ // Which blocks moved, in the document's own numbering: the page draws these, and a
137
+ // section's shrinkage is the half of a diff a document cannot place in the flow.
138
+ const blockMarks = blockChangeMarks(input.baseline, current);
139
+ const outline = marks.flatMap(({ section, changed }) =>
140
+ section.title === ""
141
+ ? []
142
+ : [
143
+ {
144
+ id: `s-${section.line}`,
145
+ line: section.line,
146
+ level: section.level,
147
+ text: section.title,
148
+ ...(changed ? { changed: true } : {}),
149
+ ...(blockMarks.removed[section.line] === undefined ? {} : { removed: blockMarks.removed[section.line] }),
150
+ },
151
+ ],
152
+ );
153
+ // The same step list approval seeds and the `steps` action indexes, so the page's
154
+ // progress cannot disagree with the todo list.
155
+ const stepList = extractPlanSteps(current);
156
+ // Highlight code blocks once, at the boundary: the client copies only the tok-*
157
+ // spans out of the server-escaped HTML.
158
+ const code: Record<string, string[]> = {};
159
+ for (const block of blocks) {
160
+ if (block.kind !== "code") continue;
161
+ const language = fenceLanguage(fenceInfo(block.lines[0] ?? ""));
162
+ if (language) code[String(block.start)] = highlightLines(codeBody(block), language);
163
+ }
164
+ return {
165
+ base: input.base,
166
+ // The document's own H1 reads better than the file-name slug, and the slug is
167
+ // still the fallback for a plan that starts with something else.
168
+ title: marks.find((mark) => mark.section.level === 1)?.section.title || planTitle(basename(input.path)),
169
+ blocks,
170
+ ...(blockMarks.starts.length === 0 ? {} : { changed: blockMarks.starts }),
171
+ ...(blockMarks.compared ? { blockMarks: true } : {}),
172
+ annotations: read.annotations,
173
+ outline,
174
+ steps: {
175
+ done: stepList.filter((step) => step.status === "completed").length,
176
+ total: stepList.length,
177
+ },
178
+ code,
179
+ limits: { comments: MAX_ANNOTATIONS, comment: MAX_COMMENT_LENGTH, quote: MAX_QUOTE_LENGTH },
180
+ token: input.token,
181
+ role,
182
+ // What the page keys this tab's unsaved draft on: same plan text, same draft.
183
+ rev: planDigest(current),
184
+ ...(caller.name === undefined ? {} : { author: caller.name }),
185
+ canApprove: role === "owner" && input.canApprove,
186
+ share: role === "owner" ? input.share() : undefined,
187
+ };
188
+ }
@@ -37,11 +37,15 @@ export interface PlanRenderDeps {
37
37
  /** The element that had focus before this frame, or null when nothing did. */
38
38
  focused: ClientNode | null;
39
39
  blocks(): readonly IslandBlock[];
40
+ /** Whether this block is new or edited since the previous review; absent means no marks. */
41
+ changed?(line: number): boolean;
40
42
  annotations(): readonly { anchor: number }[];
41
43
  activeIndex(): number;
42
44
  setActiveIndex(index: number): void;
43
45
  pendingQuote(): PendingQuote | null;
44
46
  clearPendingQuote(): void;
47
+ /** Whether the review has ended, so the rail offers no new comment. */
48
+ settled?(): boolean;
45
49
  /** The composer lifecycle, for the close a repaint begins with. */
46
50
  composer(): ComposerDeps;
47
51
  /** The block's content, from the Markdown half. */
@@ -87,24 +91,30 @@ export function renderPlanFrame(
87
91
  const wrap = el("div", "bf-block");
88
92
  wrap.setAttribute("data-line", String(block.start));
89
93
  wrap.setAttribute("data-kind", String(block.kind));
94
+ // The one mark that says "read this line again": a block the last review did not have.
95
+ if (deps.changed?.(block.start) === true) wrap.setAttribute("data-changed", "1");
90
96
  if (block.kind === "heading") wrap.id = "s-" + block.start;
91
97
  deps.renderBlock(wrap, block);
92
98
  const mine = deps.annotations().filter((annotation) => annotation.anchor === block.start);
93
99
  if (mine.length > 0) wrap.classList.add("bf-block--commented");
94
100
  const rail = el("div", "bf-block__rail");
95
- const add = el("button", "bf-block__add");
96
- add.type = "button";
97
- add.textContent = "+";
98
- add.setAttribute("aria-label", "Comment on this block");
99
- add.addEventListener("click", function () {
100
- // Read at click time, not at render time: a selection made after this frame is
101
- // the quote the reader means.
102
- const waiting = deps.pendingQuote();
103
- const quoted = waiting && waiting.wrap === wrap ? waiting.quote : deps.selectionIn(wrap);
104
- deps.clearPendingQuote();
105
- deps.open(wrap, block, quoted);
106
- });
107
- rail.appendChild(add);
101
+ // A settled review takes no new comment: this control would open a composer whose
102
+ // note could never be saved, so it is not drawn rather than drawn and refused.
103
+ if (deps.settled?.() !== true) {
104
+ const add = el("button", "bf-block__add");
105
+ add.type = "button";
106
+ add.textContent = "+";
107
+ add.setAttribute("aria-label", "Comment on this block");
108
+ add.addEventListener("click", function () {
109
+ // Read at click time, not at render time: a selection made after this frame is
110
+ // the quote the reader means.
111
+ const waiting = deps.pendingQuote();
112
+ const quoted = waiting && waiting.wrap === wrap ? waiting.quote : deps.selectionIn(wrap);
113
+ deps.clearPendingQuote();
114
+ deps.open(wrap, block, quoted);
115
+ });
116
+ rail.appendChild(add);
117
+ }
108
118
  if (mine.length > 0) {
109
119
  const badge = el("span", "bf-block__count");
110
120
  badge.textContent = String(mine.length);