claude-plan-review 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/src/ui/app.js ADDED
@@ -0,0 +1,486 @@
1
+ "use strict";
2
+
3
+ const $ = (id) => document.getElementById(id);
4
+ const esc = (s) =>
5
+ String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
6
+
7
+ async function api(path, opts) {
8
+ const res = await fetch(path, opts);
9
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.statusText);
10
+ return res.status === 204 ? null : res.json();
11
+ }
12
+
13
+ const state = {
14
+ key: null,
15
+ version: null,
16
+ baseVersion: null,
17
+ reviewId: null,
18
+ review: null,
19
+ versions: [],
20
+ data: null, // { markdown, html, meta }
21
+ comments: [],
22
+ tab: "preview",
23
+ diffMode: "split",
24
+ sel: { anchor: null, start: null, end: null }, // source line-range selection
25
+ dragging: false,
26
+ };
27
+
28
+ function toast(msg) {
29
+ const t = $("toast");
30
+ t.textContent = msg;
31
+ t.hidden = false;
32
+ clearTimeout(toast._t);
33
+ toast._t = setTimeout(() => (t.hidden = true), 2600);
34
+ }
35
+
36
+ // ---------- bootstrap ----------
37
+ async function init() {
38
+ const q = new URLSearchParams(location.search);
39
+ state.key = q.get("project");
40
+ state.reviewId = q.get("review");
41
+ const wantV = q.get("version");
42
+
43
+ wireUI();
44
+ api("/api/version")
45
+ .then((v) => ($("ver").textContent = "v" + v.version))
46
+ .catch(() => {});
47
+ await loadProjects();
48
+ if (!state.projects.length) {
49
+ showEmpty("No plans reviewed yet. Finish a plan in plan mode and it'll show up here.");
50
+ pollLoop();
51
+ return;
52
+ }
53
+ if (!state.key || !state.projects.some((p) => p.key === state.key)) state.key = state.projects[0].key;
54
+ await selectProject(state.key, wantV ? Number(wantV) : null);
55
+ pollLoop();
56
+ }
57
+
58
+ function showEmpty(msg) {
59
+ document.querySelectorAll(".panel").forEach((p) => p.classList.remove("active"));
60
+ $("panel-preview").classList.add("active");
61
+ $("preview").innerHTML = `<div class="empty">${esc(msg)}</div>`;
62
+ $("reviewActions").hidden = true;
63
+ }
64
+
65
+ async function loadProjects() {
66
+ const projects = await api("/api/projects");
67
+ state.projects = projects;
68
+ const sel = $("projectSel");
69
+ sel.innerHTML = projects
70
+ .map(
71
+ (p) =>
72
+ `<option value="${esc(p.key)}">${esc(p.name)}${p.pending ? ` (${p.pending} pending)` : ""}</option>`,
73
+ )
74
+ .join("");
75
+ if (state.key) sel.value = state.key;
76
+ }
77
+
78
+ async function selectProject(key, wantV) {
79
+ state.key = key;
80
+ $("projectSel").value = key;
81
+ const { versions } = await api(`/api/projects/${encodeURIComponent(key)}`);
82
+ state.versions = versions;
83
+ if (!versions.length) {
84
+ showEmpty("This project has no plan versions yet.");
85
+ return;
86
+ }
87
+ const latest = versions.length ? versions[versions.length - 1].n : null;
88
+ state.version = wantV || latest;
89
+
90
+ const fmt = (v) => `v${v.n} · ${new Date(v.createdAt).toLocaleString()}${v.n === latest ? " (current)" : ""}`;
91
+ $("versionSel").innerHTML = versions.map((v) => `<option value="${v.n}">${esc(fmt(v))}</option>`).join("");
92
+ $("versionSel").value = String(state.version);
93
+
94
+ // base = the version right before current, if any
95
+ state.baseVersion = versions.length > 1 ? versions[versions.findIndex((v) => v.n === state.version) - 1]?.n ?? versions[0].n : null;
96
+ $("baseSel").innerHTML = versions
97
+ .filter((v) => v.n !== state.version)
98
+ .map((v) => `<option value="${v.n}">${esc(fmt(v))}</option>`)
99
+ .join("");
100
+ if (state.baseVersion) $("baseSel").value = String(state.baseVersion);
101
+
102
+ await loadVersion(state.version);
103
+ }
104
+
105
+ async function loadVersion(n) {
106
+ state.version = n;
107
+ state.data = await api(`/api/projects/${encodeURIComponent(state.key)}/versions/${n}`);
108
+ state.comments = await api(`/api/projects/${encodeURIComponent(state.key)}/versions/${n}/comments`);
109
+ $("diffCur").textContent = n;
110
+ renderPreview();
111
+ renderSource();
112
+ renderGeneral();
113
+ updateCommentCount();
114
+ await refreshReview();
115
+ if (state.tab === "diff") loadDiff();
116
+ }
117
+
118
+ // ---------- rendering ----------
119
+ function renderPreview() {
120
+ $("preview").innerHTML = state.data.html || "<p class='empty'>(empty plan)</p>";
121
+ }
122
+
123
+ // threads are anchored under the END line of their range; covered = every line a comment spans
124
+ function commentLayout() {
125
+ const byAnchor = new Map();
126
+ const covered = new Set();
127
+ for (const c of state.comments) {
128
+ if (c.line == null) continue;
129
+ const s = c.line;
130
+ const e = c.lineEnd ?? c.line;
131
+ const anchor = Math.max(s, e);
132
+ if (!byAnchor.has(anchor)) byAnchor.set(anchor, []);
133
+ byAnchor.get(anchor).push(c);
134
+ for (let k = s; k <= e; k++) covered.add(k);
135
+ }
136
+ return { byAnchor, covered };
137
+ }
138
+
139
+ function locLabel(c) {
140
+ if (c.line == null) return "";
141
+ const e = c.lineEnd ?? c.line;
142
+ return e !== c.line ? `lines ${c.line}–${e}` : `line ${c.line}`;
143
+ }
144
+
145
+ function commentHTML(c) {
146
+ const loc = locLabel(c);
147
+ return `<div class="comment" data-id="${c.id}">
148
+ <div class="chead"><span><b>${esc(c.author)}</b>${loc ? ` · <span class="loc">${loc}</span>` : ""} · ${new Date(c.createdAt).toLocaleString()}</span>
149
+ <button class="del" data-del="${c.id}">delete</button></div>
150
+ <div class="cbody">${esc(c.body)}</div></div>`;
151
+ }
152
+
153
+ function renderSource() {
154
+ const lines = (state.data.markdown || "").split("\n");
155
+ const { byAnchor, covered } = commentLayout();
156
+ const { start, end } = state.sel;
157
+ const inSel = (n) => start != null && n >= start && n <= end;
158
+ let html = "";
159
+ for (let i = 0; i < lines.length; i++) {
160
+ const num = i + 1;
161
+ const threads = byAnchor.get(num) || [];
162
+ const cls = [covered.has(num) ? "has-comments" : "", inSel(num) ? "sel" : ""].filter(Boolean).join(" ");
163
+ html += `<div class="srow ${cls}" data-line="${num}">
164
+ <div class="sgutter" data-add="${num}">${num}</div>
165
+ <div class="scode">${esc(lines[i]) || "&nbsp;"}</div></div>`;
166
+ if (threads.length) html += `<div class="threads">${threads.map(commentHTML).join("")}</div>`;
167
+ }
168
+ $("source").innerHTML = html;
169
+ }
170
+
171
+ function renderGeneral() {
172
+ const gen = state.comments.filter((c) => c.line == null);
173
+ $("generalList").innerHTML = gen.length ? gen.map(commentHTML).join("") : "";
174
+ }
175
+
176
+ function updateCommentCount() {
177
+ const n = state.comments.length;
178
+ $("cCount").textContent = n ? n : "";
179
+ }
180
+
181
+ // ---------- comments ----------
182
+ async function addComment(line, lineEnd, body) {
183
+ if (!body.trim()) return;
184
+ await api(`/api/projects/${encodeURIComponent(state.key)}/versions/${state.version}/comments`, {
185
+ method: "POST",
186
+ headers: { "content-type": "application/json" },
187
+ body: JSON.stringify({ line, lineEnd, body }),
188
+ });
189
+ state.comments = await api(`/api/projects/${encodeURIComponent(state.key)}/versions/${state.version}/comments`);
190
+ renderSource();
191
+ renderGeneral();
192
+ updateCommentCount();
193
+ }
194
+
195
+ async function delComment(id) {
196
+ await api(`/api/projects/${encodeURIComponent(state.key)}/versions/${state.version}/comments/${id}`, {
197
+ method: "DELETE",
198
+ });
199
+ state.comments = state.comments.filter((c) => c.id !== id);
200
+ renderSource();
201
+ renderGeneral();
202
+ updateCommentCount();
203
+ }
204
+
205
+ // lightweight selection repaint — toggles a class on existing rows so we don't
206
+ // rebuild the DOM mid-drag (which would break pointer tracking)
207
+ function paintSelection() {
208
+ const { start, end } = state.sel;
209
+ document.querySelectorAll("#source .srow").forEach((row) => {
210
+ const n = Number(row.dataset.line);
211
+ row.classList.toggle("sel", start != null && n >= start && n <= end);
212
+ });
213
+ }
214
+
215
+ function clearSelection() {
216
+ state.sel = { anchor: null, start: null, end: null };
217
+ document.querySelector(".composer.inline")?.remove();
218
+ renderSource();
219
+ }
220
+
221
+ function openComposer(start, end) {
222
+ document.querySelector(".composer.inline")?.remove();
223
+ const row = document.querySelector(`.srow[data-line="${end}"]`);
224
+ if (!row) return;
225
+ const label = start === end ? `line ${start}` : `lines ${start}–${end}`;
226
+ const box = document.createElement("div");
227
+ box.className = "composer inline";
228
+ box.innerHTML = `<textarea placeholder="Comment on ${label} — shift-click another line number to extend…"></textarea>
229
+ <button class="btn primary">Comment</button><button class="btn cancel">Cancel</button>`;
230
+ row.after(box);
231
+ const ta = box.querySelector("textarea");
232
+ ta.focus();
233
+ box.querySelector(".primary").onclick = async () => {
234
+ await addComment(start, end, ta.value);
235
+ clearSelection();
236
+ };
237
+ box.querySelector(".cancel").onclick = () => clearSelection();
238
+ }
239
+
240
+ // ---------- diff ----------
241
+ function diffLines(a, b) {
242
+ const n = a.length,
243
+ m = b.length;
244
+ const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
245
+ for (let i = n - 1; i >= 0; i--)
246
+ for (let j = m - 1; j >= 0; j--)
247
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
248
+ const ops = [];
249
+ let i = 0,
250
+ j = 0;
251
+ while (i < n && j < m) {
252
+ if (a[i] === b[j]) ops.push({ t: "eq", a: i, b: j }), i++, j++;
253
+ else if (dp[i + 1][j] >= dp[i][j + 1]) ops.push({ t: "del", a: i }), i++;
254
+ else ops.push({ t: "add", b: j }), j++;
255
+ }
256
+ while (i < n) ops.push({ t: "del", a: i }), i++;
257
+ while (j < m) ops.push({ t: "add", b: j }), j++;
258
+ return ops;
259
+ }
260
+
261
+ async function loadDiff() {
262
+ if (!state.baseVersion) {
263
+ $("diff").innerHTML = "<div class='empty'>No earlier version to compare against.</div>";
264
+ $("diffStat").textContent = "";
265
+ return;
266
+ }
267
+ state.baseVersion = Number($("baseSel").value);
268
+ const d = await api(
269
+ `/api/projects/${encodeURIComponent(state.key)}/diff?from=${state.baseVersion}&to=${state.version}`,
270
+ );
271
+ const a = d.from.markdown.split("\n");
272
+ const b = d.to.markdown.split("\n");
273
+ const ops = diffLines(a, b);
274
+ const adds = ops.filter((o) => o.t === "add").length;
275
+ const dels = ops.filter((o) => o.t === "del").length;
276
+ $("diffStat").innerHTML = `<span class="add">+${adds}</span> <span class="del">−${dels}</span>`;
277
+ $("diff").innerHTML = state.diffMode === "split" ? renderSplit(ops, a, b) : renderUnified(ops, a, b);
278
+ }
279
+
280
+ function renderUnified(ops, a, b) {
281
+ const rows = ops
282
+ .map((o) => {
283
+ if (o.t === "eq")
284
+ return `<tr><td class="ln">${o.a + 1}</td><td class="ln">${o.b + 1}</td><td class="sign"> </td><td class="code">${esc(a[o.a]) || "&nbsp;"}</td></tr>`;
285
+ if (o.t === "del")
286
+ return `<tr class="del"><td class="ln">${o.a + 1}</td><td class="ln"></td><td class="sign">−</td><td class="code">${esc(a[o.a]) || "&nbsp;"}</td></tr>`;
287
+ return `<tr class="add"><td class="ln"></td><td class="ln">${o.b + 1}</td><td class="sign">+</td><td class="code">${esc(b[o.b]) || "&nbsp;"}</td></tr>`;
288
+ })
289
+ .join("");
290
+ return `<table class="difftable">${rows}</table>`;
291
+ }
292
+
293
+ function renderSplit(ops, a, b) {
294
+ const rows = [];
295
+ let pendDel = [],
296
+ pendAdd = [];
297
+ const cell = (ln, text, cls) => `<td class="ln ${cls}">${ln}</td><td class="code ${cls}">${text}</td>`;
298
+ const flush = () => {
299
+ const k = Math.max(pendDel.length, pendAdd.length);
300
+ for (let x = 0; x < k; x++) {
301
+ const dl = pendDel[x],
302
+ ad = pendAdd[x];
303
+ const left = dl != null ? cell(dl + 1, esc(a[dl]) || "&nbsp;", "delc") : cell("", "", "emptyc");
304
+ const right = ad != null ? cell(ad + 1, esc(b[ad]) || "&nbsp;", "addc") : cell("", "", "emptyc");
305
+ rows.push(`<tr>${left}${right}</tr>`);
306
+ }
307
+ pendDel = [];
308
+ pendAdd = [];
309
+ };
310
+ for (const o of ops) {
311
+ if (o.t === "del") pendDel.push(o.a);
312
+ else if (o.t === "add") pendAdd.push(o.b);
313
+ else {
314
+ flush();
315
+ rows.push(`<tr>${cell(o.a + 1, esc(a[o.a]) || "&nbsp;", "")}${cell(o.b + 1, esc(b[o.b]) || "&nbsp;", "")}</tr>`);
316
+ }
317
+ }
318
+ flush();
319
+ return `<table class="difftable split">${rows.join("")}</table>`;
320
+ }
321
+
322
+ // ---------- review actions ----------
323
+ async function refreshReview() {
324
+ const box = $("reviewActions");
325
+ if (!state.reviewId) {
326
+ box.hidden = true;
327
+ return;
328
+ }
329
+ try {
330
+ state.review = await api(`/api/reviews/${state.reviewId}`);
331
+ } catch {
332
+ box.hidden = true;
333
+ return;
334
+ }
335
+ const pill = $("reviewPill");
336
+ box.hidden = false;
337
+ const onThisVersion = state.review.version === state.version;
338
+ if (state.review.status === "pending") {
339
+ pill.className = "pill pending";
340
+ pill.textContent = onThisVersion ? "● review pending" : `● pending on v${state.review.version}`;
341
+ $("approveBtn").hidden = !onThisVersion;
342
+ $("rejectBtn").hidden = !onThisVersion;
343
+ } else {
344
+ pill.className = "pill " + state.review.status;
345
+ pill.textContent = state.review.status === "approved" ? "✓ approved" : "↩ changes requested";
346
+ $("approveBtn").hidden = true;
347
+ $("rejectBtn").hidden = true;
348
+ }
349
+ }
350
+
351
+ async function resolve(decision) {
352
+ await api(`/api/reviews/${state.reviewId}/decision`, {
353
+ method: "POST",
354
+ headers: { "content-type": "application/json" },
355
+ body: JSON.stringify({ decision }),
356
+ });
357
+ await refreshReview();
358
+ toast(decision === "approve" ? "Plan approved — Claude will proceed." : "Sent back to Claude with your comments.");
359
+ }
360
+
361
+ // ---------- polling (reflect external resolution) ----------
362
+ function pollLoop() {
363
+ setInterval(async () => {
364
+ if (state.reviewId && state.review && state.review.status === "pending") {
365
+ try {
366
+ const r = await api(`/api/reviews/${state.reviewId}`);
367
+ if (r.status !== state.review.status) {
368
+ state.review = r;
369
+ refreshReview();
370
+ }
371
+ } catch {}
372
+ }
373
+ }, 2500);
374
+ }
375
+
376
+ // ---------- wiring ----------
377
+ function wireUI() {
378
+ // theme toggle (light ⇄ dark); head script already applied any saved choice pre-paint
379
+ const toggle = $("themeToggle");
380
+ const isDark = () => {
381
+ const t = document.documentElement.dataset.theme;
382
+ if (t === "dark") return true;
383
+ if (t === "light") return false;
384
+ return matchMedia("(prefers-color-scheme: dark)").matches;
385
+ };
386
+ const paintToggle = () => (toggle.textContent = isDark() ? "☀️" : "🌙");
387
+ paintToggle();
388
+ toggle.onclick = () => {
389
+ const next = isDark() ? "light" : "dark";
390
+ document.documentElement.dataset.theme = next;
391
+ try {
392
+ localStorage.setItem("planReviewTheme", next);
393
+ } catch {}
394
+ paintToggle();
395
+ };
396
+
397
+ $("projectSel").onchange = (e) => selectProject(e.target.value);
398
+ $("versionSel").onchange = (e) => {
399
+ state.reviewId && (state.reviewId = state.reviewId); // keep
400
+ loadVersion(Number(e.target.value));
401
+ };
402
+ $("baseSel").onchange = () => loadDiff();
403
+
404
+ document.querySelectorAll(".tab").forEach((t) => {
405
+ t.onclick = () => {
406
+ document.querySelectorAll(".tab").forEach((x) => x.classList.remove("active"));
407
+ document.querySelectorAll(".panel").forEach((x) => x.classList.remove("active"));
408
+ t.classList.add("active");
409
+ state.tab = t.dataset.tab;
410
+ $("panel-" + state.tab).classList.add("active");
411
+ if (state.tab === "diff") loadDiff();
412
+ };
413
+ });
414
+
415
+ document.querySelectorAll(".seg").forEach((s) => {
416
+ s.onclick = () => {
417
+ document.querySelectorAll(".seg").forEach((x) => x.classList.remove("active"));
418
+ s.classList.add("active");
419
+ state.diffMode = s.dataset.mode;
420
+ loadDiff();
421
+ };
422
+ });
423
+
424
+ // source gutter clicks + comment deletes (delegated)
425
+ const source = $("source");
426
+ // press a line number and drag to select a range; shift-click also extends
427
+ source.addEventListener("mousedown", (e) => {
428
+ const g = e.target.closest("[data-add]");
429
+ if (!g) return;
430
+ e.preventDefault(); // suppress native text selection while dragging
431
+ const n = Number(g.dataset.add);
432
+ const sel = state.sel;
433
+ if (e.shiftKey && sel.anchor != null) {
434
+ sel.start = Math.min(sel.anchor, n);
435
+ sel.end = Math.max(sel.anchor, n);
436
+ } else {
437
+ sel.anchor = sel.start = sel.end = n;
438
+ }
439
+ state.dragging = true;
440
+ source.classList.add("dragging");
441
+ document.querySelector(".composer.inline")?.remove();
442
+ paintSelection();
443
+ });
444
+ source.addEventListener("mousemove", (e) => {
445
+ if (!state.dragging) return;
446
+ const row = e.target.closest(".srow");
447
+ if (!row) return;
448
+ const n = Number(row.dataset.line);
449
+ state.sel.start = Math.min(state.sel.anchor, n);
450
+ state.sel.end = Math.max(state.sel.anchor, n);
451
+ paintSelection();
452
+ });
453
+ document.addEventListener("mouseup", () => {
454
+ if (!state.dragging) return;
455
+ state.dragging = false;
456
+ source.classList.remove("dragging");
457
+ openComposer(state.sel.start, state.sel.end);
458
+ });
459
+ source.onclick = (e) => {
460
+ const del = e.target.closest("[data-del]");
461
+ if (del) return delComment(del.dataset.del);
462
+ };
463
+ $("generalList").onclick = (e) => {
464
+ const del = e.target.closest("[data-del]");
465
+ if (del) delComment(del.dataset.del);
466
+ };
467
+ $("generalAdd").onclick = async () => {
468
+ await addComment(null, null, $("generalInput").value);
469
+ $("generalInput").value = "";
470
+ };
471
+
472
+ $("approveBtn").onclick = () => resolve("approve");
473
+ $("rejectBtn").onclick = () => ($("rejectModal").hidden = false);
474
+ $("rejectCancel").onclick = () => ($("rejectModal").hidden = true);
475
+ $("rejectConfirm").onclick = async () => {
476
+ const summary = $("rejectSummary").value.trim();
477
+ if (summary) await addComment(null, null, summary);
478
+ $("rejectSummary").value = "";
479
+ $("rejectModal").hidden = true;
480
+ await resolve("reject");
481
+ };
482
+ }
483
+
484
+ init().catch((e) => {
485
+ document.body.innerHTML = `<div class="empty">Failed to load: ${esc(e.message)}</div>`;
486
+ });
@@ -0,0 +1,94 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Plan Review</title>
7
+ <link rel="stylesheet" href="/style.css" />
8
+ <script>
9
+ // apply saved theme before first paint to avoid a flash
10
+ try { var t = localStorage.getItem("planReviewTheme"); if (t) document.documentElement.dataset.theme = t; } catch (e) {}
11
+ </script>
12
+ </head>
13
+ <body>
14
+ <div id="app">
15
+ <header class="topbar">
16
+ <div class="brand">
17
+ <span class="title">Plan Review</span>
18
+ <span class="ver" id="ver"></span>
19
+ </div>
20
+ <div class="selectors">
21
+ <label>Project
22
+ <select id="projectSel"></select>
23
+ </label>
24
+ <label>Version
25
+ <select id="versionSel"></select>
26
+ </label>
27
+ </div>
28
+ <div class="reviewActions" id="reviewActions" hidden>
29
+ <span class="pill pending" id="reviewPill">● review pending</span>
30
+ <button class="btn approve" id="approveBtn">Approve plan</button>
31
+ <button class="btn reject" id="rejectBtn">Request changes</button>
32
+ </div>
33
+ <button class="iconbtn" id="themeToggle" title="Toggle light / dark theme" aria-label="Toggle theme">🌓</button>
34
+ </header>
35
+
36
+ <nav class="tabs">
37
+ <button class="tab active" data-tab="preview">Preview</button>
38
+ <button class="tab" data-tab="source">Source &amp; comments <span class="count" id="cCount"></span></button>
39
+ <button class="tab" data-tab="diff">Diff</button>
40
+ </nav>
41
+
42
+ <main>
43
+ <!-- PREVIEW -->
44
+ <section class="panel active" id="panel-preview">
45
+ <article class="markdown-body" id="preview"></article>
46
+ </section>
47
+
48
+ <!-- SOURCE + COMMENTS -->
49
+ <section class="panel" id="panel-source">
50
+ <div class="source" id="source"></div>
51
+ <div class="general">
52
+ <h3>General comments</h3>
53
+ <div id="generalList"></div>
54
+ <div class="composer">
55
+ <textarea id="generalInput" placeholder="Leave a general comment on this plan…"></textarea>
56
+ <button class="btn" id="generalAdd">Comment</button>
57
+ </div>
58
+ </div>
59
+ </section>
60
+
61
+ <!-- DIFF -->
62
+ <section class="panel" id="panel-diff">
63
+ <div class="diffbar">
64
+ <label>Compare base
65
+ <select id="baseSel"></select>
66
+ </label>
67
+ <span class="arrow">→ current v<span id="diffCur"></span></span>
68
+ <div class="modetoggle">
69
+ <button class="seg active" data-mode="split">Side-by-side</button>
70
+ <button class="seg" data-mode="unified">Unified</button>
71
+ </div>
72
+ <span class="diffstat" id="diffStat"></span>
73
+ </div>
74
+ <div id="diff"></div>
75
+ </section>
76
+ </main>
77
+
78
+ <div class="rejectModal" id="rejectModal" hidden>
79
+ <div class="box">
80
+ <h3>Request changes</h3>
81
+ <p>Your line &amp; general comments will be sent back to Claude, which stays in plan mode and revises. Add an optional summary:</p>
82
+ <textarea id="rejectSummary" placeholder="Optional summary of requested changes…"></textarea>
83
+ <div class="row">
84
+ <button class="btn" id="rejectCancel">Cancel</button>
85
+ <button class="btn reject" id="rejectConfirm">Send back to Claude</button>
86
+ </div>
87
+ </div>
88
+ </div>
89
+
90
+ <div class="toast" id="toast" hidden></div>
91
+ </div>
92
+ <script src="/app.js"></script>
93
+ </body>
94
+ </html>