// Two-phase review: the shared card that lets a human validate/reject an agent's edits // after seeing the real diff. Mounted identically on /review and on /comments (under the // "to validate" filter). Browser-side. Data flow: // comments (?all=1) + journal (/api/journal) → for each `addressed` comment, invert the // journal to its changed pages (cascade included) → diffs (/api/diff, one batched call). import { createApi, displayName, esc, getAuthor, type Comment } from "./comments-client"; import { type DiffMode, effectiveDiffMode, getDiffMode, renderDiff, setDiffMode } from "./diff"; interface JournalChange { page: string; commentIds: string[]; what: string; why: string; } interface JournalEntry { id: string; date: string; title: string; summary: string; changes: JournalChange[]; } interface DiffResult { page: string; file: string | null; diff: string | null; error?: string; } interface ReviewChange { page: string; what: string; why: string; otherCommentIds: string[]; } type Msgs = Record; type RouteFor = (page: string) => string; /** All changes attributed to a comment = its journal entry's changes that reference it * (an entry lists one change per touched page → this is the cascade set). */ export function changesForComment(c: Comment, journal: JournalEntry[]): ReviewChange[] { const entry = journal.find((e) => e.id === c.resolution?.journalEntryId); if (!entry) return []; return entry.changes .filter((ch) => ch.commentIds.includes(c.id)) .map((ch) => ({ page: ch.page, what: ch.what, why: ch.why, otherCommentIds: ch.commentIds.filter((id) => id !== c.id), })); } function snippet(c: Comment | undefined): string { // Anchor is a TextQuoteSelector (quote) for selection, a BlockAnchor (label) for block. const a = c?.anchor as { quote?: string; label?: string } | null | undefined; const s = a?.quote || a?.label || c?.thread[0]?.body || ""; return s.length > 40 ? `${s.slice(0, 40)}…` : s; } function renderChange( ch: ReviewChange, diffs: Map, byId: Map, m: Msgs, mode: DiffMode, ): string { const d = diffs.get(ch.page); let body: string; if (d?.diff) body = renderDiff(d.diff, mode); else if (d?.error === "git") body = `

${m.diffUnavailable}

`; else body = `

${m.newFileUncommitted}

`; const others = ch.otherCommentIds.map((id) => byId.get(id)).filter((c): c is Comment => !!c); const also = others.length ? `
ⓘ ${m.alsoAffects} ${others.map((o) => `“${esc(snippet(o))}”`).join(", ")}
` : ""; return `
${esc(ch.page)}${ch.what ? ` — ${esc(ch.what)}` : ""}
${body}${also}
`; } export function renderReviewCard( c: Comment, changes: ReviewChange[], diffs: Map, byId: Map, m: Msgs, mode: DiffMode, routeFor: RouteFor, ): string { const href = `${routeFor(c.page)}?c=${c.id}`; const anc = c.anchor as { quote?: string; kind?: string; label?: string } | null; const head = c.scope === "selection" ? `“${esc((anc?.quote || "").slice(0, 90))}”` : c.scope === "block" ? `${anc?.kind === "image" ? "🖼" : "📊"} ${esc((anc?.label || "").slice(0, 70))}` : `${m.scopePage}`; return `
${esc(c.page)} ${head}
${c.thread.map((t) => `
${esc(displayName(t.author))} ${esc(t.body)}
`).join("")}
${m.proposedByAgent}${c.resolution?.note ? ` — ${esc(c.resolution.note)}` : ""}
${changes.length ? changes.map((ch) => renderChange(ch, diffs, byId, m, mode)).join("") : `

${m.noChangesRecorded}

`}
${m.viewInPage} ${m.editFixHere}
`; } function renderToggle(el: HTMLElement, m: Msgs, mode: DiffMode): void { el.innerHTML = `` + ``; } /** * Mount the review queue into `listEl` (+ optional diff-mode `toggleEl`). Fetches * comments + journal + diffs, renders one shared card per `addressed` comment (sorted by * page), and wires approve/reject/toggle. Returns `{ reload }`. */ export async function mountReviewList( listEl: HTMLElement, toggleEl: HTMLElement | null, m: Msgs, routeFor: RouteFor, ): Promise<{ reload: () => Promise }> { let stored = getDiffMode(); let comments: Comment[] = []; let journal: JournalEntry[] = []; let byId = new Map(); let diffs = new Map(); const api = createApi(); // PATCH carries the token from localStorage const meCfg = JSON.parse(document.getElementById("notabene-me")?.textContent || "{}"); // Side-by-side is unreadable on phones → coerce to unified below 640px; the stored // preference is preserved for wider screens. const narrowMQ = typeof matchMedia === "function" ? matchMedia("(max-width: 640px)") : null; const mode = (): DiffMode => effectiveDiffMode(stored, narrowMQ?.matches ?? false); function render(): void { const addressed = comments .filter((c) => c.status === "addressed") .sort((a, b) => (a.page < b.page ? -1 : a.page > b.page ? 1 : a.createdAt < b.createdAt ? -1 : 1)); // The toggle exists only where it can actually do something: cards to switch AND a // screen the coercion above won't override. Deriving it from that very predicate is // the point — hiding it with a CSS breakpoint duplicated the 640px truth in a rule a // LATER `.diff-toggle { display }` silently won over, leaving a button that // highlighted "side by side" and rendered unified. Emptied rather than hidden: the // click listener is delegated to the container, and `.diff-toggle:empty` collapses it. if (toggleEl) { if (addressed.length && !(narrowMQ?.matches ?? false)) renderToggle(toggleEl, m, stored); else toggleEl.innerHTML = ""; } listEl.innerHTML = addressed.length ? addressed .map((c) => renderReviewCard(c, changesForComment(c, journal), diffs, byId, m, mode(), routeFor)) .join("") : `

${m.reviewEmpty}

`; } async function reload(): Promise { comments = await fetch("/api/comments?all=1").then((r) => r.json()); journal = await fetch("/api/journal") .then((r) => r.json()) .catch(() => []); byId = new Map(comments.map((c) => [c.id, c])); const pages = new Set(); for (const c of comments.filter((x) => x.status === "addressed")) { for (const ch of changesForComment(c, journal)) pages.add(ch.page); } diffs = new Map(); if (pages.size) { const qs = [...pages].map((p) => `page=${encodeURIComponent(p)}`).join("&"); const res: DiffResult[] = await fetch(`/api/diff?${qs}`) .then((r) => r.json()) .catch(() => []); for (const d of Array.isArray(res) ? res : []) diffs.set(d.page, d); } render(); } if (toggleEl) { // Buttons are painted by render() (once the queue is known to be non-empty); the // listener is delegated, so it survives every re-render. toggleEl.addEventListener("click", (e) => { const b = (e.target as HTMLElement).closest("button[data-mode]") as HTMLElement | null; if (!b) return; stored = b.dataset.mode === "split" ? "split" : "unified"; setDiffMode(stored); renderToggle(toggleEl, m, stored); render(); }); } // Re-render when crossing the narrow breakpoint (e.g. tablet rotation). narrowMQ?.addEventListener("change", () => render()); listEl.addEventListener("click", async (e) => { const btn = (e.target as HTMLElement).closest("button[data-act]") as HTMLElement | null; if (!btn) return; const card = btn.closest(".rev-card") as HTMLElement; const id = btn.dataset.id as string; const page = card.dataset.page as string; if (btn.dataset.act === "approve") { await api("PATCH", { page, id, status: "resolved" }); await reload(); } else { const form = card.querySelector(".rev-reject-form") as HTMLElement; form.hidden = !form.hidden; if (!form.hidden) (form.querySelector("textarea") as HTMLTextAreaElement).focus(); } }); listEl.addEventListener("submit", async (e) => { const form = (e.target as HTMLElement).closest(".rev-reject-form") as HTMLElement | null; if (!form) return; e.preventDefault(); const ta = form.querySelector("textarea") as HTMLTextAreaElement; const body = ta.value.trim(); if (!body) return; const page = (form.closest(".rev-card") as HTMLElement).dataset.page as string; await api("PATCH", { page, id: form.dataset.id, status: "open", reply: { author: getAuthor(meCfg.author || "you", meCfg.email || ""), body }, }); await reload(); }); await reload(); return { reload }; }