gigaplan 0.1.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 +114 -0
- package/bin/gigaplan.js +3 -0
- package/dist/cli.js +188 -0
- package/dist/markdown.js +196 -0
- package/dist/paths.js +13 -0
- package/dist/render.js +48 -0
- package/dist/review-store.js +84 -0
- package/dist/server.js +160 -0
- package/dist/session-store.js +92 -0
- package/dist/types.js +1 -0
- package/package.json +44 -0
- package/public/chrome.css +450 -0
- package/public/chrome.js +648 -0
- package/skills/gigaplan/SKILL.md +97 -0
package/public/chrome.js
ADDED
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/* ---------------------------------------------------------- icons (inline, no CDN) */
|
|
3
|
+
const ICON_PATHS = {
|
|
4
|
+
"chevron-down": '<path d="m6 9 6 6 6-6"/>',
|
|
5
|
+
"chevron-right": '<path d="m9 18 6-6-6-6"/>',
|
|
6
|
+
"message-square": '<path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"/>',
|
|
7
|
+
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
|
|
8
|
+
moon: '<path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/>',
|
|
9
|
+
check: '<path d="M20 6 9 17l-5-5"/>',
|
|
10
|
+
"circle-check": '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
|
|
11
|
+
};
|
|
12
|
+
function iconSvg(name, size = 16) {
|
|
13
|
+
const inner = ICON_PATHS[name] ?? ICON_PATHS.check;
|
|
14
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`;
|
|
15
|
+
}
|
|
16
|
+
/* ---------------------------------------------------------- section grouping */
|
|
17
|
+
function buildSections(blocks) {
|
|
18
|
+
let pageTitle = "";
|
|
19
|
+
const sections = [];
|
|
20
|
+
let current = null;
|
|
21
|
+
let counter = 0;
|
|
22
|
+
for (const block of blocks) {
|
|
23
|
+
if (block.headingLevel === 1 && !pageTitle) {
|
|
24
|
+
pageTitle = block.headingBreadcrumb[block.headingBreadcrumb.length - 1] ?? block.excerpt;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (block.headingLevel === 2) {
|
|
28
|
+
counter++;
|
|
29
|
+
current = {
|
|
30
|
+
id: block.id,
|
|
31
|
+
headingBlockId: block.id,
|
|
32
|
+
num: String(counter).padStart(2, "0"),
|
|
33
|
+
title: block.headingBreadcrumb[block.headingBreadcrumb.length - 1] ?? block.excerpt,
|
|
34
|
+
items: [],
|
|
35
|
+
};
|
|
36
|
+
sections.push(current);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (!current) {
|
|
40
|
+
counter++;
|
|
41
|
+
current = {
|
|
42
|
+
id: `intro-${block.id}`,
|
|
43
|
+
headingBlockId: null,
|
|
44
|
+
num: String(counter).padStart(2, "0"),
|
|
45
|
+
title: "Overview",
|
|
46
|
+
items: [],
|
|
47
|
+
};
|
|
48
|
+
sections.push(current);
|
|
49
|
+
}
|
|
50
|
+
current.items.push(block);
|
|
51
|
+
}
|
|
52
|
+
return { pageTitle, sections };
|
|
53
|
+
}
|
|
54
|
+
function relativeTime(ts) {
|
|
55
|
+
const mins = Math.floor((Date.now() - ts) / 60000);
|
|
56
|
+
if (mins < 1)
|
|
57
|
+
return "just now";
|
|
58
|
+
if (mins < 60)
|
|
59
|
+
return `${mins}m ago`;
|
|
60
|
+
const hours = Math.floor(mins / 60);
|
|
61
|
+
if (hours < 24)
|
|
62
|
+
return `${hours}h ago`;
|
|
63
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
64
|
+
}
|
|
65
|
+
function initials(name) {
|
|
66
|
+
return name
|
|
67
|
+
.split(/\s+/)
|
|
68
|
+
.slice(0, 2)
|
|
69
|
+
.map((w) => w[0]?.toUpperCase() ?? "")
|
|
70
|
+
.join("");
|
|
71
|
+
}
|
|
72
|
+
/* ---------------------------------------------------------- state */
|
|
73
|
+
const dataEl = document.getElementById("gigaplan-data");
|
|
74
|
+
const initial = JSON.parse(dataEl?.textContent ?? "{}");
|
|
75
|
+
const sessionKey = initial.sessionKey;
|
|
76
|
+
const state = {
|
|
77
|
+
blocks: initial.blocks ?? [],
|
|
78
|
+
pending: new Map(),
|
|
79
|
+
orphanedPending: new Map(),
|
|
80
|
+
staleIds: new Set(),
|
|
81
|
+
drafts: new Map(),
|
|
82
|
+
openComposer: null,
|
|
83
|
+
collapsed: new Set(),
|
|
84
|
+
reviewed: new Set(),
|
|
85
|
+
verdict: null,
|
|
86
|
+
globalComment: "",
|
|
87
|
+
submitted: false,
|
|
88
|
+
};
|
|
89
|
+
let currentSections = [];
|
|
90
|
+
let currentPageTitle = "";
|
|
91
|
+
const appEl = document.getElementById("gp-app");
|
|
92
|
+
function apiUrl(suffix) {
|
|
93
|
+
return `/api/sessions/${encodeURIComponent(sessionKey)}${suffix}`;
|
|
94
|
+
}
|
|
95
|
+
function hasAnyComments() {
|
|
96
|
+
return state.pending.size > 0 || state.orphanedPending.size > 0 || state.globalComment.trim().length > 0;
|
|
97
|
+
}
|
|
98
|
+
function effectiveVerdict() {
|
|
99
|
+
if (state.verdict === "request_changes" && !hasAnyComments())
|
|
100
|
+
return null;
|
|
101
|
+
return state.verdict;
|
|
102
|
+
}
|
|
103
|
+
/* ---------------------------------------------------------- comment thread + composer */
|
|
104
|
+
function commentThreadHtml(blockId) {
|
|
105
|
+
const parts = [];
|
|
106
|
+
const comment = state.pending.get(blockId);
|
|
107
|
+
if (state.staleIds.has(blockId) && comment) {
|
|
108
|
+
parts.push('<div class="gp-stale-banner">Content changed since this comment was left.</div>');
|
|
109
|
+
}
|
|
110
|
+
if (comment) {
|
|
111
|
+
parts.push(`
|
|
112
|
+
<div class="gp-thread-comment" data-comment-for="${blockId}">
|
|
113
|
+
<div class="gp-thread-comment-head">
|
|
114
|
+
<span class="cdr-avatar cdr-avatar--sm">${initials("You")}</span>
|
|
115
|
+
<span class="gp-thread-comment-author">You</span>
|
|
116
|
+
<span class="gp-thread-comment-time">${relativeTime(comment.createdAt)}</span>
|
|
117
|
+
</div>
|
|
118
|
+
<div class="gp-thread-comment-body"></div>
|
|
119
|
+
<div class="gp-composer-actions">
|
|
120
|
+
<button type="button" class="cdr-btn cdr-btn--secondary cdr-btn--sm" data-action="edit-comment" data-block-id="${blockId}">Edit</button>
|
|
121
|
+
<button type="button" class="cdr-btn cdr-btn--secondary cdr-btn--sm" data-action="remove-comment" data-block-id="${blockId}">Remove</button>
|
|
122
|
+
</div>
|
|
123
|
+
</div>`);
|
|
124
|
+
}
|
|
125
|
+
if (state.openComposer === blockId) {
|
|
126
|
+
parts.push(`
|
|
127
|
+
<div class="gp-composer" data-composer-for="${blockId}">
|
|
128
|
+
<textarea class="cdr-textarea" data-role="draft" data-block-id="${blockId}" placeholder="Leave a comment…" rows="3"></textarea>
|
|
129
|
+
<div class="gp-composer-actions">
|
|
130
|
+
<button type="button" class="cdr-btn cdr-btn--secondary cdr-btn--sm" data-action="cancel-comment" data-block-id="${blockId}">Cancel</button>
|
|
131
|
+
<button type="button" class="cdr-btn cdr-btn--sm" data-action="save-comment" data-block-id="${blockId}">Comment</button>
|
|
132
|
+
</div>
|
|
133
|
+
</div>`);
|
|
134
|
+
}
|
|
135
|
+
return parts.join("");
|
|
136
|
+
}
|
|
137
|
+
function commentButtonClasses(blockId) {
|
|
138
|
+
const classes = ["gp-comment-btn"];
|
|
139
|
+
if (state.openComposer === blockId)
|
|
140
|
+
classes.push("gp-comment-btn--open");
|
|
141
|
+
if (state.pending.has(blockId))
|
|
142
|
+
classes.push("gp-comment-btn--active");
|
|
143
|
+
return classes.join(" ");
|
|
144
|
+
}
|
|
145
|
+
/* ---------------------------------------------------------- item rendering */
|
|
146
|
+
function itemContentHtml(block) {
|
|
147
|
+
if (block.type === "list_item") {
|
|
148
|
+
if (block.checklist) {
|
|
149
|
+
return `<div class="gp-item-checklist"><span class="cdr-box" aria-hidden="true"></span><span>${block.html}</span></div>`;
|
|
150
|
+
}
|
|
151
|
+
if (block.listKind === "ordered") {
|
|
152
|
+
return `<div class="gp-item-ol"><span class="gp-ordinal">${block.ordinal ?? ""}.</span><span>${block.html}</span></div>`;
|
|
153
|
+
}
|
|
154
|
+
return `<div class="gp-item-ul"><span class="gp-bullet">•</span><span>${block.html}</span></div>`;
|
|
155
|
+
}
|
|
156
|
+
return block.html;
|
|
157
|
+
}
|
|
158
|
+
function buildCommentableRow(block) {
|
|
159
|
+
return `
|
|
160
|
+
<div class="gp-commentable" id="gp-block-${block.id}" data-block-id="${block.id}">
|
|
161
|
+
<div class="gp-commentable-row">
|
|
162
|
+
<div class="gp-commentable-content">${itemContentHtml(block)}</div>
|
|
163
|
+
<button type="button" class="${commentButtonClasses(block.id)}" data-action="toggle-composer" data-block-id="${block.id}" aria-label="Comment on this">
|
|
164
|
+
${iconSvg("message-square", 14)}
|
|
165
|
+
</button>
|
|
166
|
+
</div>
|
|
167
|
+
<div data-thread-for="${block.id}">${commentThreadHtml(block.id)}</div>
|
|
168
|
+
</div>`;
|
|
169
|
+
}
|
|
170
|
+
function buildItemsHtml(items) {
|
|
171
|
+
const html = [];
|
|
172
|
+
let i = 0;
|
|
173
|
+
while (i < items.length) {
|
|
174
|
+
const block = items[i];
|
|
175
|
+
if (block.type === "hr") {
|
|
176
|
+
html.push('<hr class="gp-hr" />');
|
|
177
|
+
i++;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (block.type === "list_item" && block.listId) {
|
|
181
|
+
const listId = block.listId;
|
|
182
|
+
const groupEnd = items.findIndex((b, idx) => idx >= i && b.listId !== listId);
|
|
183
|
+
const end = groupEnd === -1 ? items.length : groupEnd;
|
|
184
|
+
for (let j = i; j < end; j++)
|
|
185
|
+
html.push(buildCommentableRow(items[j]));
|
|
186
|
+
i = end;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
html.push(buildCommentableRow(block));
|
|
190
|
+
i++;
|
|
191
|
+
}
|
|
192
|
+
return html.join("");
|
|
193
|
+
}
|
|
194
|
+
/* ---------------------------------------------------------- sections */
|
|
195
|
+
function buildSectionHtml(section) {
|
|
196
|
+
const collapsed = state.collapsed.has(section.id);
|
|
197
|
+
const reviewed = state.reviewed.has(section.id);
|
|
198
|
+
const commentTotal = (section.headingBlockId && state.pending.has(section.headingBlockId) ? 1 : 0) +
|
|
199
|
+
section.items.filter((it) => state.pending.has(it.id)).length;
|
|
200
|
+
const headingCommentBtn = section.headingBlockId
|
|
201
|
+
? `<button type="button" class="${commentButtonClasses(section.headingBlockId)}" data-action="toggle-composer" data-block-id="${section.headingBlockId}" aria-label="Comment on heading">${iconSvg("message-square", 14)}</button>`
|
|
202
|
+
: "";
|
|
203
|
+
return `
|
|
204
|
+
<div class="gp-section" data-section-id="${section.id}">
|
|
205
|
+
<div class="gp-section-head">
|
|
206
|
+
<button type="button" class="gp-section-collapse" data-action="toggle-collapse" data-section-id="${section.id}" aria-label="Toggle section">
|
|
207
|
+
${iconSvg(collapsed ? "chevron-right" : "chevron-down")}
|
|
208
|
+
</button>
|
|
209
|
+
<span class="gp-section-num">${section.num}</span>
|
|
210
|
+
<span class="gp-section-title">${section.title}</span>
|
|
211
|
+
${headingCommentBtn}
|
|
212
|
+
<span class="gp-section-spacer"></span>
|
|
213
|
+
${commentTotal > 0 ? `<span class="cdr-badge cdr-badge--accent">${commentTotal} comment${commentTotal === 1 ? "" : "s"}</span>` : ""}
|
|
214
|
+
<label class="cdr-choice">
|
|
215
|
+
<input type="checkbox" data-action="toggle-reviewed" data-section-id="${section.id}" ${reviewed ? "checked" : ""} />
|
|
216
|
+
<span class="cdr-box">${iconSvg("check", 13)}</span>
|
|
217
|
+
Reviewed
|
|
218
|
+
</label>
|
|
219
|
+
</div>
|
|
220
|
+
<div class="gp-section-body" data-section-body="${section.id}" ${collapsed ? 'style="display:none"' : ""}>
|
|
221
|
+
${section.headingBlockId ? `<div data-thread-for="${section.headingBlockId}">${commentThreadHtml(section.headingBlockId)}</div>` : ""}
|
|
222
|
+
${buildItemsHtml(section.items)}
|
|
223
|
+
</div>
|
|
224
|
+
</div>`;
|
|
225
|
+
}
|
|
226
|
+
/* ---------------------------------------------------------- top bar */
|
|
227
|
+
function buildTopBarHtml() {
|
|
228
|
+
const commentCount = state.pending.size + state.orphanedPending.size;
|
|
229
|
+
const status = state.submitted
|
|
230
|
+
? { tone: "success", label: "Reviewed" }
|
|
231
|
+
: { tone: "warning", label: "Awaiting review" };
|
|
232
|
+
return `
|
|
233
|
+
<div class="gp-topbar">
|
|
234
|
+
<div class="gp-topbar-inner">
|
|
235
|
+
<div class="gp-topbar-row">
|
|
236
|
+
<div class="gp-topbar-heading">
|
|
237
|
+
<span class="gp-topbar-brand">/gigaplan</span>
|
|
238
|
+
<h1 class="gp-topbar-title">${escapeText(currentPageTitle)}</h1>
|
|
239
|
+
</div>
|
|
240
|
+
<span class="cdr-badge cdr-badge--${status.tone} cdr-badge--dot">${status.label}</span>
|
|
241
|
+
<button type="button" class="cdr-iconbtn cdr-iconbtn--solid" data-action="toggle-theme" aria-label="Toggle theme">
|
|
242
|
+
<span data-theme-icon>${iconSvg(document.documentElement.getAttribute("data-theme") === "dark" ? "sun" : "moon", 18)}</span>
|
|
243
|
+
</button>
|
|
244
|
+
</div>
|
|
245
|
+
<div class="gp-topbar-meta">
|
|
246
|
+
<span>${escapeText(currentPlanFilename())} · <span class="gp-mono">${currentSections.length} section${currentSections.length === 1 ? "" : "s"}</span> · <span class="gp-mono">${commentCount} comment${commentCount === 1 ? "" : "s"}</span></span>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
</div>`;
|
|
250
|
+
}
|
|
251
|
+
function currentPlanFilename() {
|
|
252
|
+
const path = initial.planPath ?? "";
|
|
253
|
+
const parts = path.split("/");
|
|
254
|
+
return parts[parts.length - 1] || path;
|
|
255
|
+
}
|
|
256
|
+
function escapeText(text) {
|
|
257
|
+
const div = document.createElement("div");
|
|
258
|
+
div.textContent = text;
|
|
259
|
+
return div.innerHTML;
|
|
260
|
+
}
|
|
261
|
+
function collectAllComments() {
|
|
262
|
+
const entries = [];
|
|
263
|
+
for (const section of currentSections) {
|
|
264
|
+
if (section.headingBlockId) {
|
|
265
|
+
const c = state.pending.get(section.headingBlockId);
|
|
266
|
+
if (c)
|
|
267
|
+
entries.push({ blockId: section.headingBlockId, sectionTitle: section.title, body: c.body, createdAt: c.createdAt });
|
|
268
|
+
}
|
|
269
|
+
for (const item of section.items) {
|
|
270
|
+
const c = state.pending.get(item.id);
|
|
271
|
+
if (c)
|
|
272
|
+
entries.push({ blockId: item.id, sectionTitle: section.title, body: c.body, createdAt: c.createdAt });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
for (const [id, c] of state.orphanedPending) {
|
|
276
|
+
entries.push({ blockId: id, sectionTitle: "removed section", body: c.body, createdAt: c.createdAt });
|
|
277
|
+
}
|
|
278
|
+
return entries.sort((a, b) => a.createdAt - b.createdAt);
|
|
279
|
+
}
|
|
280
|
+
function buildSidebarHtml() {
|
|
281
|
+
const reviewedCount = currentSections.filter((s) => state.reviewed.has(s.id)).length;
|
|
282
|
+
const total = currentSections.length;
|
|
283
|
+
const pct = total > 0 ? Math.round((reviewedCount / total) * 100) : 0;
|
|
284
|
+
const allComments = collectAllComments();
|
|
285
|
+
const commentCount = state.pending.size + state.orphanedPending.size;
|
|
286
|
+
const commentsHtml = allComments.length > 0
|
|
287
|
+
? allComments
|
|
288
|
+
.map((c) => `
|
|
289
|
+
<div class="gp-comment-row" data-action="jump-to-comment" data-block-id="${c.blockId}">
|
|
290
|
+
<div class="gp-comment-row-head">
|
|
291
|
+
<span class="cdr-avatar cdr-avatar--sm">${initials("You")}</span>
|
|
292
|
+
<span class="gp-comment-row-author">You</span>
|
|
293
|
+
<span class="gp-comment-row-time">${relativeTime(c.createdAt)}</span>
|
|
294
|
+
</div>
|
|
295
|
+
<div class="gp-comment-row-section">${escapeText(c.sectionTitle)}</div>
|
|
296
|
+
<div class="gp-comment-row-text"></div>
|
|
297
|
+
</div>`)
|
|
298
|
+
.join("")
|
|
299
|
+
: '<div class="gp-empty-comments">Comments you leave on the plan will show up here, so you can see review coverage at a glance.</div>';
|
|
300
|
+
return `
|
|
301
|
+
<aside class="gp-sidebar">
|
|
302
|
+
<div class="cdr-card gp-coverage-card">
|
|
303
|
+
<div class="ds-eyebrow">// review coverage</div>
|
|
304
|
+
<div class="gp-coverage-numbers">
|
|
305
|
+
<span class="gp-coverage-count">${reviewedCount}</span>
|
|
306
|
+
<span class="gp-coverage-total">of ${total} sections reviewed</span>
|
|
307
|
+
</div>
|
|
308
|
+
<div class="gp-coverage-bar"><div class="gp-coverage-bar-fill" style="width:${pct}%"></div></div>
|
|
309
|
+
<div class="gp-coverage-footer">${commentCount} comment${commentCount === 1 ? "" : "s"} across the plan</div>
|
|
310
|
+
</div>
|
|
311
|
+
<div class="cdr-card">
|
|
312
|
+
<div class="gp-comments-card-head">All comments</div>
|
|
313
|
+
<div>${commentsHtml}</div>
|
|
314
|
+
</div>
|
|
315
|
+
</aside>`;
|
|
316
|
+
}
|
|
317
|
+
/* ---------------------------------------------------------- finish panel */
|
|
318
|
+
function buildFinishPanelHtml() {
|
|
319
|
+
const anyComments = hasAnyComments();
|
|
320
|
+
const approveLabel = anyComments ? "Approve with comments" : "Approve";
|
|
321
|
+
const requestChangesDisabled = !anyComments || state.submitted;
|
|
322
|
+
const verdict = effectiveVerdict();
|
|
323
|
+
const cannotSubmit = !verdict || state.submitted;
|
|
324
|
+
if (state.submitted) {
|
|
325
|
+
const verdictLabel = verdict === "approve" ? approveLabel : "Request changes";
|
|
326
|
+
return `
|
|
327
|
+
<div id="gp-finish-panel" class="gp-finish-panel">
|
|
328
|
+
<div class="cdr-card">
|
|
329
|
+
<div class="gp-finish-head">
|
|
330
|
+
<h2 class="gp-finish-title">Review submitted</h2>
|
|
331
|
+
</div>
|
|
332
|
+
<div class="gp-finish-body">
|
|
333
|
+
<div class="cdr-banner cdr-banner--success">
|
|
334
|
+
${iconSvg("circle-check", 18)}
|
|
335
|
+
<div>
|
|
336
|
+
<div class="cdr-banner-title">Review submitted</div>
|
|
337
|
+
<div class="cdr-banner-body">Verdict: ${verdictLabel}. Revise the plan and reopen this session to review again.</div>
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
${state.globalComment.trim() ? `<div class="gp-submitted-quote">“<span></span>”</div>` : ""}
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
</div>`;
|
|
344
|
+
}
|
|
345
|
+
return `
|
|
346
|
+
<div id="gp-finish-panel" class="gp-finish-panel">
|
|
347
|
+
<div class="cdr-card">
|
|
348
|
+
<div class="gp-finish-head">
|
|
349
|
+
<h2 class="gp-finish-title">Finish your review</h2>
|
|
350
|
+
</div>
|
|
351
|
+
<div class="gp-finish-body">
|
|
352
|
+
<div>
|
|
353
|
+
<div class="gp-verdict-label">Your review</div>
|
|
354
|
+
<div class="gp-verdict-options">
|
|
355
|
+
<label class="cdr-choice">
|
|
356
|
+
<input type="radio" name="verdict" data-action="set-verdict" value="approve" ${verdict === "approve" ? "checked" : ""} />
|
|
357
|
+
<span class="cdr-box cdr-box--radio"></span>
|
|
358
|
+
<span data-role="approve-label">${approveLabel}</span>
|
|
359
|
+
</label>
|
|
360
|
+
<label class="cdr-choice ${requestChangesDisabled ? "cdr-choice--disabled" : ""}" data-role="request-changes-choice">
|
|
361
|
+
<input type="radio" name="verdict" data-action="set-verdict" value="request_changes" ${verdict === "request_changes" ? "checked" : ""} ${requestChangesDisabled ? "disabled" : ""} />
|
|
362
|
+
<span class="cdr-box cdr-box--radio"></span>
|
|
363
|
+
Request changes
|
|
364
|
+
</label>
|
|
365
|
+
</div>
|
|
366
|
+
<div class="gp-verdict-hint" data-role="verdict-hint" ${requestChangesDisabled ? "" : 'style="display:none"'}>Leave at least one comment — on a section heading, an item, or below — to request changes.</div>
|
|
367
|
+
</div>
|
|
368
|
+
<textarea class="cdr-textarea" data-role="global-draft" placeholder="Overall feedback for the agent…" rows="4"></textarea>
|
|
369
|
+
<div class="gp-finish-actions">
|
|
370
|
+
<button type="button" class="cdr-btn cdr-btn--lg" data-action="submit-review" ${cannotSubmit ? "disabled" : ""}>Submit review</button>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
</div>`;
|
|
375
|
+
}
|
|
376
|
+
/* ---------------------------------------------------------- top-level render */
|
|
377
|
+
function renderApp() {
|
|
378
|
+
const built = buildSections(state.blocks);
|
|
379
|
+
currentSections = built.sections;
|
|
380
|
+
currentPageTitle = built.pageTitle || currentPlanFilename();
|
|
381
|
+
appEl.innerHTML = `
|
|
382
|
+
${buildTopBarHtml()}
|
|
383
|
+
<div class="gp-body">
|
|
384
|
+
<div class="gp-main-col">${currentSections.map(buildSectionHtml).join("")}</div>
|
|
385
|
+
${buildSidebarHtml()}
|
|
386
|
+
</div>
|
|
387
|
+
${buildFinishPanelHtml()}
|
|
388
|
+
`;
|
|
389
|
+
// textContent, never innerHTML, for anything containing a reviewer's own words.
|
|
390
|
+
for (const [blockId, comment] of state.pending) {
|
|
391
|
+
appEl.querySelector(`[data-comment-for="${blockId}"] .gp-thread-comment-body`).textContent = comment.body;
|
|
392
|
+
}
|
|
393
|
+
for (const entry of collectAllComments()) {
|
|
394
|
+
const row = appEl.querySelector(`[data-action="jump-to-comment"][data-block-id="${entry.blockId}"] .gp-comment-row-text`);
|
|
395
|
+
if (row)
|
|
396
|
+
row.textContent = entry.body;
|
|
397
|
+
}
|
|
398
|
+
if (state.submitted && state.globalComment.trim()) {
|
|
399
|
+
const quote = appEl.querySelector(".gp-submitted-quote span");
|
|
400
|
+
if (quote)
|
|
401
|
+
quote.textContent = state.globalComment.trim();
|
|
402
|
+
}
|
|
403
|
+
// Restore in-progress drafts and focus into any open composer.
|
|
404
|
+
appEl.querySelectorAll('textarea[data-role="draft"]').forEach((ta) => {
|
|
405
|
+
const blockId = ta.dataset.blockId;
|
|
406
|
+
ta.value = state.drafts.get(blockId) ?? "";
|
|
407
|
+
});
|
|
408
|
+
const globalTextarea = appEl.querySelector('textarea[data-role="global-draft"]');
|
|
409
|
+
if (globalTextarea)
|
|
410
|
+
globalTextarea.value = state.globalComment;
|
|
411
|
+
if (state.openComposer) {
|
|
412
|
+
appEl.querySelector(`[data-composer-for="${state.openComposer}"] textarea`)?.focus();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/* ---------------------------------------------------------- actions */
|
|
416
|
+
function openComposer(blockId) {
|
|
417
|
+
state.openComposer = blockId;
|
|
418
|
+
if (!state.drafts.has(blockId))
|
|
419
|
+
state.drafts.set(blockId, state.pending.get(blockId)?.body ?? "");
|
|
420
|
+
renderApp();
|
|
421
|
+
}
|
|
422
|
+
function cancelComposer(blockId) {
|
|
423
|
+
state.openComposer = null;
|
|
424
|
+
state.drafts.delete(blockId);
|
|
425
|
+
renderApp();
|
|
426
|
+
}
|
|
427
|
+
function saveComment(blockId) {
|
|
428
|
+
const body = (state.drafts.get(blockId) ?? "").trim();
|
|
429
|
+
if (!body)
|
|
430
|
+
return;
|
|
431
|
+
const block = state.blocks.find((b) => b.id === blockId);
|
|
432
|
+
const existing = state.pending.get(blockId);
|
|
433
|
+
state.pending.set(blockId, {
|
|
434
|
+
body,
|
|
435
|
+
excerpt: block?.excerpt ?? "",
|
|
436
|
+
headingBreadcrumb: block?.headingBreadcrumb ?? [],
|
|
437
|
+
createdAt: existing?.createdAt ?? Date.now(),
|
|
438
|
+
});
|
|
439
|
+
state.openComposer = null;
|
|
440
|
+
state.drafts.delete(blockId);
|
|
441
|
+
renderApp();
|
|
442
|
+
}
|
|
443
|
+
function removeComment(blockId) {
|
|
444
|
+
state.pending.delete(blockId);
|
|
445
|
+
renderApp();
|
|
446
|
+
}
|
|
447
|
+
function toggleCollapse(sectionId) {
|
|
448
|
+
const body = appEl.querySelector(`[data-section-body="${sectionId}"]`);
|
|
449
|
+
const chevronBtn = appEl.querySelector(`[data-action="toggle-collapse"][data-section-id="${sectionId}"]`);
|
|
450
|
+
if (!body || !chevronBtn)
|
|
451
|
+
return;
|
|
452
|
+
const nowCollapsed = !state.collapsed.has(sectionId);
|
|
453
|
+
if (nowCollapsed)
|
|
454
|
+
state.collapsed.add(sectionId);
|
|
455
|
+
else
|
|
456
|
+
state.collapsed.delete(sectionId);
|
|
457
|
+
body.style.display = nowCollapsed ? "none" : "";
|
|
458
|
+
chevronBtn.innerHTML = iconSvg(nowCollapsed ? "chevron-right" : "chevron-down");
|
|
459
|
+
}
|
|
460
|
+
function toggleReviewed(sectionId) {
|
|
461
|
+
const nowReviewed = !state.reviewed.has(sectionId);
|
|
462
|
+
if (nowReviewed) {
|
|
463
|
+
state.reviewed.add(sectionId);
|
|
464
|
+
state.collapsed.add(sectionId);
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
state.reviewed.delete(sectionId);
|
|
468
|
+
state.collapsed.delete(sectionId);
|
|
469
|
+
}
|
|
470
|
+
renderApp();
|
|
471
|
+
}
|
|
472
|
+
function jumpToComment(blockId) {
|
|
473
|
+
const section = currentSections.find((s) => s.headingBlockId === blockId || s.items.some((it) => it.id === blockId));
|
|
474
|
+
if (section && state.collapsed.has(section.id)) {
|
|
475
|
+
state.collapsed.delete(section.id);
|
|
476
|
+
renderApp();
|
|
477
|
+
}
|
|
478
|
+
requestAnimationFrame(() => {
|
|
479
|
+
document.getElementById(`gp-block-${blockId}`)?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
function setVerdict(v) {
|
|
483
|
+
if (v === "request_changes" && !hasAnyComments())
|
|
484
|
+
return;
|
|
485
|
+
state.verdict = v;
|
|
486
|
+
renderApp();
|
|
487
|
+
}
|
|
488
|
+
// Called on every keystroke in the global-feedback textarea. Patches just the
|
|
489
|
+
// verdict controls in place (never renderApp() here) so typing never steals
|
|
490
|
+
// focus out of the textarea the reviewer is actively using.
|
|
491
|
+
function updateVerdictAvailability() {
|
|
492
|
+
const anyComments = hasAnyComments();
|
|
493
|
+
const requestChangesDisabled = !anyComments || state.submitted;
|
|
494
|
+
const approveLabelEl = appEl.querySelector('[data-role="approve-label"]');
|
|
495
|
+
if (approveLabelEl)
|
|
496
|
+
approveLabelEl.textContent = anyComments ? "Approve with comments" : "Approve";
|
|
497
|
+
const requestInput = appEl.querySelector('input[data-action="set-verdict"][value="request_changes"]');
|
|
498
|
+
if (requestInput) {
|
|
499
|
+
requestInput.disabled = requestChangesDisabled;
|
|
500
|
+
if (requestChangesDisabled && requestInput.checked) {
|
|
501
|
+
requestInput.checked = false;
|
|
502
|
+
state.verdict = null;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
appEl
|
|
506
|
+
.querySelector('[data-role="request-changes-choice"]')
|
|
507
|
+
?.classList.toggle("cdr-choice--disabled", requestChangesDisabled);
|
|
508
|
+
const hintEl = appEl.querySelector('[data-role="verdict-hint"]');
|
|
509
|
+
if (hintEl)
|
|
510
|
+
hintEl.style.display = requestChangesDisabled ? "" : "none";
|
|
511
|
+
const submitBtn = appEl.querySelector('[data-action="submit-review"]');
|
|
512
|
+
if (submitBtn)
|
|
513
|
+
submitBtn.disabled = !effectiveVerdict() || state.submitted;
|
|
514
|
+
}
|
|
515
|
+
function toggleTheme() {
|
|
516
|
+
const root = document.documentElement;
|
|
517
|
+
const next = root.getAttribute("data-theme") === "dark" ? "light" : "dark";
|
|
518
|
+
root.setAttribute("data-theme", next);
|
|
519
|
+
const iconHost = document.querySelector("[data-theme-icon]");
|
|
520
|
+
if (iconHost)
|
|
521
|
+
iconHost.innerHTML = iconSvg(next === "dark" ? "sun" : "moon", 18);
|
|
522
|
+
}
|
|
523
|
+
async function submitReview() {
|
|
524
|
+
const verdict = effectiveVerdict();
|
|
525
|
+
if (!verdict || state.submitted)
|
|
526
|
+
return;
|
|
527
|
+
const comments = Array.from(state.pending.entries()).map(([blockId, c]) => ({ blockId, body: c.body }));
|
|
528
|
+
const orphanedText = Array.from(state.orphanedPending.values())
|
|
529
|
+
.map((c) => `[on removed section "${c.headingBreadcrumb.join(" > ") || c.excerpt}"]: ${c.body}`)
|
|
530
|
+
.join("\n\n");
|
|
531
|
+
const globalComment = [state.globalComment.trim(), orphanedText].filter(Boolean).join("\n\n");
|
|
532
|
+
const res = await fetch(apiUrl("/submit"), {
|
|
533
|
+
method: "POST",
|
|
534
|
+
headers: { "Content-Type": "application/json" },
|
|
535
|
+
body: JSON.stringify({ verdict, globalComment, comments }),
|
|
536
|
+
});
|
|
537
|
+
if (!res.ok)
|
|
538
|
+
return;
|
|
539
|
+
state.submitted = true;
|
|
540
|
+
renderApp();
|
|
541
|
+
}
|
|
542
|
+
/* ---------------------------------------------------------- event delegation */
|
|
543
|
+
appEl.addEventListener("click", (e) => {
|
|
544
|
+
const target = e.target.closest("[data-action]");
|
|
545
|
+
if (!target)
|
|
546
|
+
return;
|
|
547
|
+
const action = target.dataset.action;
|
|
548
|
+
const blockId = target.dataset.blockId;
|
|
549
|
+
const sectionId = target.dataset.sectionId;
|
|
550
|
+
switch (action) {
|
|
551
|
+
case "toggle-composer":
|
|
552
|
+
if (!blockId)
|
|
553
|
+
break;
|
|
554
|
+
if (state.openComposer === blockId)
|
|
555
|
+
cancelComposer(blockId);
|
|
556
|
+
else
|
|
557
|
+
openComposer(blockId);
|
|
558
|
+
break;
|
|
559
|
+
case "cancel-comment":
|
|
560
|
+
if (blockId)
|
|
561
|
+
cancelComposer(blockId);
|
|
562
|
+
break;
|
|
563
|
+
case "save-comment":
|
|
564
|
+
if (blockId)
|
|
565
|
+
saveComment(blockId);
|
|
566
|
+
break;
|
|
567
|
+
case "edit-comment":
|
|
568
|
+
if (blockId)
|
|
569
|
+
openComposer(blockId);
|
|
570
|
+
break;
|
|
571
|
+
case "remove-comment":
|
|
572
|
+
if (blockId)
|
|
573
|
+
removeComment(blockId);
|
|
574
|
+
break;
|
|
575
|
+
case "toggle-collapse":
|
|
576
|
+
if (sectionId)
|
|
577
|
+
toggleCollapse(sectionId);
|
|
578
|
+
break;
|
|
579
|
+
case "jump-to-comment":
|
|
580
|
+
if (blockId)
|
|
581
|
+
jumpToComment(blockId);
|
|
582
|
+
break;
|
|
583
|
+
case "toggle-theme":
|
|
584
|
+
toggleTheme();
|
|
585
|
+
break;
|
|
586
|
+
case "submit-review":
|
|
587
|
+
submitReview().catch(() => {
|
|
588
|
+
// Leaves the panel interactive so the reviewer can just try again.
|
|
589
|
+
});
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
appEl.addEventListener("change", (e) => {
|
|
594
|
+
const target = e.target;
|
|
595
|
+
const action = target.dataset.action;
|
|
596
|
+
if (action === "toggle-reviewed" && target.dataset.sectionId) {
|
|
597
|
+
toggleReviewed(target.dataset.sectionId);
|
|
598
|
+
}
|
|
599
|
+
else if (action === "set-verdict" && target instanceof HTMLInputElement) {
|
|
600
|
+
setVerdict(target.value);
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
appEl.addEventListener("input", (e) => {
|
|
604
|
+
const target = e.target;
|
|
605
|
+
if (target.dataset.role === "draft" && target.dataset.blockId && target instanceof HTMLTextAreaElement) {
|
|
606
|
+
state.drafts.set(target.dataset.blockId, target.value);
|
|
607
|
+
}
|
|
608
|
+
else if (target.dataset.role === "global-draft" && target instanceof HTMLTextAreaElement) {
|
|
609
|
+
state.globalComment = target.value;
|
|
610
|
+
updateVerdictAvailability();
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
/* ---------------------------------------------------------- live-reload */
|
|
614
|
+
async function fetchAndReconcile() {
|
|
615
|
+
const res = await fetch(apiUrl("/blocks"));
|
|
616
|
+
if (!res.ok)
|
|
617
|
+
return;
|
|
618
|
+
const data = (await res.json());
|
|
619
|
+
const newIds = new Set(data.blocks.map((b) => b.id));
|
|
620
|
+
for (const [id, comment] of Array.from(state.pending.entries())) {
|
|
621
|
+
if (!newIds.has(id)) {
|
|
622
|
+
state.orphanedPending.set(id, comment);
|
|
623
|
+
state.pending.delete(id);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
for (const o of data.orphaned) {
|
|
627
|
+
const existing = state.pending.get(o.id);
|
|
628
|
+
if (existing) {
|
|
629
|
+
state.orphanedPending.set(o.id, existing);
|
|
630
|
+
state.pending.delete(o.id);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
state.staleIds.clear();
|
|
634
|
+
for (const s of data.stale)
|
|
635
|
+
state.staleIds.add(s.id);
|
|
636
|
+
state.blocks = data.blocks;
|
|
637
|
+
renderApp();
|
|
638
|
+
}
|
|
639
|
+
function connectSSE() {
|
|
640
|
+
const source = new EventSource(apiUrl("/events"));
|
|
641
|
+
source.addEventListener("changed", () => {
|
|
642
|
+
fetchAndReconcile().catch(() => {
|
|
643
|
+
// Live-reload is best-effort; the reviewer can still submit against what's on screen.
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
renderApp();
|
|
648
|
+
connectSSE();
|