@gavin-hu/my-pi-agent 0.20.0 → 0.21.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 (106) hide show
  1. package/README.md +3 -2
  2. package/README.zh-CN.md +3 -2
  3. package/docs/extensions/README.md +1 -0
  4. package/docs/extensions/board.md +57 -42
  5. package/docs/extensions/bytefly.md +85 -89
  6. package/docs/extensions/file-browse.md +36 -41
  7. package/docs/extensions/plan.md +31 -14
  8. package/docs/extensions/serve.md +129 -0
  9. package/docs/extensions/session-bus.md +1 -1
  10. package/docs/extensions/team.md +11 -11
  11. package/extensions/board/commands.ts +27 -14
  12. package/extensions/board/index.ts +14 -2
  13. package/extensions/board/join.ts +31 -10
  14. package/extensions/board/page.ts +53 -83
  15. package/extensions/board/result.ts +1 -24
  16. package/extensions/board/rows.ts +1 -1
  17. package/extensions/board/schema.ts +9 -3
  18. package/extensions/board/snapshot.ts +7 -6
  19. package/extensions/board/tools.ts +27 -15
  20. package/extensions/board/transport/router.ts +3 -5
  21. package/extensions/board/transport/server.ts +59 -28
  22. package/extensions/board/types.ts +0 -10
  23. package/extensions/board/view/client.ts +3 -2
  24. package/extensions/board/view/live.ts +1 -7
  25. package/extensions/board/view/page.ts +16 -5
  26. package/extensions/board/view/render.ts +22 -17
  27. package/extensions/board/view/styles.ts +7 -1
  28. package/extensions/bytefly/footer/config.ts +3 -6
  29. package/extensions/bytefly/footer/render/footer.ts +18 -4
  30. package/extensions/bytefly/footer/render/key.ts +46 -0
  31. package/extensions/bytefly/footer/render/layout.ts +13 -3
  32. package/extensions/bytefly/footer/render/lines.ts +4 -4
  33. package/extensions/bytefly/footer/render/segments.ts +10 -34
  34. package/extensions/bytefly/header/component.ts +4 -20
  35. package/extensions/check/render.ts +10 -1
  36. package/extensions/file-browse/config/config.ts +4 -19
  37. package/extensions/file-browse/index.ts +27 -48
  38. package/extensions/file-browse/live/live.ts +1 -1
  39. package/extensions/file-browse/transport/router.ts +12 -4
  40. package/extensions/file-browse/transport/server.ts +61 -28
  41. package/extensions/file-browse/view/chrome.ts +4 -4
  42. package/extensions/file-browse/view/client.ts +3 -1
  43. package/extensions/file-browse/view/html.ts +73 -40
  44. package/extensions/file-browse/view/live-client.ts +2 -2
  45. package/extensions/file-browse/view/markdown.ts +13 -14
  46. package/extensions/file-browse/view/palette.ts +2 -2
  47. package/extensions/file-browse/view/raw-html.ts +9 -9
  48. package/extensions/file-browse/view/tree.ts +1 -1
  49. package/extensions/file-browse/view/urls.ts +20 -6
  50. package/extensions/goal/tui.ts +12 -2
  51. package/extensions/job/tools.ts +19 -7
  52. package/extensions/job/tui.ts +2 -28
  53. package/extensions/plan/commands.ts +2 -2
  54. package/extensions/plan/feedback.ts +62 -16
  55. package/extensions/plan/index.ts +7 -1
  56. package/extensions/plan/plan-text.ts +114 -0
  57. package/extensions/plan/runtime.ts +12 -2
  58. package/extensions/plan/tools.ts +2 -2
  59. package/extensions/plan/web-blocks.ts +76 -5
  60. package/extensions/plan/web-chrome.ts +4 -0
  61. package/extensions/plan/web-client.ts +99 -2
  62. package/extensions/plan/web-collab.ts +4 -4
  63. package/extensions/plan/web-comments.ts +39 -8
  64. package/extensions/plan/web-composer.ts +14 -3
  65. package/extensions/plan/web-keys.ts +149 -25
  66. package/extensions/plan/web-markdown.ts +1 -1
  67. package/extensions/plan/web-outline.ts +22 -2
  68. package/extensions/plan/web-page.ts +56 -86
  69. package/extensions/plan/web-payload.ts +188 -0
  70. package/extensions/plan/web-plan.ts +23 -13
  71. package/extensions/plan/web-settle.ts +14 -7
  72. package/extensions/plan/web-share-session.ts +24 -55
  73. package/extensions/plan/web-share.ts +19 -37
  74. package/extensions/plan/web-write.ts +112 -0
  75. package/extensions/plan/web.ts +77 -130
  76. package/extensions/serve/config.ts +37 -0
  77. package/extensions/serve/index.ts +71 -0
  78. package/extensions/serve/registry.ts +251 -0
  79. package/extensions/session-bus/join-request.ts +16 -9
  80. package/extensions/session-bus/result.ts +1 -27
  81. package/extensions/session-bus/tools.ts +5 -4
  82. package/extensions/team/commands.ts +3 -1
  83. package/extensions/todo/tui.ts +21 -3
  84. package/lib/README.md +5 -4
  85. package/lib/board/page-pointer.ts +29 -4
  86. package/lib/board/session.ts +84 -18
  87. package/lib/board/store.ts +34 -8
  88. package/lib/board/view.ts +20 -14
  89. package/lib/bus-request.ts +83 -0
  90. package/lib/http/index.ts +11 -7
  91. package/lib/http/mount.ts +142 -0
  92. package/lib/http/port.ts +25 -0
  93. package/lib/http/serve.ts +170 -46
  94. package/lib/http/server.ts +3 -3
  95. package/lib/plan-entry.ts +9 -13
  96. package/lib/requirements.ts +2 -2
  97. package/lib/session-label.ts +23 -7
  98. package/lib/session-tool.ts +37 -21
  99. package/lib/tui/README.md +6 -3
  100. package/lib/tui/glyphs.ts +2 -4
  101. package/lib/tui/render-cache.ts +29 -0
  102. package/lib/tui/tool.ts +30 -1
  103. package/lib/web/client-runtime.ts +27 -1
  104. package/lib/web/markup.ts +18 -1
  105. package/package.json +3 -2
  106. package/extensions/board/config.ts +0 -36
@@ -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
+ * `set_plan_steps` 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 `exit_plan_mode` 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 set_plan_steps 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);
@@ -15,10 +15,12 @@
15
15
  export interface SettleDeps {
16
16
  /** The path a write goes to, without its query string. */
17
17
  token(): string;
18
+ /** The review's mount on the session's server, e.g. `/plan/3f9c21aa`. */
19
+ base(): string;
18
20
  role(): string;
19
21
  /** The comments this writer may write: her own when she is a reviewer, all when owner. */
20
- mine(): readonly unknown[];
21
- annotations(): readonly unknown[];
22
+ mine(): readonly { author?: string }[];
23
+ annotations(): readonly { author?: string }[];
22
24
  /** Whether the review has already ended, by any of the three routes. */
23
25
  settled(): boolean;
24
26
  setStatus(text: string, kind?: string): void;
@@ -82,9 +84,14 @@ export function submitAnnotations(
82
84
  if (onOk) onOk();
83
85
  deps.updateState();
84
86
  // The server binds a reviewer's name to her link on the first post, so a
85
- // second tab of the same link posts under the same name.
87
+ // second tab of the same link posts under the same name — and it may normalise
88
+ // what she typed (`@alice` is stored as `alice`). The comments she just posted
89
+ // are in the file under the bound name, so the page's copy has to say that too:
90
+ // otherwise they stop being hers to edit, and her next post would replace
91
+ // markers it never sent.
86
92
  const body = result.body as { author?: unknown } | null;
87
93
  if (deps.role() === "reviewer" && body && typeof body.author === "string" && body.author) {
94
+ for (const annotation of payload) if (annotation.author !== undefined) annotation.author = body.author;
88
95
  deps.adoptName(body.author);
89
96
  }
90
97
  return { outcome: "ok" as const };
@@ -117,7 +124,7 @@ export function saveReview(deps: SettleDeps): Promise<SettleResult> | null {
117
124
  deps.setStatus("Saving\u2026");
118
125
  return submitAnnotations(
119
126
  deps,
120
- "/api/feedback",
127
+ deps.base() + "/api/feedback",
121
128
  "Saved \u2014 return to Pi to continue.",
122
129
  "Could not save: ",
123
130
  function () {
@@ -140,7 +147,7 @@ export function postReviewComments(deps: SettleDeps): Promise<SettleResult> | nu
140
147
  deps.setStatus("Posting\u2026");
141
148
  return submitAnnotations(
142
149
  deps,
143
- "/api/comment",
150
+ deps.base() + "/api/comment",
144
151
  "Comments posted \u2014 the owner sees them now.",
145
152
  "Could not post: ",
146
153
  null,
@@ -153,7 +160,7 @@ export function approveReview(deps: SettleDeps): Promise<SettleResult> | null {
153
160
  deps.setStatus("Approving\u2026");
154
161
  return submitAnnotations(
155
162
  deps,
156
- "/api/approve",
163
+ deps.base() + "/api/approve",
157
164
  "Approved \u2014 return to Pi to continue.",
158
165
  "Could not approve: ",
159
166
  function () {
@@ -180,7 +187,7 @@ export function cancelReview(deps: SettleDeps): Promise<void> | null {
180
187
  deps.setStatus("Closing\u2026");
181
188
  deps.updateState();
182
189
  return deps
183
- .send("/api/cancel?token=" + encodeURIComponent(deps.token()))
190
+ .send(deps.base() + "/api/cancel?token=" + encodeURIComponent(deps.token()))
184
191
  .catch(function () {})
185
192
  .then(function () {
186
193
  deps.setStatus("Closed \u2014 return to Pi.");