@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.
- package/README.md +3 -2
- package/README.zh-CN.md +3 -2
- package/docs/extensions/README.md +1 -0
- package/docs/extensions/board.md +57 -42
- package/docs/extensions/bytefly.md +85 -89
- package/docs/extensions/file-browse.md +36 -41
- package/docs/extensions/plan.md +31 -14
- package/docs/extensions/serve.md +129 -0
- package/docs/extensions/session-bus.md +1 -1
- package/docs/extensions/team.md +11 -11
- package/extensions/board/commands.ts +27 -14
- package/extensions/board/index.ts +14 -2
- package/extensions/board/join.ts +31 -10
- package/extensions/board/page.ts +53 -83
- package/extensions/board/result.ts +1 -24
- package/extensions/board/rows.ts +1 -1
- package/extensions/board/schema.ts +9 -3
- package/extensions/board/snapshot.ts +7 -6
- package/extensions/board/tools.ts +27 -15
- package/extensions/board/transport/router.ts +3 -5
- package/extensions/board/transport/server.ts +59 -28
- package/extensions/board/types.ts +0 -10
- package/extensions/board/view/client.ts +3 -2
- package/extensions/board/view/live.ts +1 -7
- package/extensions/board/view/page.ts +16 -5
- package/extensions/board/view/render.ts +22 -17
- package/extensions/board/view/styles.ts +7 -1
- package/extensions/bytefly/footer/config.ts +3 -6
- package/extensions/bytefly/footer/render/footer.ts +18 -4
- package/extensions/bytefly/footer/render/key.ts +46 -0
- package/extensions/bytefly/footer/render/layout.ts +13 -3
- package/extensions/bytefly/footer/render/lines.ts +4 -4
- package/extensions/bytefly/footer/render/segments.ts +10 -34
- package/extensions/bytefly/header/component.ts +4 -20
- package/extensions/check/render.ts +10 -1
- package/extensions/file-browse/config/config.ts +4 -19
- package/extensions/file-browse/index.ts +27 -48
- package/extensions/file-browse/live/live.ts +1 -1
- package/extensions/file-browse/transport/router.ts +12 -4
- package/extensions/file-browse/transport/server.ts +61 -28
- package/extensions/file-browse/view/chrome.ts +4 -4
- package/extensions/file-browse/view/client.ts +3 -1
- package/extensions/file-browse/view/html.ts +73 -40
- package/extensions/file-browse/view/live-client.ts +2 -2
- package/extensions/file-browse/view/markdown.ts +13 -14
- package/extensions/file-browse/view/palette.ts +2 -2
- package/extensions/file-browse/view/raw-html.ts +9 -9
- package/extensions/file-browse/view/tree.ts +1 -1
- package/extensions/file-browse/view/urls.ts +20 -6
- package/extensions/goal/tui.ts +12 -2
- package/extensions/job/tools.ts +19 -7
- package/extensions/job/tui.ts +2 -28
- package/extensions/plan/commands.ts +2 -2
- package/extensions/plan/feedback.ts +62 -16
- package/extensions/plan/index.ts +7 -1
- package/extensions/plan/plan-text.ts +114 -0
- package/extensions/plan/runtime.ts +12 -2
- package/extensions/plan/tools.ts +2 -2
- package/extensions/plan/web-blocks.ts +76 -5
- package/extensions/plan/web-chrome.ts +4 -0
- package/extensions/plan/web-client.ts +99 -2
- package/extensions/plan/web-collab.ts +4 -4
- package/extensions/plan/web-comments.ts +39 -8
- package/extensions/plan/web-composer.ts +14 -3
- package/extensions/plan/web-keys.ts +149 -25
- package/extensions/plan/web-markdown.ts +1 -1
- package/extensions/plan/web-outline.ts +22 -2
- package/extensions/plan/web-page.ts +56 -86
- package/extensions/plan/web-payload.ts +188 -0
- package/extensions/plan/web-plan.ts +23 -13
- package/extensions/plan/web-settle.ts +14 -7
- package/extensions/plan/web-share-session.ts +24 -55
- package/extensions/plan/web-share.ts +19 -37
- package/extensions/plan/web-write.ts +112 -0
- package/extensions/plan/web.ts +77 -130
- package/extensions/serve/config.ts +37 -0
- package/extensions/serve/index.ts +71 -0
- package/extensions/serve/registry.ts +251 -0
- package/extensions/session-bus/join-request.ts +16 -9
- package/extensions/session-bus/result.ts +1 -27
- package/extensions/session-bus/tools.ts +5 -4
- package/extensions/team/commands.ts +3 -1
- package/extensions/todo/tui.ts +21 -3
- package/lib/README.md +5 -4
- package/lib/board/page-pointer.ts +29 -4
- package/lib/board/session.ts +84 -18
- package/lib/board/store.ts +34 -8
- package/lib/board/view.ts +20 -14
- package/lib/bus-request.ts +83 -0
- package/lib/http/index.ts +11 -7
- package/lib/http/mount.ts +142 -0
- package/lib/http/port.ts +25 -0
- package/lib/http/serve.ts +170 -46
- package/lib/http/server.ts +3 -3
- package/lib/plan-entry.ts +9 -13
- package/lib/requirements.ts +2 -2
- package/lib/session-label.ts +23 -7
- package/lib/session-tool.ts +37 -21
- package/lib/tui/README.md +6 -3
- package/lib/tui/glyphs.ts +2 -4
- package/lib/tui/render-cache.ts +29 -0
- package/lib/tui/tool.ts +30 -1
- package/lib/web/client-runtime.ts +27 -1
- package/lib/web/markup.ts +18 -1
- package/package.json +3 -2
- 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
|
|
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 {
|
|
32
|
+
import type { ReviewPageData } from "./web-payload.ts";
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
241
|
-
const
|
|
242
|
-
|
|
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
|
|
215
|
+
/** The shortcut sheet's rows: the same table the handler dispatches through. */
|
|
246
216
|
function reviewKeys(role: ReviewRole, canApprove: boolean): KeyHint[] {
|
|
247
|
-
const
|
|
248
|
-
|
|
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>
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
21
|
-
annotations(): readonly
|
|
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.");
|