claude-plan-review 0.3.2 → 0.4.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/src/ui/app.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  const $ = (id) => document.getElementById(id);
4
+ const enc = encodeURIComponent;
4
5
  const esc = (s) =>
5
6
  String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
6
7
 
@@ -9,7 +10,10 @@ async function api(path, opts) {
9
10
  if (!res.ok) {
10
11
  const body = await res.json().catch(() => ({}));
11
12
  const err = new Error(body.error || res.statusText);
13
+ err.status = res.status;
14
+ err.body = body;
12
15
  err.fixCommand = body.fixCommand ?? null;
16
+ err.pendingReviews = body.pendingReviews ?? null;
13
17
  throw err;
14
18
  }
15
19
  return res.status === 204 ? null : res.json();
@@ -21,15 +25,30 @@ const state = {
21
25
  baseVersion: null,
22
26
  reviewId: null,
23
27
  review: null,
28
+ projects: [],
24
29
  versions: [],
25
- data: null, // { markdown, html, meta }
26
- comments: [],
30
+ data: null, // { markdown, html, meta, kind?, manifest? }
31
+ // --- multi-document ---
32
+ manifest: null, // current version's manifest, or null for legacy/flat
33
+ kind: "single",
34
+ doc: "root", // active doc slug
35
+ docCache: new Map(), // slug → {slug,title,parent,markdown,html}
36
+ manifestCache: new Map(), // version n → manifest|null (for the diff doc union)
37
+ allComments: [], // every comment on the version (each carries `doc`) — drives badges
38
+ viewAsTree: false, // single-doc display-only outline mode
39
+ treeSection: -1, // selected outline section index (-1 = show all)
40
+ _sections: null, // cached outline sections for the current preview
41
+ // --- deletion ---
42
+ pendingDelete: null, // { type:"version"|"project"|"bulk", key, n?, ns?, fromManage? }
43
+ manageProjects: null,
44
+ // ---
45
+ comments: [], // comments scoped to the active doc
27
46
  tab: "preview",
28
47
  diffMode: "split",
29
- sel: { anchor: null, start: null, end: null }, // source line-range selection
30
- dragging: false,
31
- storage: {}, // channel bindings for the current project, e.g. { gist: {...} }
32
- channels: [], // channel readiness (loaded when the save modal opens)
48
+ pendingSel: null,
49
+ pendingDoc: null, // &doc= from the initial URL, consumed once
50
+ storage: {},
51
+ channels: [],
33
52
  };
34
53
 
35
54
  function toast(msg) {
@@ -46,6 +65,7 @@ async function init() {
46
65
  state.key = q.get("project");
47
66
  state.reviewId = q.get("review");
48
67
  const wantV = q.get("version");
68
+ state.pendingDoc = q.get("doc");
49
69
 
50
70
  wireUI();
51
71
  api("/api/version")
@@ -66,8 +86,11 @@ function showEmpty(msg) {
66
86
  document.querySelectorAll(".panel").forEach((p) => p.classList.remove("active"));
67
87
  $("panel-preview").classList.add("active");
68
88
  $("preview").innerHTML = `<div class="empty">${esc(msg)}</div>`;
89
+ $("docSidebar").hidden = true;
69
90
  $("reviewActions").hidden = true;
70
91
  $("saveActions").hidden = true;
92
+ $("deleteVerBtn").hidden = true;
93
+ $("treeToggle").hidden = true;
71
94
  }
72
95
 
73
96
  async function loadProjects() {
@@ -86,9 +109,10 @@ async function loadProjects() {
86
109
  async function selectProject(key, wantV) {
87
110
  state.key = key;
88
111
  $("projectSel").value = key;
89
- const { versions } = await api(`/api/projects/${encodeURIComponent(key)}`);
112
+ state.manifestCache = new Map();
113
+ const { versions } = await api(`/api/projects/${enc(key)}`);
90
114
  state.versions = versions;
91
- state.storage = await api(`/api/projects/${encodeURIComponent(key)}/storage`).catch(() => ({}));
115
+ state.storage = await api(`/api/projects/${enc(key)}/storage`).catch(() => ({}));
92
116
  if (!versions.length) {
93
117
  showEmpty("This project has no plan versions yet.");
94
118
  return;
@@ -99,6 +123,7 @@ async function selectProject(key, wantV) {
99
123
  const fmt = (v) => `v${v.n} · ${new Date(v.createdAt).toLocaleString()}${v.n === latest ? " (current)" : ""}`;
100
124
  $("versionSel").innerHTML = versions.map((v) => `<option value="${v.n}">${esc(fmt(v))}</option>`).join("");
101
125
  $("versionSel").value = String(state.version);
126
+ $("deleteVerBtn").hidden = false;
102
127
 
103
128
  // base = the version right before current, if any
104
129
  state.baseVersion = versions.length > 1 ? versions[versions.findIndex((v) => v.n === state.version) - 1]?.n ?? versions[0].n : null;
@@ -113,21 +138,87 @@ async function selectProject(key, wantV) {
113
138
 
114
139
  async function loadVersion(n) {
115
140
  state.version = n;
116
- state.data = await api(`/api/projects/${encodeURIComponent(state.key)}/versions/${n}`);
117
- state.comments = await api(`/api/projects/${encodeURIComponent(state.key)}/versions/${n}/comments`);
141
+ hideSelPop();
142
+ state.viewAsTree = false;
143
+ state.treeSection = -1;
144
+ state.docCache = new Map();
145
+ state.data = await api(`/api/projects/${enc(state.key)}/versions/${n}`);
146
+ // Legacy/flat servers return no manifest — treat the whole plan as one root doc.
147
+ state.manifest = state.data.manifest || null;
148
+ const docs = state.manifest?.docs || [];
149
+ const isTree = docs.length > 1;
150
+ state.kind = state.data.kind || (isTree ? "tree" : "single");
118
151
  $("diffCur").textContent = n;
119
- renderPreview();
120
- renderSource();
121
- renderGeneral();
122
- updateCommentCount();
152
+ updateTreeToggle();
153
+
154
+ if (isTree) {
155
+ // sidebar badge counts come from one unscoped fetch; threads filter it per doc.
156
+ state.allComments = await api(`/api/projects/${enc(state.key)}/versions/${n}/comments`);
157
+ let slug = state.manifest.root || docs[0].slug;
158
+ if (state.pendingDoc && docs.some((d) => d.slug === state.pendingDoc)) slug = state.pendingDoc;
159
+ state.pendingDoc = null;
160
+ await loadDoc(slug);
161
+ } else {
162
+ state.doc = "root";
163
+ state.comments = await api(`/api/projects/${enc(state.key)}/versions/${n}/comments`);
164
+ state.allComments = state.comments;
165
+ renderPreview();
166
+ renderGeneral();
167
+ updateCommentCount();
168
+ renderSidebar();
169
+ }
170
+
123
171
  refreshSaveUI();
124
172
  await refreshReview();
125
173
  if (state.tab === "diff") loadDiff();
126
174
  }
127
175
 
176
+ // Load a document within a tree version (cached), scoping comments + preview to it.
177
+ async function loadDoc(slug) {
178
+ state.doc = slug;
179
+ hideSelPop();
180
+ let doc = state.docCache.get(slug);
181
+ if (!doc) {
182
+ doc = await api(`/api/projects/${enc(state.key)}/versions/${state.version}/docs/${enc(slug)}`);
183
+ state.docCache.set(slug, doc);
184
+ }
185
+ state.data = { ...state.data, markdown: doc.markdown, html: doc.html };
186
+ state.comments = (state.allComments || []).filter((c) => (c.doc || "root") === slug);
187
+ updateDocParam();
188
+ renderPreview();
189
+ renderGeneral();
190
+ updateCommentCount();
191
+ renderSidebar();
192
+ }
193
+
194
+ // keep &doc in the URL in sync without a reload (no pushState today — greenfield)
195
+ function updateDocParam() {
196
+ const q = new URLSearchParams(location.search);
197
+ if (state.key) q.set("project", state.key);
198
+ if (state.version) q.set("version", String(state.version));
199
+ if (state.doc && state.doc !== "root") q.set("doc", state.doc);
200
+ else q.delete("doc");
201
+ history.replaceState(null, "", location.pathname + "?" + q.toString());
202
+ }
203
+
204
+ // refetch comments after a create/delete, keeping the doc-scoped + badge views in sync
205
+ async function refreshComments() {
206
+ state.allComments = await api(`/api/projects/${enc(state.key)}/versions/${state.version}/comments`);
207
+ const isTree = state.manifest && state.manifest.docs.length > 1;
208
+ state.comments = isTree
209
+ ? state.allComments.filter((c) => (c.doc || "root") === state.doc)
210
+ : state.allComments;
211
+ renderPreview();
212
+ renderGeneral();
213
+ updateCommentCount();
214
+ renderSidebar();
215
+ }
216
+
128
217
  // ---------- rendering ----------
129
218
  function renderPreview() {
130
219
  $("preview").innerHTML = state.data.html || "<p class='empty'>(empty plan)</p>";
220
+ renderThreads();
221
+ if (state.viewAsTree) applyTreeView();
131
222
  }
132
223
 
133
224
  // threads are anchored under the END line of their range; covered = every line a comment spans
@@ -154,28 +245,45 @@ function locLabel(c) {
154
245
 
155
246
  function commentHTML(c) {
156
247
  const loc = locLabel(c);
248
+ const quote = c.quote ? `<div class="cquote">${esc(c.quote)}</div>` : "";
157
249
  return `<div class="comment" data-id="${c.id}">
158
250
  <div class="chead"><span><b>${esc(c.author)}</b>${loc ? ` · <span class="loc">${loc}</span>` : ""} · ${new Date(c.createdAt).toLocaleString()}</span>
159
251
  <button class="del" data-del="${c.id}">delete</button></div>
252
+ ${quote}
160
253
  <div class="cbody">${esc(c.body)}</div></div>`;
161
254
  }
162
255
 
163
- function renderSource() {
164
- const lines = (state.data.markdown || "").split("\n");
256
+ // map a source line to the rendered block that spans it
257
+ function blockForLine(line) {
258
+ const blocks = $("preview").querySelectorAll(".md-block");
259
+ for (const b of blocks) {
260
+ if (Number(b.dataset.lineStart) <= line && line <= Number(b.dataset.lineEnd)) return b;
261
+ }
262
+ return blocks[blocks.length - 1] || null;
263
+ }
264
+
265
+ // render inline comment threads into the preview, anchored after their block
266
+ function renderThreads() {
267
+ const preview = $("preview");
268
+ preview.querySelectorAll(".threads, .composer.inline").forEach((n) => n.remove());
165
269
  const { byAnchor, covered } = commentLayout();
166
- const { start, end } = state.sel;
167
- const inSel = (n) => start != null && n >= start && n <= end;
168
- let html = "";
169
- for (let i = 0; i < lines.length; i++) {
170
- const num = i + 1;
171
- const threads = byAnchor.get(num) || [];
172
- const cls = [covered.has(num) ? "has-comments" : "", inSel(num) ? "sel" : ""].filter(Boolean).join(" ");
173
- html += `<div class="srow ${cls}" data-line="${num}">
174
- <div class="sgutter" data-add="${num}">${num}</div>
175
- <div class="scode">${esc(lines[i]) || "&nbsp;"}</div></div>`;
176
- if (threads.length) html += `<div class="threads">${threads.map(commentHTML).join("")}</div>`;
270
+
271
+ preview.querySelectorAll(".md-block").forEach((b) => {
272
+ const s = Number(b.dataset.lineStart);
273
+ const e = Number(b.dataset.lineEnd);
274
+ let has = false;
275
+ for (let k = s; k <= e && !has; k++) if (covered.has(k)) has = true;
276
+ b.classList.toggle("has-comments", has);
277
+ });
278
+
279
+ for (const [anchor, threads] of byAnchor) {
280
+ const block = blockForLine(anchor);
281
+ if (!block) continue;
282
+ const box = document.createElement("div");
283
+ box.className = "threads";
284
+ box.innerHTML = threads.map(commentHTML).join("");
285
+ block.after(box);
177
286
  }
178
- $("source").innerHTML = html;
179
287
  }
180
288
 
181
289
  function renderGeneral() {
@@ -184,67 +292,230 @@ function renderGeneral() {
184
292
  }
185
293
 
186
294
  function updateCommentCount() {
187
- const n = state.comments.length;
295
+ const n = (state.allComments || state.comments).length;
188
296
  $("cCount").textContent = n ? n : "";
189
297
  }
190
298
 
299
+ // ---------- sidebar (doc tree / outline) ----------
300
+ function docCounts() {
301
+ const m = new Map();
302
+ for (const c of state.allComments || []) {
303
+ const d = c.doc || "root";
304
+ m.set(d, (m.get(d) || 0) + 1);
305
+ }
306
+ return m;
307
+ }
308
+
309
+ function renderSidebar() {
310
+ const sb = $("docSidebar");
311
+ const isTree = state.manifest && state.manifest.docs.length > 1;
312
+ if (isTree) {
313
+ sb.hidden = false;
314
+ sb.innerHTML = renderDocTree();
315
+ } else if (state.viewAsTree) {
316
+ sb.hidden = false;
317
+ sb.innerHTML = renderHeadingTree();
318
+ } else {
319
+ sb.hidden = true;
320
+ sb.innerHTML = "";
321
+ }
322
+ }
323
+
324
+ function renderDocTree() {
325
+ const docs = state.manifest.docs.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
326
+ const bySlug = new Map(docs.map((d) => [d.slug, d]));
327
+ const rootSlug = state.manifest.root || "root";
328
+ const counts = docCounts();
329
+ const pending = state.review && state.review.status === "pending";
330
+
331
+ const parentKey = (d) => {
332
+ const p = d.parent;
333
+ if (!p || p === "") return d.slug === rootSlug ? null : rootSlug;
334
+ return p;
335
+ };
336
+
337
+ const rows = [];
338
+ const seen = new Set();
339
+ const walk = (slug, depth) => {
340
+ if (seen.has(slug)) return;
341
+ const d = bySlug.get(slug);
342
+ if (!d) return;
343
+ seen.add(slug);
344
+ rows.push(docRow(d, depth, counts, pending));
345
+ docs.filter((x) => parentKey(x) === slug).forEach((ch) => walk(ch.slug, depth + 1));
346
+ };
347
+ walk(rootSlug, 0);
348
+ // orphans (parent points nowhere) — render at top level so nothing is lost
349
+ docs.forEach((d) => {
350
+ if (!seen.has(d.slug)) walk(d.slug, 0);
351
+ });
352
+
353
+ return `<div class="sb-head">Documents</div>${rows.join("")}`;
354
+ }
355
+
356
+ function docRow(d, depth, counts, pending) {
357
+ const n = counts.get(d.slug) || 0;
358
+ const active = d.slug === state.doc ? " active" : "";
359
+ const dot = n > 0 && pending ? `<span class="sb-dot" title="comments pending review"></span>` : "";
360
+ const badge = n ? `<span class="sb-badge">${n}</span>` : "";
361
+ return `<a class="sb-row${active}" data-doc="${esc(d.slug)}" href="#" style="padding-left:${8 + depth * 14}px">
362
+ <span class="sb-title">${esc(d.title)}</span>${dot}${badge}</a>`;
363
+ }
364
+
365
+ // ---------- view-as-tree (single-doc, display-only) ----------
366
+ // Split the server-rendered preview into heading sections. Blocks are HIDDEN via
367
+ // a class, never detached — blockForLine/commentLayout still walk them by line.
368
+ function computeSections() {
369
+ const blocks = [...$("preview").querySelectorAll(".md-block")];
370
+ const sections = [];
371
+ let cur = null;
372
+ for (const b of blocks) {
373
+ const first = b.firstElementChild;
374
+ const isHeading = first && /^H[1-6]$/.test(first.tagName);
375
+ if (isHeading) {
376
+ cur = { id: sections.length, level: Number(first.tagName[1]), title: first.textContent.trim() || "(untitled)", blocks: [b] };
377
+ sections.push(cur);
378
+ } else {
379
+ if (!cur) {
380
+ cur = { id: sections.length, level: 0, title: "(top)", blocks: [] };
381
+ sections.push(cur);
382
+ }
383
+ cur.blocks.push(b);
384
+ }
385
+ }
386
+ return sections;
387
+ }
388
+
389
+ function applyTreeView() {
390
+ const sections = computeSections();
391
+ state._sections = sections;
392
+ if (state.treeSection >= sections.length) state.treeSection = -1;
393
+ const blocks = [...$("preview").querySelectorAll(".md-block")];
394
+
395
+ const show = new Set();
396
+ if (state.treeSection === -1) {
397
+ blocks.forEach((b) => show.add(b));
398
+ } else {
399
+ const sel = sections[state.treeSection];
400
+ sel.blocks.forEach((b) => show.add(b));
401
+ if (sel.level !== 0) {
402
+ for (let j = state.treeSection + 1; j < sections.length; j++) {
403
+ if (sections[j].level > sel.level) sections[j].blocks.forEach((b) => show.add(b));
404
+ else break;
405
+ }
406
+ }
407
+ }
408
+ blocks.forEach((b) => b.classList.toggle("tree-hidden", !show.has(b)));
409
+ // hide threads whose owning block is hidden
410
+ $("preview").querySelectorAll(".threads, .composer.inline").forEach((t) => {
411
+ const prev = t.previousElementSibling;
412
+ const hidden = prev && prev.classList.contains("md-block") && prev.classList.contains("tree-hidden");
413
+ t.classList.toggle("tree-hidden", !!hidden);
414
+ });
415
+ }
416
+
417
+ function renderHeadingTree() {
418
+ const sections = state._sections || computeSections();
419
+ state._sections = sections;
420
+ const rows = [`<div class="sb-head">Outline</div>`];
421
+ rows.push(
422
+ `<a class="sb-row${state.treeSection === -1 ? " active" : ""}" data-sec="-1" href="#"><span class="sb-title">Show all</span></a>`,
423
+ );
424
+ sections.forEach((s, i) => {
425
+ if (s.level === 0 && !s.blocks.length) return;
426
+ const depth = s.level === 0 ? 0 : s.level - 1;
427
+ rows.push(
428
+ `<a class="sb-row${state.treeSection === i ? " active" : ""}" data-sec="${i}" href="#" style="padding-left:${8 + depth * 14}px"><span class="sb-title">${esc(s.title)}</span></a>`,
429
+ );
430
+ });
431
+ return rows.join("");
432
+ }
433
+
434
+ function updateTreeToggle() {
435
+ const isTree = state.manifest && state.manifest.docs.length > 1;
436
+ const t = $("treeToggle");
437
+ t.hidden = isTree; // real doc trees get the sidebar automatically
438
+ if (isTree) state.viewAsTree = false;
439
+ t.classList.toggle("active", state.viewAsTree);
440
+ t.textContent = state.viewAsTree ? "Exit tree view" : "View as tree";
441
+ }
442
+
191
443
  // ---------- comments ----------
192
- async function addComment(line, lineEnd, body) {
444
+ async function addComment(line, lineEnd, body, quote) {
193
445
  if (!body.trim()) return;
194
- await api(`/api/projects/${encodeURIComponent(state.key)}/versions/${state.version}/comments`, {
446
+ await api(`/api/projects/${enc(state.key)}/versions/${state.version}/comments`, {
195
447
  method: "POST",
196
448
  headers: { "content-type": "application/json" },
197
- body: JSON.stringify({ line, lineEnd, body }),
449
+ body: JSON.stringify({ doc: state.doc, line, lineEnd, quote: quote ?? null, body }),
198
450
  });
199
- state.comments = await api(`/api/projects/${encodeURIComponent(state.key)}/versions/${state.version}/comments`);
200
- renderSource();
201
- renderGeneral();
202
- updateCommentCount();
451
+ await refreshComments();
203
452
  }
204
453
 
205
454
  async function delComment(id) {
206
- await api(`/api/projects/${encodeURIComponent(state.key)}/versions/${state.version}/comments/${id}`, {
455
+ await api(`/api/projects/${enc(state.key)}/versions/${state.version}/comments/${id}`, {
207
456
  method: "DELETE",
208
457
  });
209
- state.comments = state.comments.filter((c) => c.id !== id);
210
- renderSource();
211
- renderGeneral();
212
- updateCommentCount();
458
+ await refreshComments();
213
459
  }
214
460
 
215
- // lightweight selection repaint toggles a class on existing rows so we don't
216
- // rebuild the DOM mid-drag (which would break pointer tracking)
217
- function paintSelection() {
218
- const { start, end } = state.sel;
219
- document.querySelectorAll("#source .srow").forEach((row) => {
220
- const n = Number(row.dataset.line);
221
- row.classList.toggle("sel", start != null && n >= start && n <= end);
461
+ // ---------- text selection line-anchored comment ----------
462
+ function currentSelection() {
463
+ const sel = window.getSelection();
464
+ if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
465
+ const range = sel.getRangeAt(0);
466
+ const preview = $("preview");
467
+ if (!preview.contains(range.commonAncestorContainer)) return null;
468
+ const quote = sel.toString().replace(/\s+/g, " ").trim();
469
+ if (!quote) return null;
470
+
471
+ const blocks = [];
472
+ preview.querySelectorAll(".md-block").forEach((b) => {
473
+ if (b.classList.contains("tree-hidden")) return;
474
+ const ir = range.cloneRange();
475
+ const br = document.createRange();
476
+ br.selectNodeContents(b);
477
+ if (ir.compareBoundaryPoints(Range.START_TO_START, br) < 0) ir.setStart(br.startContainer, br.startOffset);
478
+ if (ir.compareBoundaryPoints(Range.END_TO_END, br) > 0) ir.setEnd(br.endContainer, br.endOffset);
479
+ if (!ir.collapsed && ir.toString().trim()) blocks.push(b);
222
480
  });
481
+ if (!blocks.length) return null;
482
+ const start = Math.min(...blocks.map((b) => Number(b.dataset.lineStart)));
483
+ const end = Math.max(...blocks.map((b) => Number(b.dataset.lineEnd)));
484
+ return { start, end, quote, rect: range.getBoundingClientRect() };
223
485
  }
224
486
 
225
- function clearSelection() {
226
- state.sel = { anchor: null, start: null, end: null };
227
- document.querySelector(".composer.inline")?.remove();
228
- renderSource();
487
+ function showSelPop(info) {
488
+ const pop = $("selPop");
489
+ pop.hidden = false;
490
+ pop.style.top = `${info.rect.bottom + 6}px`;
491
+ pop.style.left = `${info.rect.left}px`;
492
+ state.pendingSel = info;
229
493
  }
230
494
 
231
- function openComposer(start, end) {
495
+ function hideSelPop() {
496
+ $("selPop").hidden = true;
497
+ state.pendingSel = null;
498
+ }
499
+
500
+ function openComposer(info) {
232
501
  document.querySelector(".composer.inline")?.remove();
233
- const row = document.querySelector(`.srow[data-line="${end}"]`);
234
- if (!row) return;
235
- const label = start === end ? `line ${start}` : `lines ${start}–${end}`;
502
+ const block = blockForLine(info.end);
503
+ if (!block) return;
504
+ const label = info.start === info.end ? `line ${info.start}` : `lines ${info.start}–${info.end}`;
236
505
  const box = document.createElement("div");
237
506
  box.className = "composer inline";
238
- box.innerHTML = `<textarea placeholder="Comment on ${label} shift-click another line number to extend…"></textarea>
239
- <button class="btn primary">Comment</button><button class="btn cancel">Cancel</button>`;
240
- row.after(box);
507
+ box.innerHTML = `${info.quote ? `<div class="cquote">${esc(info.quote)}</div>` : ""}
508
+ <div class="composer-row">
509
+ <textarea placeholder="Comment on ${label}…"></textarea>
510
+ <button class="btn primary">Comment</button><button class="btn cancel">Cancel</button>
511
+ </div>`;
512
+ block.after(box);
241
513
  const ta = box.querySelector("textarea");
242
514
  ta.focus();
243
515
  box.querySelector(".primary").onclick = async () => {
244
- await addComment(start, end, ta.value);
245
- clearSelection();
516
+ await addComment(info.start, info.end, ta.value, info.quote);
246
517
  };
247
- box.querySelector(".cancel").onclick = () => clearSelection();
518
+ box.querySelector(".cancel").onclick = () => box.remove();
248
519
  }
249
520
 
250
521
  // ---------- diff ----------
@@ -268,18 +539,60 @@ function diffLines(a, b) {
268
539
  return ops;
269
540
  }
270
541
 
542
+ async function manifestFor(n) {
543
+ if (state.manifestCache.has(n)) return state.manifestCache.get(n);
544
+ const v = await api(`/api/projects/${enc(state.key)}/versions/${n}`).catch(() => null);
545
+ const mf = v?.manifest || null;
546
+ state.manifestCache.set(n, mf);
547
+ return mf;
548
+ }
549
+
550
+ function versionSummary(n) {
551
+ return state.versions.find((v) => v.n === n);
552
+ }
553
+
554
+ // Build the doc <select> for the diff (union of both sides' slugs). Hidden when
555
+ // both versions are flat (no docCount>1) so flat-vs-flat looks exactly as today.
556
+ async function updateDiffDocSel() {
557
+ const from = versionSummary(state.baseVersion);
558
+ const to = versionSummary(state.version);
559
+ const treeDiff = (from?.docCount > 1) || (to?.docCount > 1);
560
+ const wrap = $("diffDocWrap");
561
+ wrap.hidden = !treeDiff;
562
+ if (!treeDiff) return;
563
+
564
+ const [mf, mt] = await Promise.all([manifestFor(state.baseVersion), manifestFor(state.version)]);
565
+ const slugs = [];
566
+ const seen = new Set();
567
+ for (const mm of [mf, mt])
568
+ for (const d of mm?.docs || [])
569
+ if (!seen.has(d.slug)) {
570
+ seen.add(d.slug);
571
+ slugs.push({ slug: d.slug, title: d.title });
572
+ }
573
+ const prev = $("diffDocSel").value;
574
+ $("diffDocSel").innerHTML = slugs.map((s) => `<option value="${esc(s.slug)}">${esc(s.title)}</option>`).join("");
575
+ const wantRoot = state.manifest?.root || "root";
576
+ if (slugs.some((s) => s.slug === prev)) $("diffDocSel").value = prev;
577
+ else if (slugs.some((s) => s.slug === wantRoot)) $("diffDocSel").value = wantRoot;
578
+ else if (slugs.length) $("diffDocSel").value = slugs[0].slug;
579
+ }
580
+
271
581
  async function loadDiff() {
272
582
  if (!state.baseVersion) {
273
583
  $("diff").innerHTML = "<div class='empty'>No earlier version to compare against.</div>";
274
584
  $("diffStat").textContent = "";
585
+ $("diffDocWrap").hidden = true;
275
586
  return;
276
587
  }
277
588
  state.baseVersion = Number($("baseSel").value);
278
- const d = await api(
279
- `/api/projects/${encodeURIComponent(state.key)}/diff?from=${state.baseVersion}&to=${state.version}`,
280
- );
281
- const a = d.from.markdown.split("\n");
282
- const b = d.to.markdown.split("\n");
589
+ await updateDiffDocSel();
590
+ const scoped = !$("diffDocWrap").hidden;
591
+ const doc = scoped ? $("diffDocSel").value || "root" : null;
592
+ const qs = `from=${state.baseVersion}&to=${state.version}${doc ? `&doc=${enc(doc)}` : ""}`;
593
+ const d = await api(`/api/projects/${enc(state.key)}/diff?${qs}`);
594
+ const a = (d.from.markdown || "").split("\n");
595
+ const b = (d.to.markdown || "").split("\n");
283
596
  const ops = diffLines(a, b);
284
597
  const adds = ops.filter((o) => o.t === "add").length;
285
598
  const dels = ops.filter((o) => o.t === "del").length;
@@ -381,6 +694,13 @@ async function resolve(decision) {
381
694
  function projectName() {
382
695
  return state.projects?.find((p) => p.key === state.key)?.name || "plan";
383
696
  }
697
+ function projNameByKey(key) {
698
+ return (
699
+ state.manageProjects?.find((p) => p.key === key)?.name ||
700
+ state.projects?.find((p) => p.key === key)?.name ||
701
+ key
702
+ );
703
+ }
384
704
 
385
705
  function refreshSaveUI() {
386
706
  const box = $("saveActions");
@@ -463,7 +783,7 @@ async function doSave() {
463
783
  btn.textContent = "Saving…";
464
784
  try {
465
785
  const r = await api(
466
- `/api/projects/${encodeURIComponent(state.key)}/versions/${state.version}/save`,
786
+ `/api/projects/${enc(state.key)}/versions/${state.version}/save`,
467
787
  {
468
788
  method: "POST",
469
789
  headers: { "content-type": "application/json" },
@@ -475,7 +795,6 @@ async function doSave() {
475
795
  refreshSaveUI();
476
796
  toast(`Saved v${state.version} to ${channel}.`);
477
797
  } catch (e) {
478
- // surface the remediation command if the channel handed one back
479
798
  renderChannelStatus({ ready: false, reason: e.message, fixCommand: e.fixCommand });
480
799
  } finally {
481
800
  btn.textContent = prev;
@@ -483,6 +802,169 @@ async function doSave() {
483
802
  }
484
803
  }
485
804
 
805
+ // ---------- deletion ----------
806
+ function showDeleteModal(title, desc) {
807
+ $("deleteTitle").textContent = title;
808
+ $("deleteDesc").textContent = desc;
809
+ $("deletePending").hidden = true;
810
+ $("deletePending").textContent = "";
811
+ $("deleteForce").hidden = true;
812
+ $("deleteConfirm").hidden = false;
813
+ $("deleteConfirm").disabled = false;
814
+ $("deleteForce").disabled = false;
815
+ $("deleteModal").hidden = false;
816
+ }
817
+
818
+ function openDeleteVersion() {
819
+ if (!state.version) return;
820
+ state.pendingDelete = { type: "version", key: state.key, n: state.version };
821
+ showDeleteModal(
822
+ `Delete v${state.version}?`,
823
+ `This permanently removes v${state.version} of “${projectName()}” and its comments. This can't be undone.`,
824
+ );
825
+ }
826
+
827
+ function showDeleteBlocked(e) {
828
+ const revs = e.pendingReviews || [];
829
+ const info = $("deletePending");
830
+ info.hidden = false;
831
+ const many = revs.length !== 1;
832
+ info.textContent =
833
+ `Blocked: ${revs.length || "a"} pending review${many ? "s" : ""} still awaiting a decision. ` +
834
+ `Force will auto-reject ${many ? "them" : "it"} (Claude is told the plan was deleted and to re-present or ask how to proceed), then delete.`;
835
+ $("deleteForce").hidden = false;
836
+ $("deleteConfirm").hidden = true;
837
+ }
838
+
839
+ // bulk-delete is always 200 with partial-success {deleted,blocked,meta}; render
840
+ // the blocked versions (+ their pending reviews) and offer a Force retry for
841
+ // just those. Single-version / project DELETE stay 409-on-block (showDeleteBlocked).
842
+ function showBulkBlocked(blocked, deleted) {
843
+ const info = $("deletePending");
844
+ info.hidden = false;
845
+ const ns = blocked.map((b) => b.n);
846
+ const totalPending = blocked.reduce((a, b) => a + (b.pendingReviews?.length || 0), 0);
847
+ const many = totalPending !== 1;
848
+ const delMsg = deleted.length ? `Deleted v${deleted.join(", v")}. ` : "";
849
+ info.textContent =
850
+ `${delMsg}Blocked: v${ns.join(", v")} — ${totalPending || "a"} pending review${many ? "s" : ""} ` +
851
+ `still awaiting a decision. Force will auto-reject ${many ? "them" : "it"} (Claude is told the plan ` +
852
+ `was deleted and to re-present or ask how to proceed) and delete the remaining ${ns.length} version${ns.length === 1 ? "" : "s"}.`;
853
+ $("deleteForce").hidden = false;
854
+ $("deleteForce").disabled = false;
855
+ $("deleteConfirm").hidden = true;
856
+ }
857
+
858
+ async function finishDelete(pd, msg) {
859
+ const wasManage = pd.fromManage;
860
+ $("deleteModal").hidden = true;
861
+ state.pendingDelete = null;
862
+ toast(msg);
863
+ await reloadAfterDelete();
864
+ if (wasManage && !$("manageModal").hidden) await renderManage();
865
+ }
866
+
867
+ async function confirmDelete(force) {
868
+ const pd = state.pendingDelete;
869
+ if (!pd) return;
870
+ const bconfirm = $("deleteConfirm");
871
+ const bforce = $("deleteForce");
872
+ bconfirm.disabled = true;
873
+ bforce.disabled = true;
874
+ const f = force ? "true" : "";
875
+ try {
876
+ if (pd.type === "bulk") {
877
+ const r = await api(`/api/projects/${enc(pd.key)}/bulk-delete`, {
878
+ method: "POST",
879
+ headers: { "content-type": "application/json" },
880
+ body: JSON.stringify({ versions: pd.ns, force }),
881
+ });
882
+ const blocked = r?.blocked || [];
883
+ const deleted = r?.deleted || [];
884
+ if (blocked.length) {
885
+ // partial success: some deleted, some blocked by pending reviews
886
+ if (deleted.length) pd.didDelete = true; // reflect deletions even if user cancels
887
+ pd.ns = blocked.map((b) => b.n); // Force retry targets only the blocked ones
888
+ showBulkBlocked(blocked, deleted);
889
+ return;
890
+ }
891
+ await finishDelete(pd, `Deleted ${deleted.length || pd.ns.length} version${(deleted.length || pd.ns.length) === 1 ? "" : "s"}.`);
892
+ return;
893
+ }
894
+
895
+ if (pd.type === "version") {
896
+ await api(`/api/projects/${enc(pd.key)}/versions/${pd.n}?force=${f}`, { method: "DELETE" });
897
+ await finishDelete(pd, `Deleted v${pd.n}.`);
898
+ } else if (pd.type === "project") {
899
+ await api(`/api/projects/${enc(pd.key)}?force=${f}`, { method: "DELETE" });
900
+ await finishDelete(pd, "Project deleted.");
901
+ }
902
+ } catch (e) {
903
+ if (e.status === 409) showDeleteBlocked(e); // version / project block
904
+ else {
905
+ toast("Delete failed: " + e.message);
906
+ bconfirm.disabled = false;
907
+ bforce.disabled = false;
908
+ }
909
+ }
910
+ }
911
+
912
+ // Delete flows must explicitly reload projects/versions — the pollLoop only
913
+ // refreshes review status.
914
+ async function reloadAfterDelete() {
915
+ await loadProjects();
916
+ if (!state.projects.length) {
917
+ showEmpty("No plans reviewed yet. Finish a plan in plan mode and it'll show up here.");
918
+ return;
919
+ }
920
+ const key = state.projects.some((p) => p.key === state.key) ? state.key : state.projects[0].key;
921
+ await selectProject(key);
922
+ }
923
+
924
+ // ---------- manage modal ----------
925
+ async function openManageModal() {
926
+ $("manageModal").hidden = false;
927
+ await renderManage();
928
+ }
929
+
930
+ async function renderManage() {
931
+ const list = $("manageList");
932
+ list.innerHTML = "<div class='empty'>Loading…</div>";
933
+ const projects = await api("/api/projects").catch(() => []);
934
+ const detailed = await Promise.all(
935
+ projects.map(async (p) => {
936
+ const r = await api(`/api/projects/${enc(p.key)}`).catch(() => ({ versions: [] }));
937
+ return { ...p, versions: r.versions || [] };
938
+ }),
939
+ );
940
+ state.manageProjects = detailed;
941
+ list.innerHTML = detailed.length
942
+ ? detailed.map(manageProjectHTML).join("")
943
+ : "<div class='empty'>No projects.</div>";
944
+ }
945
+
946
+ function manageProjectHTML(p) {
947
+ const vers = (p.versions || [])
948
+ .map(
949
+ (v) => `<label class="mg-ver">
950
+ <input type="checkbox" data-key="${esc(p.key)}" data-n="${v.n}" />
951
+ <span class="mg-vn">v${v.n}</span>
952
+ <span class="mg-meta">${esc(new Date(v.createdAt).toLocaleString())}${v.docCount > 1 ? ` · ${v.docCount} docs` : ""}${v.kind === "tree" ? " · tree" : ""}</span>
953
+ </label>`,
954
+ )
955
+ .join("");
956
+ return `<div class="mg-project" data-key="${esc(p.key)}">
957
+ <div class="mg-phead">
958
+ <span class="mg-name">${esc(p.name)}${p.pending ? ` <span class="sb-dot" title="${p.pending} pending"></span>` : ""}</span>
959
+ <span class="mg-actions">
960
+ <button class="btn sm" data-mg="delsel" data-key="${esc(p.key)}">Delete selected</button>
961
+ <button class="btn reject sm" data-mg="delproj" data-key="${esc(p.key)}">Delete project</button>
962
+ </span>
963
+ </div>
964
+ <div class="mg-vers">${vers || "<span class='mg-meta'>no versions</span>"}</div>
965
+ </div>`;
966
+ }
967
+
486
968
  // ---------- polling (reflect external resolution) ----------
487
969
  function pollLoop() {
488
970
  setInterval(async () => {
@@ -492,6 +974,7 @@ function pollLoop() {
492
974
  if (r.status !== state.review.status) {
493
975
  state.review = r;
494
976
  refreshReview();
977
+ renderSidebar(); // pending dot depends on review status
495
978
  }
496
979
  } catch {}
497
980
  }
@@ -520,11 +1003,9 @@ function wireUI() {
520
1003
  };
521
1004
 
522
1005
  $("projectSel").onchange = (e) => selectProject(e.target.value);
523
- $("versionSel").onchange = (e) => {
524
- state.reviewId && (state.reviewId = state.reviewId); // keep
525
- loadVersion(Number(e.target.value));
526
- };
1006
+ $("versionSel").onchange = (e) => loadVersion(Number(e.target.value));
527
1007
  $("baseSel").onchange = () => loadDiff();
1008
+ $("diffDocSel").onchange = () => loadDiff();
528
1009
 
529
1010
  document.querySelectorAll(".tab").forEach((t) => {
530
1011
  t.onclick = () => {
@@ -533,6 +1014,7 @@ function wireUI() {
533
1014
  t.classList.add("active");
534
1015
  state.tab = t.dataset.tab;
535
1016
  $("panel-" + state.tab).classList.add("active");
1017
+ hideSelPop();
536
1018
  if (state.tab === "diff") loadDiff();
537
1019
  };
538
1020
  });
@@ -546,45 +1028,61 @@ function wireUI() {
546
1028
  };
547
1029
  });
548
1030
 
549
- // source gutter clicks + comment deletes (delegated)
550
- const source = $("source");
551
- // press a line number and drag to select a range; shift-click also extends
552
- source.addEventListener("mousedown", (e) => {
553
- const g = e.target.closest("[data-add]");
554
- if (!g) return;
555
- e.preventDefault(); // suppress native text selection while dragging
556
- const n = Number(g.dataset.add);
557
- const sel = state.sel;
558
- if (e.shiftKey && sel.anchor != null) {
559
- sel.start = Math.min(sel.anchor, n);
560
- sel.end = Math.max(sel.anchor, n);
561
- } else {
562
- sel.anchor = sel.start = sel.end = n;
1031
+ // view-as-tree (single-doc, display-only)
1032
+ $("treeToggle").onclick = () => {
1033
+ state.viewAsTree = !state.viewAsTree;
1034
+ state.treeSection = -1;
1035
+ updateTreeToggle();
1036
+ renderPreview();
1037
+ renderSidebar();
1038
+ };
1039
+
1040
+ // sidebar navigation — doc rows (tree) or outline sections (view-as-tree)
1041
+ $("docSidebar").addEventListener("click", (e) => {
1042
+ const row = e.target.closest("[data-doc]");
1043
+ if (row) {
1044
+ e.preventDefault();
1045
+ if (row.dataset.doc !== state.doc) loadDoc(row.dataset.doc);
1046
+ return;
1047
+ }
1048
+ const sec = e.target.closest("[data-sec]");
1049
+ if (sec) {
1050
+ e.preventDefault();
1051
+ state.treeSection = Number(sec.dataset.sec);
1052
+ applyTreeView();
1053
+ renderSidebar();
563
1054
  }
564
- state.dragging = true;
565
- source.classList.add("dragging");
566
- document.querySelector(".composer.inline")?.remove();
567
- paintSelection();
568
- });
569
- source.addEventListener("mousemove", (e) => {
570
- if (!state.dragging) return;
571
- const row = e.target.closest(".srow");
572
- if (!row) return;
573
- const n = Number(row.dataset.line);
574
- state.sel.start = Math.min(state.sel.anchor, n);
575
- state.sel.end = Math.max(state.sel.anchor, n);
576
- paintSelection();
577
1055
  });
578
- document.addEventListener("mouseup", () => {
579
- if (!state.dragging) return;
580
- state.dragging = false;
581
- source.classList.remove("dragging");
582
- openComposer(state.sel.start, state.sel.end);
1056
+
1057
+ // comment on rendered text: after a selection in the preview, offer a "Comment" button
1058
+ document.addEventListener("mouseup", (e) => {
1059
+ if ($("selPop").contains(e.target)) return; // the button handles its own click
1060
+ const info = currentSelection();
1061
+ if (info) showSelPop(info);
1062
+ else hideSelPop();
583
1063
  });
584
- source.onclick = (e) => {
585
- const del = e.target.closest("[data-del]");
586
- if (del) return delComment(del.dataset.del);
1064
+ window.addEventListener("scroll", hideSelPop, true);
1065
+ $("selComment").onclick = () => {
1066
+ const info = state.pendingSel;
1067
+ hideSelPop();
1068
+ window.getSelection().removeAllRanges();
1069
+ if (info) openComposer(info);
587
1070
  };
1071
+
1072
+ // preview clicks: cross-doc links navigate in-app; delete buttons remove comments
1073
+ $("preview").addEventListener("click", (e) => {
1074
+ const link = e.target.closest("a[data-doc]");
1075
+ if (link) {
1076
+ e.preventDefault();
1077
+ const slug = link.dataset.doc;
1078
+ if (state.docCache.has(slug) || (state.manifest && state.manifest.docs.some((d) => d.slug === slug))) {
1079
+ loadDoc(slug);
1080
+ }
1081
+ return;
1082
+ }
1083
+ const del = e.target.closest("[data-del]");
1084
+ if (del) delComment(del.dataset.del);
1085
+ });
588
1086
  $("generalList").onclick = (e) => {
589
1087
  const del = e.target.closest("[data-del]");
590
1088
  if (del) delComment(del.dataset.del);
@@ -610,6 +1108,49 @@ function wireUI() {
610
1108
  $("rejectModal").hidden = true;
611
1109
  await resolve("reject");
612
1110
  };
1111
+
1112
+ // deletion + manage
1113
+ $("deleteVerBtn").onclick = () => openDeleteVersion();
1114
+ $("deleteCancel").onclick = async () => {
1115
+ const pd = state.pendingDelete;
1116
+ $("deleteModal").hidden = true;
1117
+ state.pendingDelete = null;
1118
+ // a partial bulk delete already removed some versions — reflect that even on cancel
1119
+ if (pd && pd.didDelete) {
1120
+ await reloadAfterDelete();
1121
+ if (!$("manageModal").hidden) await renderManage();
1122
+ }
1123
+ };
1124
+ $("deleteConfirm").onclick = () => confirmDelete(false);
1125
+ $("deleteForce").onclick = () => confirmDelete(true);
1126
+
1127
+ $("manageBtn").onclick = () => openManageModal();
1128
+ $("manageClose").onclick = () => ($("manageModal").hidden = true);
1129
+ $("manageList").addEventListener("click", (e) => {
1130
+ const btn = e.target.closest("[data-mg]");
1131
+ if (!btn) return;
1132
+ const key = btn.dataset.key;
1133
+ if (btn.dataset.mg === "delproj") {
1134
+ state.pendingDelete = { type: "project", key, fromManage: true };
1135
+ showDeleteModal(
1136
+ `Delete project “${projNameByKey(key)}”?`,
1137
+ `Permanently removes the project and all its versions, comments and reviews. This can't be undone.`,
1138
+ );
1139
+ } else if (btn.dataset.mg === "delsel") {
1140
+ const ns = [...$("manageList").querySelectorAll('input[type="checkbox"]:checked')]
1141
+ .filter((x) => x.dataset.key === key)
1142
+ .map((x) => Number(x.dataset.n));
1143
+ if (!ns.length) {
1144
+ toast("Select at least one version to delete.");
1145
+ return;
1146
+ }
1147
+ state.pendingDelete = { type: "bulk", key, ns, fromManage: true };
1148
+ showDeleteModal(
1149
+ `Delete ${ns.length} version${ns.length === 1 ? "" : "s"} of “${projNameByKey(key)}”?`,
1150
+ `Permanently removes v${ns.join(", v")} and their comments. This can't be undone.`,
1151
+ );
1152
+ }
1153
+ });
613
1154
  }
614
1155
 
615
1156
  init().catch((e) => {