claude-plan-review 0.3.2 → 0.5.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;
493
+ }
494
+
495
+ function hideSelPop() {
496
+ $("selPop").hidden = true;
497
+ state.pendingSel = null;
229
498
  }
230
499
 
231
- function openComposer(start, end) {
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,166 @@ async function doSave() {
483
802
  }
484
803
  }
485
804
 
805
+ // ---------- deletion ----------
806
+ // Deletes happen IMMEDIATELY on click — no confirmation modal. The modal appears
807
+ // ONLY when a delete is blocked by a pending review (409 for single/project, or a
808
+ // non-empty `blocked` array for bulk): it shows the blocked info + a Force option.
809
+ function deleteTitleFor(pd) {
810
+ if (pd.type === "version") return `Couldn't delete v${pd.n}`;
811
+ if (pd.type === "project") return `Couldn't delete “${projNameByKey(pd.key)}”`;
812
+ return "Couldn't delete every selected version";
813
+ }
814
+
815
+ // Open the blocked/Force modal (the only remaining modal interaction).
816
+ function openBlockedModal(pd) {
817
+ $("deleteTitle").textContent = deleteTitleFor(pd);
818
+ $("deleteDesc").textContent = "";
819
+ $("deleteForce").hidden = false;
820
+ $("deleteForce").disabled = false;
821
+ $("deleteModal").hidden = false;
822
+ }
823
+
824
+ function deleteVersionNow() {
825
+ if (!state.version) return;
826
+ state.pendingDelete = { type: "version", key: state.key, n: state.version };
827
+ performDelete(false);
828
+ }
829
+
830
+ function showDeleteBlocked(e) {
831
+ openBlockedModal(state.pendingDelete);
832
+ const revs = e.pendingReviews || [];
833
+ const info = $("deletePending");
834
+ info.hidden = false;
835
+ const many = revs.length !== 1;
836
+ info.textContent =
837
+ `Blocked: ${revs.length || "a"} pending review${many ? "s" : ""} still awaiting a decision. ` +
838
+ `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.`;
839
+ }
840
+
841
+ // bulk-delete is always 200 with partial-success {deleted,blocked,meta}; render
842
+ // the blocked versions (+ their pending reviews) and offer a Force retry for
843
+ // just those. Single-version / project DELETE stay 409-on-block (showDeleteBlocked).
844
+ function showBulkBlocked(blocked, deleted) {
845
+ openBlockedModal(state.pendingDelete);
846
+ const info = $("deletePending");
847
+ info.hidden = false;
848
+ const ns = blocked.map((b) => b.n);
849
+ const totalPending = blocked.reduce((a, b) => a + (b.pendingReviews?.length || 0), 0);
850
+ const many = totalPending !== 1;
851
+ const delMsg = deleted.length ? `Deleted v${deleted.join(", v")}. ` : "";
852
+ info.textContent =
853
+ `${delMsg}Blocked: v${ns.join(", v")} — ${totalPending || "a"} pending review${many ? "s" : ""} ` +
854
+ `still awaiting a decision. Force will auto-reject ${many ? "them" : "it"} (Claude is told the plan ` +
855
+ `was deleted and to re-present or ask how to proceed) and delete the remaining ${ns.length} version${ns.length === 1 ? "" : "s"}.`;
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 performDelete(force) {
868
+ const pd = state.pendingDelete;
869
+ if (!pd) return;
870
+ const bforce = $("deleteForce");
871
+ bforce.disabled = true;
872
+ const f = force ? "true" : "";
873
+ try {
874
+ if (pd.type === "bulk") {
875
+ const r = await api(`/api/projects/${enc(pd.key)}/bulk-delete`, {
876
+ method: "POST",
877
+ headers: { "content-type": "application/json" },
878
+ body: JSON.stringify({ versions: pd.ns, force }),
879
+ });
880
+ const blocked = r?.blocked || [];
881
+ const deleted = r?.deleted || [];
882
+ if (blocked.length) {
883
+ // partial success: some deleted, some blocked by pending reviews
884
+ if (deleted.length) pd.didDelete = true; // reflect deletions even if user cancels
885
+ pd.ns = blocked.map((b) => b.n); // Force retry targets only the blocked ones
886
+ showBulkBlocked(blocked, deleted);
887
+ return;
888
+ }
889
+ await finishDelete(pd, `Deleted ${deleted.length || pd.ns.length} version${(deleted.length || pd.ns.length) === 1 ? "" : "s"}.`);
890
+ return;
891
+ }
892
+
893
+ if (pd.type === "version") {
894
+ await api(`/api/projects/${enc(pd.key)}/versions/${pd.n}?force=${f}`, { method: "DELETE" });
895
+ await finishDelete(pd, `Deleted v${pd.n}.`);
896
+ } else if (pd.type === "project") {
897
+ await api(`/api/projects/${enc(pd.key)}?force=${f}`, { method: "DELETE" });
898
+ await finishDelete(pd, "Project deleted.");
899
+ }
900
+ } catch (e) {
901
+ if (e.status === 409) showDeleteBlocked(e); // version / project block
902
+ else {
903
+ toast("Delete failed: " + e.message);
904
+ bforce.disabled = false;
905
+ }
906
+ }
907
+ }
908
+
909
+ // Delete flows must explicitly reload projects/versions — the pollLoop only
910
+ // refreshes review status.
911
+ async function reloadAfterDelete() {
912
+ await loadProjects();
913
+ if (!state.projects.length) {
914
+ showEmpty("No plans reviewed yet. Finish a plan in plan mode and it'll show up here.");
915
+ return;
916
+ }
917
+ const key = state.projects.some((p) => p.key === state.key) ? state.key : state.projects[0].key;
918
+ await selectProject(key);
919
+ }
920
+
921
+ // ---------- manage modal ----------
922
+ async function openManageModal() {
923
+ $("manageModal").hidden = false;
924
+ await renderManage();
925
+ }
926
+
927
+ async function renderManage() {
928
+ const list = $("manageList");
929
+ list.innerHTML = "<div class='empty'>Loading…</div>";
930
+ const projects = await api("/api/projects").catch(() => []);
931
+ const detailed = await Promise.all(
932
+ projects.map(async (p) => {
933
+ const r = await api(`/api/projects/${enc(p.key)}`).catch(() => ({ versions: [] }));
934
+ return { ...p, versions: r.versions || [] };
935
+ }),
936
+ );
937
+ state.manageProjects = detailed;
938
+ list.innerHTML = detailed.length
939
+ ? detailed.map(manageProjectHTML).join("")
940
+ : "<div class='empty'>No projects.</div>";
941
+ }
942
+
943
+ function manageProjectHTML(p) {
944
+ const vers = (p.versions || [])
945
+ .map(
946
+ (v) => `<label class="mg-ver">
947
+ <input type="checkbox" data-key="${esc(p.key)}" data-n="${v.n}" />
948
+ <span class="mg-vn">v${v.n}</span>
949
+ <span class="mg-meta">${esc(new Date(v.createdAt).toLocaleString())}${v.docCount > 1 ? ` · ${v.docCount} docs` : ""}${v.kind === "tree" ? " · tree" : ""}</span>
950
+ </label>`,
951
+ )
952
+ .join("");
953
+ return `<div class="mg-project" data-key="${esc(p.key)}">
954
+ <div class="mg-phead">
955
+ <span class="mg-name">${esc(p.name)}${p.pending ? ` <span class="sb-dot" title="${p.pending} pending"></span>` : ""}</span>
956
+ <span class="mg-actions">
957
+ <button class="btn sm" data-mg="delsel" data-key="${esc(p.key)}">Delete selected</button>
958
+ <button class="btn reject sm" data-mg="delproj" data-key="${esc(p.key)}">Delete project</button>
959
+ </span>
960
+ </div>
961
+ <div class="mg-vers">${vers || "<span class='mg-meta'>no versions</span>"}</div>
962
+ </div>`;
963
+ }
964
+
486
965
  // ---------- polling (reflect external resolution) ----------
487
966
  function pollLoop() {
488
967
  setInterval(async () => {
@@ -492,6 +971,7 @@ function pollLoop() {
492
971
  if (r.status !== state.review.status) {
493
972
  state.review = r;
494
973
  refreshReview();
974
+ renderSidebar(); // pending dot depends on review status
495
975
  }
496
976
  } catch {}
497
977
  }
@@ -520,11 +1000,9 @@ function wireUI() {
520
1000
  };
521
1001
 
522
1002
  $("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
- };
1003
+ $("versionSel").onchange = (e) => loadVersion(Number(e.target.value));
527
1004
  $("baseSel").onchange = () => loadDiff();
1005
+ $("diffDocSel").onchange = () => loadDiff();
528
1006
 
529
1007
  document.querySelectorAll(".tab").forEach((t) => {
530
1008
  t.onclick = () => {
@@ -533,6 +1011,7 @@ function wireUI() {
533
1011
  t.classList.add("active");
534
1012
  state.tab = t.dataset.tab;
535
1013
  $("panel-" + state.tab).classList.add("active");
1014
+ hideSelPop();
536
1015
  if (state.tab === "diff") loadDiff();
537
1016
  };
538
1017
  });
@@ -546,45 +1025,61 @@ function wireUI() {
546
1025
  };
547
1026
  });
548
1027
 
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;
1028
+ // view-as-tree (single-doc, display-only)
1029
+ $("treeToggle").onclick = () => {
1030
+ state.viewAsTree = !state.viewAsTree;
1031
+ state.treeSection = -1;
1032
+ updateTreeToggle();
1033
+ renderPreview();
1034
+ renderSidebar();
1035
+ };
1036
+
1037
+ // sidebar navigation — doc rows (tree) or outline sections (view-as-tree)
1038
+ $("docSidebar").addEventListener("click", (e) => {
1039
+ const row = e.target.closest("[data-doc]");
1040
+ if (row) {
1041
+ e.preventDefault();
1042
+ if (row.dataset.doc !== state.doc) loadDoc(row.dataset.doc);
1043
+ return;
1044
+ }
1045
+ const sec = e.target.closest("[data-sec]");
1046
+ if (sec) {
1047
+ e.preventDefault();
1048
+ state.treeSection = Number(sec.dataset.sec);
1049
+ applyTreeView();
1050
+ renderSidebar();
563
1051
  }
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
1052
  });
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);
1053
+
1054
+ // comment on rendered text: after a selection in the preview, offer a "Comment" button
1055
+ document.addEventListener("mouseup", (e) => {
1056
+ if ($("selPop").contains(e.target)) return; // the button handles its own click
1057
+ const info = currentSelection();
1058
+ if (info) showSelPop(info);
1059
+ else hideSelPop();
583
1060
  });
584
- source.onclick = (e) => {
585
- const del = e.target.closest("[data-del]");
586
- if (del) return delComment(del.dataset.del);
1061
+ window.addEventListener("scroll", hideSelPop, true);
1062
+ $("selComment").onclick = () => {
1063
+ const info = state.pendingSel;
1064
+ hideSelPop();
1065
+ window.getSelection().removeAllRanges();
1066
+ if (info) openComposer(info);
587
1067
  };
1068
+
1069
+ // preview clicks: cross-doc links navigate in-app; delete buttons remove comments
1070
+ $("preview").addEventListener("click", (e) => {
1071
+ const link = e.target.closest("a[data-doc]");
1072
+ if (link) {
1073
+ e.preventDefault();
1074
+ const slug = link.dataset.doc;
1075
+ if (state.docCache.has(slug) || (state.manifest && state.manifest.docs.some((d) => d.slug === slug))) {
1076
+ loadDoc(slug);
1077
+ }
1078
+ return;
1079
+ }
1080
+ const del = e.target.closest("[data-del]");
1081
+ if (del) delComment(del.dataset.del);
1082
+ });
588
1083
  $("generalList").onclick = (e) => {
589
1084
  const del = e.target.closest("[data-del]");
590
1085
  if (del) delComment(del.dataset.del);
@@ -610,6 +1105,42 @@ function wireUI() {
610
1105
  $("rejectModal").hidden = true;
611
1106
  await resolve("reject");
612
1107
  };
1108
+
1109
+ // deletion + manage — delete fires immediately; the modal is blocked-only
1110
+ $("deleteVerBtn").onclick = () => deleteVersionNow();
1111
+ $("deleteCancel").onclick = async () => {
1112
+ const pd = state.pendingDelete;
1113
+ $("deleteModal").hidden = true;
1114
+ state.pendingDelete = null;
1115
+ // a partial bulk delete already removed some versions — reflect that even on cancel
1116
+ if (pd && pd.didDelete) {
1117
+ await reloadAfterDelete();
1118
+ if (!$("manageModal").hidden) await renderManage();
1119
+ }
1120
+ };
1121
+ $("deleteForce").onclick = () => performDelete(true);
1122
+
1123
+ $("manageBtn").onclick = () => openManageModal();
1124
+ $("manageClose").onclick = () => ($("manageModal").hidden = true);
1125
+ $("manageList").addEventListener("click", (e) => {
1126
+ const btn = e.target.closest("[data-mg]");
1127
+ if (!btn) return;
1128
+ const key = btn.dataset.key;
1129
+ if (btn.dataset.mg === "delproj") {
1130
+ state.pendingDelete = { type: "project", key, fromManage: true };
1131
+ performDelete(false);
1132
+ } else if (btn.dataset.mg === "delsel") {
1133
+ const ns = [...$("manageList").querySelectorAll('input[type="checkbox"]:checked')]
1134
+ .filter((x) => x.dataset.key === key)
1135
+ .map((x) => Number(x.dataset.n));
1136
+ if (!ns.length) {
1137
+ toast("Select at least one version to delete.");
1138
+ return;
1139
+ }
1140
+ state.pendingDelete = { type: "bulk", key, ns, fromManage: true };
1141
+ performDelete(false);
1142
+ }
1143
+ });
613
1144
  }
614
1145
 
615
1146
  init().catch((e) => {