prreviewbuddy 0.12.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/dist/server.js ADDED
@@ -0,0 +1,3849 @@
1
+ import { B as lineagePosition, C as clearClaim, D as removeWorktree, E as readMarker, F as loadJob, G as saveWorkspace, I as saveJob, J as processDiscussion, M as deleteJob, N as fail, O as relativeTime$1, P as isTerminal, R as STORE_ROOT, S as fillFileUrlTemplate, T as MANAGED_ROOT, U as recentReviewGroups, V as loadWorkspace, Y as describeAuthorship, _ as feedbackUrl, a as refreshPrContext, b as EXPLAIN_SIMPLY_PROMPT, c as agentEnvOf, et as agentFor, g as BUILD_VERSION, h as writeServerRecord, i as updateReview, j as allJobs, k as checkFreshness, n as record, nt as AgentUnavailableError, o as isUnchanged, q as touchWorkspace, r as RefreshUnavailableError, s as askCheckout, t as readEvents, u as clearServerRecord, v as runJob, w as isClaimed } from "./log-DdPaO6Wo.js";
2
+ import { isAbsolute, join, relative, resolve } from "node:path";
3
+ import { randomBytes, timingSafeEqual } from "node:crypto";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
5
+ import { fileURLToPath } from "node:url";
6
+ import { createServer } from "node:http";
7
+ //#region ../../packages/review-harness/src/domain/ai_review/review_composition.ts
8
+ /**
9
+ * What a follow-up actually reviewed — the files it wrote about, not the files it was handed.
10
+ *
11
+ * This distinction is the whole of a bug found on a live run. Composing from `scope.paths` made
12
+ * the Overview claim "480 of 482 reviewed in depth" while the card beside it read `20 / 482`: the
13
+ * card counts files the model sectioned, and a follow-up given 467 files had sectioned seven of
14
+ * them. The runbook's phase 2 rule is explicit — the numerator is what the model sectioned, and
15
+ * crossing it with what we sent produces a confidently wrong number, which is worse than the
16
+ * missing one it replaced.
17
+ *
18
+ * Being handed a file is not evidence it was read. Only the model's own output is.
19
+ */
20
+ function coveredByRun(run) {
21
+ return [...run.result.sections.flatMap((section) => section.files.map((file) => file.path)), ...run.result.fileNotes.map((note) => note.path)];
22
+ }
23
+ var RISK_RANK = {
24
+ low: 0,
25
+ medium: 1,
26
+ high: 2
27
+ };
28
+ /**
29
+ * Risk after follow-ups, derived from the model's own severity rather than from coverage.
30
+ *
31
+ * Only ever escalates, and only on a `critical` finding. Two things this deliberately is not:
32
+ * it is not a fresh verdict from a second model (nobody asked for one), and it is not a coverage
33
+ * heuristic — "more files reviewed means lower risk" would fire on every large PR and is exactly
34
+ * the kind of threshold rule invariant 0 rejects. A follow-up that finds a critical problem has
35
+ * demonstrably raised the stakes; a follow-up that finds nothing has not lowered them, because
36
+ * absence of a finding in one area says nothing about the areas still unread.
37
+ */
38
+ function deriveOverallRisk(primary, followUpIssues) {
39
+ return followUpIssues.some((issue) => issue.severity === "critical") && RISK_RANK[primary] < RISK_RANK.high ? "high" : primary;
40
+ }
41
+ /**
42
+ * Namespaces a follow-up's ids so they can never collide with the primary's — or each other's.
43
+ *
44
+ * Primary ids are never renumbered, in any circumstance, for any reason: that is the invariant
45
+ * `completedSectionIds` and `answeredQuestionIds` depend on. Models also reuse obvious ids like
46
+ * "s1" across runs, so without a per-run prefix a second follow-up would silently overwrite the
47
+ * first in every id-keyed lookup.
48
+ */
49
+ function namespaced(runId, id) {
50
+ return `${runId}:${id}`;
51
+ }
52
+ function composeSections(primary, runs, notesBySection) {
53
+ const composed = primary.map((section) => {
54
+ const notes = notesBySection.get(section.id);
55
+ if (!notes) return { ...section };
56
+ const files = section.files.map((file) => {
57
+ const focus = notes.get(file.path);
58
+ return focus ? {
59
+ ...file,
60
+ focus
61
+ } : file;
62
+ });
63
+ const reassessed = section.files.some((file) => notes.has(file.path));
64
+ return {
65
+ ...section,
66
+ files,
67
+ reassessed
68
+ };
69
+ });
70
+ const primaryCount = composed.length;
71
+ for (const run of runs) for (const section of run.result.sections) composed.push({
72
+ ...section,
73
+ id: namespaced(run.id, section.id),
74
+ fromFollowUpId: run.id,
75
+ recommendedStartingPoint: false
76
+ });
77
+ return [...composed.slice(0, primaryCount).sort((a, b) => a.order - b.order), ...composed.slice(primaryCount)].map((section, index) => ({
78
+ ...section,
79
+ order: index + 1
80
+ }));
81
+ }
82
+ /**
83
+ * Suggested areas with anything a follow-up has since reviewed removed.
84
+ *
85
+ * Without this the card that just ran stays on screen offering to run again — and clicking it
86
+ * would re-review the same files and bill for it twice. An area is a claim about work still
87
+ * outstanding, so it has to be derived from current coverage rather than frozen at plan time.
88
+ *
89
+ * A partially covered area keeps its card with a reduced count, which is honest: the rest of it
90
+ * genuinely has not been read. An area with nothing left disappears entirely.
91
+ */
92
+ function composeSuggestedAreas(session, reviewedPaths) {
93
+ return (session.metadata.suggestedAreas ?? []).map((area) => {
94
+ if (!area.paths?.length) return area;
95
+ const remaining = area.paths.filter((path) => !reviewedPaths.has(path));
96
+ return {
97
+ ...area,
98
+ paths: remaining,
99
+ fileCount: remaining.length
100
+ };
101
+ }).filter((area) => area.paths === void 0 || area.paths.length > 0);
102
+ }
103
+ /**
104
+ * Coverage after follow-ups, so the Overview's count climbs as more of the PR gets read.
105
+ *
106
+ * Only recomputed when the primary recorded `reviewedPaths`. Without that list there is no way to
107
+ * union anything — adding the follow-up's file count to the stored total would double-count every
108
+ * file both runs saw, and a coverage number that overstates itself is the exact bug phase 2
109
+ * existed to kill.
110
+ */
111
+ function composeCoverage(session, reviewedPaths, runs) {
112
+ const coverage = session.metadata.fileCoverage;
113
+ if (!coverage?.reviewedPaths) return coverage;
114
+ const partialFiles = composePartialFiles(coverage.partialFiles, runs);
115
+ return {
116
+ ...coverage,
117
+ reviewedFiles: reviewedPaths.size,
118
+ reviewedPaths: [...reviewedPaths],
119
+ ...partialFiles ? {
120
+ partialFiles,
121
+ partiallyReadFiles: partialFiles.length
122
+ } : {}
123
+ };
124
+ }
125
+ /**
126
+ * How far each file has been read, after replaying the follow-up runs over the primary's answer.
127
+ *
128
+ * The rule is "the last run to touch a file is the one that knows": a run is given the file's
129
+ * outstanding remainder, so whatever it reports back supersedes what it was told. A file the run
130
+ * covered and did *not* report as partial has been read to the end and leaves the list — that
131
+ * absence is the only signal that anything ever completes, which is why `FollowUpRun.partialFiles`
132
+ * being `[]` and being `undefined` have to stay distinguishable.
133
+ *
134
+ * Returns `undefined` when nothing can be said: a primary that predates the field, with no run
135
+ * carrying the information either. `[]` is a real answer and means everything has been read whole.
136
+ */
137
+ function composePartialFiles(primary, runs) {
138
+ if (primary === void 0 && runs.every((r) => r.partialFiles === void 0)) return void 0;
139
+ const outstanding = new Map((primary ?? []).map((p) => [p.path, p]));
140
+ for (const run of runs) {
141
+ if (run.partialFiles === void 0) continue;
142
+ for (const path of run.scope.paths) outstanding.delete(path);
143
+ for (const partial of run.partialFiles) outstanding.set(partial.path, partial);
144
+ }
145
+ return [...outstanding.values()];
146
+ }
147
+ /**
148
+ * Composes a session's primary result with its follow-up runs. Pure, deterministic, total.
149
+ *
150
+ * Returns `null` only when there is no primary result — a session that has not produced one has
151
+ * nothing for follow-ups to attach to.
152
+ */
153
+ function composeReview(session) {
154
+ const primary = session.result;
155
+ if (!primary) return null;
156
+ const runs = [...session.followUps ?? []].sort((a, b) => a.generatedAt - b.generatedAt);
157
+ const notesBySection = /* @__PURE__ */ new Map();
158
+ for (const run of runs) for (const note of run.result.fileNotes) {
159
+ const forSection = notesBySection.get(note.sectionId) ?? /* @__PURE__ */ new Map();
160
+ forSection.set(note.path, note.focus);
161
+ notesBySection.set(note.sectionId, forSection);
162
+ }
163
+ const alreadyRead = new Set(session.metadata.fileCoverage?.reviewedPaths ?? []);
164
+ const issues = primary.issues.map((issue) => ({ ...issue }));
165
+ const questions = primary.questions.map((question) => ({ ...question }));
166
+ const followUpIssues = [];
167
+ for (const run of runs) {
168
+ for (const issue of run.result.issues) {
169
+ followUpIssues.push(issue);
170
+ issues.push({
171
+ ...issue,
172
+ id: namespaced(run.id, issue.id),
173
+ sectionId: issue.sectionId ? resolveSectionId(run, issue.sectionId) : void 0,
174
+ fromFollowUpId: run.id,
175
+ reassessment: issue.file !== void 0 && alreadyRead.has(issue.file) ? true : void 0
176
+ });
177
+ }
178
+ for (const question of run.result.questions) questions.push({
179
+ ...question,
180
+ id: namespaced(run.id, question.id),
181
+ sectionId: question.sectionId ? resolveSectionId(run, question.sectionId) : void 0
182
+ });
183
+ }
184
+ const overallRisk = deriveOverallRisk(primary.overallRisk, followUpIssues);
185
+ const reviewedPaths = /* @__PURE__ */ new Set([...session.metadata.fileCoverage?.reviewedPaths ?? [], ...runs.flatMap(coveredByRun)]);
186
+ return {
187
+ schemaVersion: 1,
188
+ summary: primary.summary,
189
+ recommendation: primary.recommendation,
190
+ nextSteps: primary.nextSteps,
191
+ overallRisk,
192
+ riskEscalated: overallRisk !== primary.overallRisk,
193
+ sections: composeSections(primary.sections, runs, notesBySection),
194
+ issues,
195
+ questions,
196
+ reviewedPaths: [...reviewedPaths],
197
+ suggestedAreas: composeSuggestedAreas(session, reviewedPaths),
198
+ fileCoverage: composeCoverage(session, reviewedPaths, runs),
199
+ runs
200
+ };
201
+ }
202
+ /**
203
+ * An item's sectionId may point at a section the same run created (namespaced along with it) or
204
+ * at an existing primary section (left alone). Getting this backwards would orphan every issue a
205
+ * follow-up raised against the guide it was reviewing.
206
+ */
207
+ function resolveSectionId(run, sectionId) {
208
+ return run.result.sections.some((section) => section.id === sectionId) ? namespaced(run.id, sectionId) : sectionId;
209
+ }
210
+ //#endregion
211
+ //#region ../../packages/review-harness/src/domain/utils/html.ts
212
+ function escapeHtml(value) {
213
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
214
+ }
215
+ function renderInlineText(value) {
216
+ return escapeHtml(value).replace(/`([^`\n]+)`/g, "<code>$1</code>");
217
+ }
218
+ //#endregion
219
+ //#region ../../packages/review-harness/src/domain/utils/file_path.ts
220
+ /**
221
+ * Splits a repo-relative path for display, so the filename can be shown prominently and the
222
+ * directory demoted to context. Deep monorepo paths are mostly prefix — rendering them at equal
223
+ * weight buries the one part that identifies the file.
224
+ */
225
+ function splitFilePath(path) {
226
+ const lastSlash = path.lastIndexOf("/");
227
+ if (lastSlash === -1) return {
228
+ dir: "",
229
+ name: path
230
+ };
231
+ return {
232
+ dir: path.slice(0, lastSlash),
233
+ name: path.slice(lastSlash + 1)
234
+ };
235
+ }
236
+ //#endregion
237
+ //#region ../../packages/review-harness/src/domain/ai_review/review_metrics.ts
238
+ /**
239
+ * Overview counts, derived entirely from the review content the user actually reads.
240
+ *
241
+ * These were previously read straight off the model's output — in particular `files` used
242
+ * `result.files.length`, a top-level per-file summary list that the model was free to omit and
243
+ * routinely did on larger PRs, so the Overview reported "0 Files" on a 29-file review. Nothing
244
+ * was broken; the count simply measured a field the model had declined to fill in. That field no
245
+ * longer exists (see types.ts) and sections are now the single source of truth for files.
246
+ *
247
+ * The rule this encodes: the AI generates content, the extension counts it. Any metric that can
248
+ * be computed from content the user can see should be, so a number in the UI can never disagree
249
+ * with the thing it claims to be counting.
250
+ */
251
+ function deriveReviewMetrics(result) {
252
+ return {
253
+ files: new Set(result.sections.flatMap((s) => s.files.map((f) => f.path))).size,
254
+ sections: result.sections.length,
255
+ issues: result.issues.length,
256
+ questions: result.questions.length
257
+ };
258
+ }
259
+ //#endregion
260
+ //#region ../../packages/review-harness/src/domain/utils/relative_time.ts
261
+ function formatRelativeTime(ts, now = Date.now()) {
262
+ const diffMs = Math.max(0, now - ts);
263
+ const minute = 6e4;
264
+ const hour = 60 * minute;
265
+ const day = 24 * hour;
266
+ if (diffMs < minute) return "Just now";
267
+ if (diffMs < hour) {
268
+ const mins = Math.floor(diffMs / minute);
269
+ return `${mins} min${mins === 1 ? "" : "s"} ago`;
270
+ }
271
+ if (diffMs < day) {
272
+ const hours = Math.floor(diffMs / hour);
273
+ return `${hours} hour${hours === 1 ? "" : "s"} ago`;
274
+ }
275
+ const days = Math.floor(diffMs / day);
276
+ return `${days} day${days === 1 ? "" : "s"} ago`;
277
+ }
278
+ //#endregion
279
+ //#region ../../packages/review-harness/src/domain/utils/code_block.ts
280
+ var CODE_BLOCK_CLASS = "pr-helper-code-block";
281
+ var CODE_COPY_CLASS = "pr-helper-code-copy";
282
+ //#endregion
283
+ //#region ../../packages/review-harness/src/domain/utils/markdown.ts
284
+ var BLOCK_MARKER = "\0";
285
+ var INLINE_MARKER = "";
286
+ var BLOCK_TOKEN = /\u0000(\d+)\u0000/g;
287
+ var BLOCK_TOKEN_LINE = /^\u0000\d+\u0000$/;
288
+ var INLINE_TOKEN = /\u0001(\d+)\u0001/g;
289
+ var SAFE_HREF = /^(?:https?:\/\/|#)/i;
290
+ function renderMarkdown(value) {
291
+ const { text, blocks } = extractFencedBlocks(value.replace(/[\u0000\u0001]/g, ""));
292
+ return restoreFencedBlocks(renderBlocks(text), blocks);
293
+ }
294
+ function extractFencedBlocks(value) {
295
+ const blocks = [];
296
+ const lines = value.split("\n");
297
+ const out = [];
298
+ let i = 0;
299
+ while (i < lines.length) {
300
+ const opening = lines[i].match(/^\s*(`{3,}|~{3,})\s*[\w+#.-]*\s*$/);
301
+ if (!opening) {
302
+ out.push(lines[i]);
303
+ i += 1;
304
+ continue;
305
+ }
306
+ const closing = new RegExp(`^\\s*${opening[1][0]}{3,}\\s*$`);
307
+ const body = [];
308
+ i += 1;
309
+ while (i < lines.length && !closing.test(lines[i])) {
310
+ body.push(lines[i]);
311
+ i += 1;
312
+ }
313
+ i += 1;
314
+ out.push(`${BLOCK_MARKER}${blocks.length}${BLOCK_MARKER}`);
315
+ blocks.push(body.join("\n"));
316
+ }
317
+ return {
318
+ text: out.join("\n"),
319
+ blocks
320
+ };
321
+ }
322
+ function restoreFencedBlocks(html, blocks) {
323
+ return html.replace(BLOCK_TOKEN, (_match, index) => `<div class="${CODE_BLOCK_CLASS}"><pre><code>${escapeHtml(blocks[Number(index)])}</code></pre><button type="button" class="${CODE_COPY_CLASS}" aria-label="Copy code">Copy</button></div>`);
324
+ }
325
+ function renderBlocks(text) {
326
+ const out = [];
327
+ let paragraph = [];
328
+ let quote = [];
329
+ let list = null;
330
+ const flush = () => {
331
+ if (paragraph.length) out.push(`<p>${renderInline(paragraph.join(" "))}</p>`);
332
+ if (quote.length) out.push(`<blockquote>${renderInline(quote.join(" "))}</blockquote>`);
333
+ if (list) out.push(`<${list.tag}>${list.items.map((item) => `<li>${renderInline(item)}</li>`).join("")}</${list.tag}>`);
334
+ paragraph = [];
335
+ quote = [];
336
+ list = null;
337
+ };
338
+ for (const raw of text.split("\n")) {
339
+ const line = raw.trim();
340
+ if (!line) {
341
+ flush();
342
+ continue;
343
+ }
344
+ if (BLOCK_TOKEN_LINE.test(line)) {
345
+ flush();
346
+ out.push(line);
347
+ continue;
348
+ }
349
+ if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(line)) {
350
+ flush();
351
+ out.push("<hr>");
352
+ continue;
353
+ }
354
+ const heading = line.match(/^(#{1,6})\s+(.+)$/);
355
+ if (heading) {
356
+ flush();
357
+ const level = Math.min(heading[1].length, 3) + 3;
358
+ out.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
359
+ continue;
360
+ }
361
+ const bullet = line.match(/^[-*+]\s+(.+)$/);
362
+ if (bullet) {
363
+ if (list?.tag !== "ul") {
364
+ flush();
365
+ list = {
366
+ tag: "ul",
367
+ items: []
368
+ };
369
+ }
370
+ list.items.push(bullet[1]);
371
+ continue;
372
+ }
373
+ const ordered = line.match(/^\d{1,9}[.)]\s+(.+)$/);
374
+ if (ordered) {
375
+ if (list?.tag !== "ol") {
376
+ flush();
377
+ list = {
378
+ tag: "ol",
379
+ items: []
380
+ };
381
+ }
382
+ list.items.push(ordered[1]);
383
+ continue;
384
+ }
385
+ const quoted = line.match(/^>\s?(.*)$/);
386
+ if (quoted) {
387
+ if (!quote.length) flush();
388
+ quote.push(quoted[1]);
389
+ continue;
390
+ }
391
+ if (list && list.items.length) {
392
+ list.items[list.items.length - 1] += ` ${line}`;
393
+ continue;
394
+ }
395
+ if (quote.length) {
396
+ quote[quote.length - 1] += ` ${line}`;
397
+ continue;
398
+ }
399
+ paragraph.push(line);
400
+ }
401
+ flush();
402
+ return out.join("");
403
+ }
404
+ function renderInline(value) {
405
+ const codeSpans = [];
406
+ return escapeHtml(value).replace(/`([^`\n]+)`/g, (_match, code) => {
407
+ codeSpans.push(code);
408
+ return `${INLINE_MARKER}${codeSpans.length - 1}${INLINE_MARKER}`;
409
+ }).replace(/(?<!!)\[([^\]\n]+)\]\(([^)\s]+)\)/g, (match, label, href) => SAFE_HREF.test(href) ? `<a href="${href}" target="_blank" rel="noopener noreferrer">${label}</a>` : match).replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>").replace(/__([^_\n]+)__/g, "<strong>$1</strong>").replace(/(^|[^*\w])\*([^*\n]+)\*/g, "$1<em>$2</em>").replace(/(^|[^_\w])_([^_\n]+)_(?![\w_])/g, "$1<em>$2</em>").replace(INLINE_TOKEN, (_match, index) => `<code>${codeSpans[Number(index)]}</code>`);
410
+ }
411
+ //#endregion
412
+ //#region ../../packages/review-harness/src/domain/utils/model_label.ts
413
+ var KNOWN_BRANDS = {
414
+ gpt: "GPT",
415
+ claude: "Claude"
416
+ };
417
+ var isNumericToken = (token) => /^\d+(\.\d+)?$/.test(token);
418
+ function titleCase$1(token) {
419
+ return token.charAt(0).toUpperCase() + token.slice(1);
420
+ }
421
+ function humanizeModelId(id) {
422
+ const tokens = id.split("-").filter(Boolean);
423
+ if (tokens.length === 0) return id;
424
+ const [brandToken, ...rest] = tokens;
425
+ let display = KNOWN_BRANDS[brandToken.toLowerCase()] ?? titleCase$1(brandToken);
426
+ let prevWasNumeric = false;
427
+ rest.forEach((token, index) => {
428
+ const numeric = isNumericToken(token);
429
+ if (numeric && prevWasNumeric) display += `.${token}`;
430
+ else if (numeric && index === 0) display += `-${token}`;
431
+ else display += ` ${numeric ? token : titleCase$1(token)}`;
432
+ prevWasNumeric = numeric;
433
+ });
434
+ return display;
435
+ }
436
+ //#endregion
437
+ //#region ../../packages/review-harness/src/domain/ui/components/pill_tooltips.ts
438
+ /**
439
+ * What every pill in the sidebar actually means, in one place.
440
+ *
441
+ * A pill is a single uppercase word standing in for a whole axis of judgement, and the axes are
442
+ * not self-evident: MAJOR and "medium confidence" sit side by side on the same card and answer
443
+ * completely different questions. Severity is how much the finding would matter *if it is real*;
444
+ * confidence is how sure the analysis is that it *is* real. A reviewer who has collapsed those
445
+ * into one mental "how bad is this" scale is reading the card wrong, and no amount of colour
446
+ * fixes that — only words do.
447
+ *
448
+ * The wording is derived from the field guidance the model is actually given (see
449
+ * STRUCTURED_OUTPUT_INSTRUCTIONS in ai_review/pipeline.ts). This file must not invent a richer
450
+ * taxonomy than the prompt asks for: if the prompt does not define what separates "major" from
451
+ * "minor", neither does the tooltip. It names the axis and where the value sits on it, and
452
+ * leaves the grading to the model that did the grading.
453
+ *
454
+ * House style, because a tooltip is read once at a glance and never studied:
455
+ *
456
+ * 1. Open with the concept, not a sentence the reader has to parse into one. "PR risk — the
457
+ * overall risk level assigned to this pull request" lands before the clause finishes;
458
+ * "Overall risk this analysis assigned the pull request" makes them unpick the grammar first.
459
+ * 2. Describe what the thing *is*, never how the extension builds it. A follow-up "builds on the
460
+ * original analysis" — that it is composed into the stored session is our problem, not theirs.
461
+ * 3. Plain reviewer's English over product vocabulary. "During your review", not "before you
462
+ * sign off"; "the section of the Guide", not the section's own title read back at them.
463
+ */
464
+ /** Ordered worst-first, matching how the prompt lists the enum. */
465
+ var SEVERITY_SCALE = [
466
+ "critical",
467
+ "major",
468
+ "minor",
469
+ "nit"
470
+ ];
471
+ var ATTENTION_SCALE = [
472
+ "high",
473
+ "medium",
474
+ "low"
475
+ ];
476
+ /**
477
+ * "2 of 4: critical → major → minor → nit" — the value's rank and the whole scale it sits on.
478
+ *
479
+ * Both halves earn their place: the rank alone leaves the reader guessing what the other steps
480
+ * are, and the scale alone makes them count. An arrow rather than a bullet or comma, so the
481
+ * ordering is visible rather than something the reader has to already know.
482
+ */
483
+ function position(value, scale) {
484
+ const index = scale.indexOf(value);
485
+ if (index === -1) return scale.join(" → ");
486
+ return `${index + 1} of ${scale.length}: ${scale.join(" → ")}`;
487
+ }
488
+ function severityTip(severity) {
489
+ return `Severity — how much this would matter if it turns out to be real. ${position(severity, SEVERITY_SCALE)}.`;
490
+ }
491
+ /**
492
+ * Only medium and low ever reach the screen (renderIssueConfidence suppresses "high"), but all
493
+ * three are worded here so the copy does not depend on that rendering decision staying put.
494
+ *
495
+ * The second sentence is the one that matters. Confidence and severity sit side by side on the
496
+ * same card, and a reader who has not been told otherwise will assume they are the same scale
497
+ * read twice — so the tooltip says they are not, in as many words.
498
+ */
499
+ function confidenceTip(confidence) {
500
+ return `Confidence — how sure the analysis is that this issue is real. This is separate from how serious it would be. ${{
501
+ high: "High means the analysis is confident it’s real.",
502
+ medium: "Medium means it’s worth confirming in the code before acting on it.",
503
+ low: "Low means it was likely reasoned from the diff alone, so check the surrounding code first."
504
+ }[confidence]}`;
505
+ }
506
+ function importanceTip(importance) {
507
+ return `Importance — how much attention this question deserves during your review. ${position(importance, ATTENTION_SCALE)}.`;
508
+ }
509
+ /**
510
+ * The four statuses an "Analyse new changes" pass assigns to a finding.
511
+ *
512
+ * Each says what the *product* concluded and, where it matters, how much to trust it. "Still open"
513
+ * carries the load: it is both a real judgement ("the changes do not address this") and the safe
514
+ * default applied when the analysis said nothing about a finding at all, and the reviewer cannot
515
+ * tell those apart from the badge — so the copy commits to the weaker of the two readings rather
516
+ * than claiming a judgement that may not have been made.
517
+ */
518
+ function findingStatusTip(status) {
519
+ return {
520
+ resolved: "Resolved — the new commits appear to address this finding. Worth a glance to confirm before you close it out.",
521
+ "still-open": "Still open — the new commits do not address this finding. Findings the analysis did not comment on are shown this way too, so this never disappears on its own.",
522
+ affected: "Affected — the code this finding concerns has changed, but not in a way that settles it. Re-read this one.",
523
+ new: "New — introduced by the changes pushed since your previous analysis."
524
+ }[status];
525
+ }
526
+ /** The reviewer-facing label for a status. Kept beside the tooltips so the two cannot drift. */
527
+ function findingStatusLabel(status) {
528
+ return {
529
+ resolved: "Resolved",
530
+ "still-open": "Still open",
531
+ affected: "Affected",
532
+ new: "New"
533
+ }[status];
534
+ }
535
+ /**
536
+ * What the commits pushed since did to an open question.
537
+ *
538
+ * "Still open" carries the same double load it does on a finding: a real judgement, and the default
539
+ * for a question the reassessment never mentioned. The copy commits to the weaker reading, because
540
+ * the reviewer must not read silence as an answer.
541
+ */
542
+ function questionStatusTip(status) {
543
+ return {
544
+ answered: "Answered — the new commits settle this, and the answer is below. Worth a glance to confirm before you drop it.",
545
+ "still-open": "Still open — the new commits do not settle this. Questions the reassessment did not comment on are shown this way too, so this never disappears on its own.",
546
+ affected: "Affected — the code this question is about has changed without answering it. Read the question again before you ask it."
547
+ }[status];
548
+ }
549
+ /** The reviewer-facing label for a question status. Kept beside the tooltip so the two cannot drift. */
550
+ function questionStatusLabel(status) {
551
+ return {
552
+ answered: "Answered",
553
+ "still-open": "Still open",
554
+ affected: "Affected"
555
+ }[status];
556
+ }
557
+ /**
558
+ * The four impacts a repository investigation assigns to a finding.
559
+ *
560
+ * Every one of these says the evidence came from *outside* the diff, because that is the whole
561
+ * reason to trust them more than the analysis they revise — and equally the reason to say where
562
+ * they came from. "A coding agent read the repository" is the qualification, and a reviewer who
563
+ * does not know that cannot weigh it.
564
+ *
565
+ * "Investigated" carries the same load "Still open" does on the other axis: it is both a real
566
+ * judgement ("this evidence does not bear on that finding") and the default applied when the
567
+ * reassessment said nothing about a finding at all. The copy commits to the weaker reading rather
568
+ * than claiming a judgement that may not have been made.
569
+ */
570
+ function investigationImpactTip(impact) {
571
+ return {
572
+ confirmed: "Confirmed — a repository investigation found evidence supporting this finding. It no longer rests on the diff alone.",
573
+ refuted: "Refuted — a repository investigation found evidence that this concern does not hold. Kept on the list, with the reasoning, rather than removed.",
574
+ revised: "Revised — a repository investigation changed what this finding says. Where its severity or confidence moved, the previous value is shown alongside.",
575
+ unchanged: "Investigated — this finding was reassessed against a repository investigation and stands as it was. Findings the reassessment did not comment on are shown this way too."
576
+ }[impact];
577
+ }
578
+ /**
579
+ * The reviewer-facing label. Note `unchanged` → "Investigated": the reviewer's world contains a
580
+ * finding that was looked into and survived, which is information. "Unchanged" describes our
581
+ * bookkeeping — what the field did — and says nothing about the code.
582
+ */
583
+ function investigationImpactLabel(impact) {
584
+ return {
585
+ confirmed: "Confirmed",
586
+ refuted: "Refuted",
587
+ revised: "Revised",
588
+ unchanged: "Investigated"
589
+ }[impact];
590
+ }
591
+ var SECTION_TAG_TIP = "Guide section — the section of the Guide this finding belongs to. Click to open it in the Guide tab.";
592
+ /**
593
+ * Builds a pill's `class` and `data-tip` attributes together.
594
+ *
595
+ * They are emitted as one string rather than left to the call site because they are useless
596
+ * apart: `data-tip` without `pr-helper-tip` never renders, and `pr-helper-tip` without a tip
597
+ * renders an empty black rectangle on hover. Taking the pill's own classes as an argument means
598
+ * there is no way to add the tooltip and clobber the styling, which is what a bare
599
+ * `class="pr-helper-tip"` helper would have invited.
600
+ *
601
+ * Emits a leading space so it drops straight in after a tag name.
602
+ */
603
+ function pillAttrs(classNames, tip) {
604
+ return ` class="${escapeHtml(classNames)} pr-helper-tip" data-tip="${escapeHtml(tip)}"`;
605
+ }
606
+ //#endregion
607
+ //#region ../../packages/review-harness/src/domain/ai_review/investigation_record.ts
608
+ /**
609
+ * The records for one finding, oldest first. Records accumulate: every "Investigate Again" appends.
610
+ */
611
+ function investigationsForFinding(records, findingKind, findingId) {
612
+ return (records ?? []).filter((r) => r.findingKind === findingKind && r.findingId === findingId);
613
+ }
614
+ /**
615
+ * The record that decides which buttons a finding shows — the newest, because a reviewer who has
616
+ * just asked again is mid-investigation regardless of what earlier rounds concluded.
617
+ */
618
+ function newestInvestigationForFinding(records, findingKind, findingId) {
619
+ const matches = investigationsForFinding(records, findingKind, findingId);
620
+ return matches.length ? matches[matches.length - 1] : null;
621
+ }
622
+ //#endregion
623
+ //#region ../../packages/review-harness/src/domain/ui/components/review_tabs.ts
624
+ function emptyState(message) {
625
+ return `<p class="pr-helper-review-empty">${escapeHtml(message)}</p>`;
626
+ }
627
+ function renderNoReviewState(iconUrl) {
628
+ return `
629
+ <div class="pr-helper-review-intro">
630
+ ${iconUrl ? `<img src="${escapeHtml(iconUrl)}" class="pr-helper-intro-mark" alt="" />` : ""}
631
+ <p class="pr-helper-intro-brand">PR Review Buddy</p>
632
+ <h3 class="pr-helper-intro-title">No analysis yet</h3>
633
+ <p class="pr-helper-intro-body">Analyse this pull request to unlock summaries, issues, questions and the Assistant.</p>
634
+ <button type="button" class="pr-helper-generate-review-button">Generate Analysis</button>
635
+ </div>
636
+ `;
637
+ }
638
+ function titleCase(value) {
639
+ return value.charAt(0).toUpperCase() + value.slice(1);
640
+ }
641
+ function hasExplainedSimply(session) {
642
+ return session.chat.some((m) => m.kind === "explain-simply");
643
+ }
644
+ var PROVIDER_DISPLAY_NAME = {
645
+ anthropic: "Claude",
646
+ openai: "GPT",
647
+ gemini: "Gemini",
648
+ openai_compatible: "the model"
649
+ };
650
+ var THINKING_PHRASES = [
651
+ "Buddy is reading the changes…",
652
+ "Buddy is connecting the dots…",
653
+ "Buddy is gathering context…",
654
+ "Buddy is preparing your analysis…"
655
+ ];
656
+ var THINKING_PHRASE_CYCLE_SECONDS = 15;
657
+ function renderThinkingPhrases() {
658
+ const phrases = [...THINKING_PHRASES];
659
+ for (let i = phrases.length - 1; i > 0; i--) {
660
+ const j = Math.floor(Math.random() * (i + 1));
661
+ [phrases[i], phrases[j]] = [phrases[j], phrases[i]];
662
+ }
663
+ const segment = THINKING_PHRASE_CYCLE_SECONDS / phrases.length;
664
+ return phrases.map((phrase, i) => `<p class="pr-helper-thinking-phrase" style="animation-delay:${(i * segment).toFixed(2)}s">${escapeHtml(phrase)}</p>`).join("");
665
+ }
666
+ var CANCEL_BUTTON = `<button type="button" class="pr-helper-cancel-button">Cancel</button>`;
667
+ function renderGeneratingOverview(session, iconUrl) {
668
+ const activeIndex = session.progress.findIndex((step) => !step.done);
669
+ const brandMark = iconUrl ? `<img src="${escapeHtml(iconUrl)}" class="pr-helper-brand-mark" alt="" />` : "";
670
+ const poweredByName = session.provider === "openai_compatible" ? humanizeModelId(session.model) : PROVIDER_DISPLAY_NAME[session.provider];
671
+ const isThinking = activeIndex === 2;
672
+ return `
673
+ <div class="pr-helper-generating pr-helper-generating-focused">
674
+ <div class="pr-helper-generating-body">
675
+ ${brandMark}
676
+ ${isThinking ? `<div class="pr-helper-thinking-phrases">${renderThinkingPhrases()}</div>
677
+ <p class="pr-helper-generating-title pr-helper-direct-headline" hidden>This is taking longer than usual.</p>` : `<p class="pr-helper-generating-title">${activeIndex >= 0 && activeIndex < 2 ? "Preparing analysis" : "Finalizing analysis"}</p>`}
678
+ <div class="pr-helper-thinking-dots"><span></span><span></span><span></span></div>
679
+ <div class="pr-helper-progress-bar pr-helper-progress-bar-shimmer"></div>
680
+ ${isThinking ? `<p class="pr-helper-generating-elapsed"></p>
681
+ <p class="pr-helper-escalation-hint" hidden>AI providers occasionally experience delays.</p>` : ""}
682
+ </div>
683
+ <div class="pr-helper-generating-footer">
684
+ <p class="pr-helper-generating-powered-by">Powered by ${escapeHtml(poweredByName)}</p>
685
+ ${CANCEL_BUTTON}
686
+ </div>
687
+ </div>
688
+ `;
689
+ }
690
+ function renderFailedOverview(session) {
691
+ return `
692
+ <div class="pr-helper-generating-error">
693
+ <p class="pr-helper-generating-error-text">${escapeHtml(session.error ?? "The AI analysis failed.")}</p>
694
+ <button type="button" class="pr-helper-retry-button">Retry</button>
695
+ </div>
696
+ `;
697
+ }
698
+ function renderOverviewStat(value, label, tab, options = {}) {
699
+ const { info, ariaLabel } = options;
700
+ const wide = typeof value === "string" ? " pr-helper-overview-stat-value-wide" : "";
701
+ const icon = info ? `<span class="pr-helper-overview-stat-info" data-overview-info data-tip="${escapeHtml(info)}" aria-hidden="true"><svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg></span>` : "";
702
+ return `<button type="button" class="pr-helper-overview-stat" data-overview-nav="${tab}"${ariaLabel ? ` aria-label="${escapeHtml(ariaLabel)}"` : ""}><span class="pr-helper-overview-stat-value${wide}">${escapeHtml(String(value))}</span><span class="pr-helper-overview-stat-label">${icon}${escapeHtml(label)}<span class="pr-helper-overview-stat-arrow" aria-hidden="true">→</span></span></button>`;
703
+ }
704
+ function renderCoverageStat(session, reviewedFiles) {
705
+ const coverage = composeReview(session)?.fileCoverage;
706
+ if (!coverage || coverage.totalChangedFiles <= reviewedFiles) return renderOverviewStat(reviewedFiles, "Files", "files");
707
+ if (coverage.repositoryAvailable) return renderOverviewStat(coverage.totalChangedFiles, "Files", "files");
708
+ return renderOverviewStat(`${reviewedFiles} / ${coverage.totalChangedFiles}`, "Files", "files", {
709
+ info: `All ${coverage.totalChangedFiles} changed files were triaged. ${coverage.unplanned ? "No analysis plan was produced, so the files analysed in depth were chosen by size of change." : "An analysis plan selected these for in-depth analysis; the rest are accounted for in the Guide and the Files tab."}`,
710
+ ariaLabel: `Analysis coverage: ${reviewedFiles} of ${coverage.totalChangedFiles} changed files analysed`
711
+ });
712
+ }
713
+ /**
714
+ * Provenance — how the review was produced, as opposed to what it found.
715
+ *
716
+ * These facts used to sit as two paragraphs in the middle of the Overview, between the summary
717
+ * and the suggested path. That was the wrong place: the Overview answers what the risk is, what
718
+ * is changing, where to start and what to watch for, and none of this answers any of them. It is
719
+ * the reviewer's right to audit how the review was made, but not at the cost of the four
720
+ * questions they came for — so it collapses, and becomes the home for any future metadata.
721
+ */
722
+ function coverageDetails(session) {
723
+ const coverage = composeReview(session)?.fileCoverage;
724
+ if (!coverage) return [];
725
+ const details = [];
726
+ const skipped = coverage.totalChangedFiles - coverage.reviewedFiles;
727
+ if (coverage.repositoryAvailable) {
728
+ const inline = coverage.reviewedPaths?.length;
729
+ details.push(inline === void 0 ? `All ${coverage.totalChangedFiles} changed files were named for the analysis, which had the repository open and could read any of them.` : `All ${coverage.totalChangedFiles} changed files were named for the analysis, which had the repository open: ${inline} arrived with ${inline === 1 ? "its diff" : "their diffs"} inline and it could read the rest directly.`);
730
+ return details;
731
+ }
732
+ details.push(skipped > 0 ? `All ${coverage.totalChangedFiles} changed files were triaged; ${coverage.reviewedFiles} ${coverage.reviewedFiles === 1 ? "was" : "were"} analysed in depth. Generated, vendored and lockfile changes were excluded, and the remaining files are available in the Files tab for your own review.` : `All ${coverage.totalChangedFiles} changed files were triaged and analysed in depth.`);
733
+ if (coverage.unplanned && skipped > 0) details.push("No analysis plan was produced for this PR, so the files analysed in depth were chosen by size of change.");
734
+ if (coverage.capBound) details.push(`The analysis plan identified more files than one pass can cover, so it was cut off at ${coverage.reviewedFiles}; the areas it ranked lowest were not reached.`);
735
+ const outstanding = coverage.partialFiles;
736
+ if (outstanding?.length) {
737
+ const n = outstanding.length;
738
+ const scale = n === 1 ? `${outstanding[0].path} (${outstanding[0].linesRead} of ${outstanding[0].linesTotal} diff lines read)` : `${n} files`;
739
+ details.push(`A further pass read on from where the first stopped, and ${n === 1 ? "one file is" : `${n} files are`} still larger than that could cover: ${scale}. The analysis was told exactly which lines it had seen, and asked to qualify anything it concluded from them.`);
740
+ } else if (outstanding === void 0 && coverage.partiallyReadFiles) {
741
+ const n = coverage.partiallyReadFiles;
742
+ details.push(`${n} ${n === 1 ? "file was" : "files were"} too large for the whole diff to fit in one pass, so ${n === 1 ? "it was" : "they were"} read in part. The analysis was told which, and asked to qualify anything it concluded from them.`);
743
+ }
744
+ return details;
745
+ }
746
+ function renderReviewDetails(session) {
747
+ const items = [...discussionDetails(session), ...coverageDetails(session)];
748
+ if (items.length === 0) return "";
749
+ return `
750
+ <details class="pr-helper-review-details">
751
+ <summary class="pr-helper-review-details-summary">Analysis details</summary>
752
+ <ul class="pr-helper-review-details-list">
753
+ ${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}
754
+ </ul>
755
+ </details>
756
+ `;
757
+ }
758
+ /**
759
+ * The risk verdict, qualified when the review could not see enough to give one outright.
760
+ *
761
+ * The trap this avoids: "coverage below some percentage means provisional" fires on every large
762
+ * PR, which makes it noise, and it is a threshold rule of exactly the kind invariant 0 rejects.
763
+ * The gate is instead the plan's *own* stated priority — an area the model itself called high
764
+ * priority, which the review then did not cover. The model supplied the judgement; the extension
765
+ * only checked it against what actually happened.
766
+ *
767
+ * Two badges would be one too many, so the qualifier sits inline with the risk label rather than
768
+ * competing with it: the verdict is still the headline, it just no longer claims to be final.
769
+ */
770
+ function renderProvisionalBadge(session) {
771
+ if ((session.metadata.unreviewedHighPriority ?? []).length === 0) return "";
772
+ return `<span class="pr-helper-risk-provisional">Provisional</span>`;
773
+ }
774
+ function renderProvisionalReason(session) {
775
+ const areas = session.metadata.unreviewedHighPriority ?? [];
776
+ if (areas.length === 0) return "";
777
+ const named = areas.map((a) => escapeHtml(a)).join(", ");
778
+ return `<p class="pr-helper-risk-provisional-reason">The analysis plan called ${areas.length === 1 ? "this area" : "these areas"} high priority but ${areas.length === 1 ? "it was" : "they were"} not analysed in depth: ${named}. Treat the verdict above as provisional until ${areas.length === 1 ? "it has" : "they have"} been looked at.</p>`;
779
+ }
780
+ function renderOverviewTab(session, iconUrl = "") {
781
+ if (!session) return renderNoReviewState(iconUrl);
782
+ if (session.status === "generating") return renderGeneratingOverview(session, iconUrl);
783
+ if (session.status === "failed") return renderFailedOverview(session);
784
+ const result = composeReview(session);
785
+ if (!result) return emptyState("No analysis yet.");
786
+ const nextSteps = result.nextSteps.map((step) => `<li class="pr-helper-markdown">${renderMarkdown(step)}</li>`).join("") || "<li>None</li>";
787
+ const metrics = deriveReviewMetrics(result);
788
+ return `
789
+ <div class="pr-helper-risk-headline">
790
+ <div class="pr-helper-risk-headline-top">
791
+ <span class="pr-helper-risk-dot pr-helper-risk-dot-${result.overallRisk}"></span>
792
+ <span class="pr-helper-risk-headline-label">${escapeHtml(titleCase(result.overallRisk))} Risk</span>
793
+ ${renderProvisionalBadge(session)}
794
+ </div>
795
+ ${renderProvisionalReason(session)}
796
+ ${result.recommendation ? `<div class="pr-helper-risk-headline-takeaway pr-helper-markdown">${renderMarkdown(result.recommendation)}</div>` : ""}
797
+ </div>
798
+ <div class="pr-helper-overview-stats">
799
+ ${renderOverviewStat(metrics.sections, "Themes", "guide")}
800
+ ${renderOverviewStat(metrics.issues, "Issues", "issues")}
801
+ ${renderOverviewStat(metrics.questions, "Questions", "questions")}
802
+ ${renderCoverageStat(session, metrics.files)}
803
+ </div>
804
+ <div class="pr-helper-review-summary pr-helper-markdown">${renderMarkdown(result.summary)}</div>
805
+ ${renderSuggestedPath(result.sections, "Suggested review path")}
806
+ ${hasExplainedSimply(session) ? `<p class="pr-helper-explain-simply-done">Explained in simple terms. See the Assistant tab.</p>` : `<button type="button" class="pr-helper-explain-simply-button">Explain in simple terms</button>`}
807
+ ${renderReviewDetails(session)}
808
+ <h4>Next steps</h4>
809
+ <ul>${nextSteps}</ul>
810
+ `;
811
+ }
812
+ function renderFileLocation(file, className, line) {
813
+ if (!file) return "";
814
+ const { dir, name } = splitFilePath(file);
815
+ const suffix = line ? `:${line}` : "";
816
+ const dirLine = dir ? `<span class="pr-helper-path-dir">${escapeHtml(dir)}</span>` : "";
817
+ const lineAttr = line ? ` data-line="${line}"` : "";
818
+ return `<span class="${className}" data-file="${escapeHtml(file)}"${lineAttr} title="${escapeHtml(file + suffix)}">${dirLine}<span class="pr-helper-path-name">${escapeHtml(name + suffix)}</span></span>`;
819
+ }
820
+ function discussionDetails(session) {
821
+ const stats = session.metadata.discussionStats;
822
+ if (!stats) return [];
823
+ const conversation = stats.conversationComments ?? 0;
824
+ const decisions = stats.reviewDecisions ?? 0;
825
+ const reviewThreads = stats.resolvedThreads === void 0 ? Math.max(0, stats.totalThreads - conversation) : stats.unresolvedThreads + stats.resolvedThreads;
826
+ const parts = [];
827
+ if (reviewThreads > 0) {
828
+ const outdated = stats.outdatedThreads ?? 0;
829
+ const state = stats.unresolvedThreads > 0 ? `${stats.unresolvedThreads} unresolved${outdated > 0 ? `, ${outdated} on outdated lines` : ""}` : "all resolved";
830
+ parts.push(`${reviewThreads} ${plural(reviewThreads, "review thread")} (${state})`);
831
+ }
832
+ if (conversation > 0) parts.push(`${conversation} ${plural(conversation, "conversation comment")}`);
833
+ if (decisions > 0) parts.push(`${decisions} ${plural(decisions, "review decision")}`);
834
+ return parts;
835
+ }
836
+ function plural(count, word) {
837
+ return count === 1 ? word : `${word}s`;
838
+ }
839
+ function renderExistingDiscussion(ref) {
840
+ if (!ref) return "";
841
+ const state = ref.status === "resolved" ? "thread since marked resolved" : "thread still unresolved";
842
+ const label = `<strong class="pr-helper-issue-discussion-lead">${escapeHtml(ref.relation === "related" ? "Related discussion" : "Already raised")}</strong>${escapeHtml(` by @${ref.raisedBy} · ${state}`)}`;
843
+ const body = ref.url ? `<a class="pr-helper-issue-discussion-link" href="${escapeHtml(ref.url)}" data-discussion-url="${escapeHtml(ref.url)}" rel="noopener noreferrer">${label}</a>` : label;
844
+ return `<p class="pr-helper-issue-discussion pr-helper-issue-discussion-${escapeHtml(ref.status)}">${body}</p>`;
845
+ }
846
+ function buildSectionLookup(result) {
847
+ const map = /* @__PURE__ */ new Map();
848
+ for (const section of result?.sections ?? []) map.set(section.id, section);
849
+ return map;
850
+ }
851
+ var SECTION_ATTENTION_LABELS = {
852
+ high: "High Priority",
853
+ medium: "Medium Priority",
854
+ low: "Low Priority"
855
+ };
856
+ var WEIGHT_MINUTE_RANGES = {
857
+ 1: [2, 4],
858
+ 2: [4, 7],
859
+ 3: [7, 12],
860
+ 4: [12, 18],
861
+ 5: [18, 25]
862
+ };
863
+ function estimateMinutesForWeight(weight) {
864
+ return WEIGHT_MINUTE_RANGES[weight];
865
+ }
866
+ function roundedMinutes(sections) {
867
+ const total = sections.reduce((sum, s) => {
868
+ const [min, max] = estimateMinutesForWeight(s.reviewWeight);
869
+ return sum + (min + max) / 2;
870
+ }, 0);
871
+ return Math.round(total / 5) * 5;
872
+ }
873
+ function estimateTotalMinutes(sections) {
874
+ return Math.max(5, roundedMinutes(sections));
875
+ }
876
+ function nextRecommendedSection(sections, completed) {
877
+ const outstanding = sections.filter((s) => !completed.has(s.id));
878
+ if (outstanding.length === 0) return null;
879
+ return outstanding.find((s) => s.recommendedStartingPoint) ?? outstanding[0];
880
+ }
881
+ function deriveSectionStatuses(sections, completed) {
882
+ const active = nextRecommendedSection(sections, completed);
883
+ return new Map(sections.map((s) => [s.id, completed.has(s.id) ? "completed" : s.id === active?.id ? "active" : "pending"]));
884
+ }
885
+ function remainingMinutes(sections, completed) {
886
+ const outstanding = sections.filter((s) => !completed.has(s.id));
887
+ if (outstanding.length === 0) return 0;
888
+ return Math.max(1, roundedMinutes(outstanding));
889
+ }
890
+ var GUIDE_PATH_HEADING = "Buddy's suggested review path";
891
+ function renderSuggestedPath(sections, heading) {
892
+ if (sections.length === 0) return "";
893
+ const items = sections.map((s) => `<li>${escapeHtml(s.title)}</li>`).join("");
894
+ return `
895
+ <div class="pr-helper-suggested-path">
896
+ <h4 class="pr-helper-suggested-path-title">${escapeHtml(heading)}</h4>
897
+ <ol class="pr-helper-suggested-path-list">${items}</ol>
898
+ <p class="pr-helper-suggested-path-time">Estimated review time ≈${estimateTotalMinutes(sections)} minutes</p>
899
+ </div>
900
+ `;
901
+ }
902
+ function renderGuideProgressSummary(sections, completed) {
903
+ const total = sections.length;
904
+ const done = sections.filter((s) => completed.has(s.id)).length;
905
+ if (done === 0) return renderSuggestedPath(sections, GUIDE_PATH_HEADING);
906
+ const counter = `<p class="pr-helper-progress-counter"><span class="pr-helper-progress-check" aria-hidden="true">✓</span>${done} of ${total} theme${total === 1 ? "" : "s"} completed</p>`;
907
+ const bar = `
908
+ <div class="pr-helper-progress-bar" role="progressbar" aria-valuenow="${done}" aria-valuemin="0" aria-valuemax="${total}">
909
+ <div class="pr-helper-progress-bar-fill" style="width: ${Math.round(done / total * 100)}%"></div>
910
+ </div>
911
+ `;
912
+ const next = nextRecommendedSection(sections, completed);
913
+ if (!next) return `
914
+ <div class="pr-helper-suggested-path pr-helper-suggested-path-complete">
915
+ <h4 class="pr-helper-suggested-path-title">Review complete</h4>
916
+ ${counter}
917
+ ${bar}
918
+ <p class="pr-helper-suggested-path-time">Every theme reviewed. Check the Issues tab before you submit.</p>
919
+ </div>
920
+ `;
921
+ return `
922
+ <div class="pr-helper-suggested-path">
923
+ <h4 class="pr-helper-suggested-path-title">${escapeHtml(GUIDE_PATH_HEADING)}</h4>
924
+ ${counter}
925
+ ${bar}
926
+ <p class="pr-helper-progress-next"><span class="pr-helper-progress-next-label">Next recommended:</span> ${escapeHtml(next.title)}</p>
927
+ <p class="pr-helper-suggested-path-time">Estimated remaining ≈${remainingMinutes(sections, completed)} minutes</p>
928
+ </div>
929
+ `;
930
+ }
931
+ function renderGuideSectionCard(section, counts, activeGuidedSectionId, status, activeBadgeLabel) {
932
+ const files = section.files.map((f) => {
933
+ const focus = f.focus ? `<div class="pr-helper-guide-file-focus">${renderInlineText(f.focus)}</div>` : "";
934
+ const { dir, name } = splitFilePath(f.path);
935
+ const dirLine = dir ? `<span class="pr-helper-path-dir">${escapeHtml(dir)}</span>` : "";
936
+ return `<li class="pr-helper-guide-file" data-file="${escapeHtml(f.path)}" data-guide-section="${escapeHtml(section.id)}" title="${escapeHtml(f.path)}">${dirLine}<span class="pr-helper-path-name">${escapeHtml(name)}</span>${focus}</li>`;
937
+ }).join("") || "<li class=\"pr-helper-review-empty\">No files listed.</li>";
938
+ const checklist = section.checklist.map((item) => `<li>${renderInlineText(item)}</li>`).join("") || "<li>None</li>";
939
+ const [minMinutes, maxMinutes] = estimateMinutesForWeight(section.reviewWeight);
940
+ const statParts = [`${section.files.length} file${section.files.length === 1 ? "" : "s"}`];
941
+ if (counts.issues > 0) statParts.push(`${counts.issues} issue${counts.issues === 1 ? "" : "s"}`);
942
+ if (counts.questions > 0) statParts.push(`${counts.questions} question${counts.questions === 1 ? "" : "s"}`);
943
+ statParts.push(`~${minMinutes}-${maxMinutes} min`);
944
+ const isActive = activeGuidedSectionId === section.id;
945
+ const isCompleted = status === "completed";
946
+ const check = isCompleted ? `<span class="pr-helper-guide-check" aria-hidden="true">✓</span>` : "";
947
+ const statusBadge = isCompleted ? `<span class="pr-helper-guide-badge pr-helper-completed-badge">Completed</span>` : status === "active" ? `<span class="pr-helper-guide-badge pr-helper-start-here-badge">${escapeHtml(activeBadgeLabel)}</span>` : "";
948
+ const undo = isCompleted ? `<button type="button" class="pr-helper-guide-uncomplete" data-guide-uncomplete="${escapeHtml(section.id)}">Mark as not reviewed</button>` : "";
949
+ return `
950
+ <section class="pr-helper-guide-section${isCompleted ? " pr-helper-guide-section-completed" : ""}" id="pr-helper-guide-section-${escapeHtml(section.id)}" data-guide-section-card="${escapeHtml(section.id)}">
951
+ <button type="button" class="pr-helper-guide-section-header" data-guide-toggle="${escapeHtml(section.id)}">
952
+ <div class="pr-helper-guide-section-badges">
953
+ ${check}
954
+ ${statusBadge}
955
+ <span class="pr-helper-guide-badge pr-helper-attention-badge pr-helper-attention-${section.reviewAttention}">${escapeHtml(SECTION_ATTENTION_LABELS[section.reviewAttention])}</span>
956
+ </div>
957
+ <div class="pr-helper-guide-section-header-top">
958
+ <span class="pr-helper-guide-disclosure">▼</span>
959
+ <span class="pr-helper-guide-section-title">${escapeHtml(section.title)}</span>
960
+ </div>
961
+ <div class="pr-helper-guide-meta-row" title="Review weight ${section.reviewWeight}/5">${escapeHtml(statParts.join(" · "))}</div>
962
+ </button>
963
+ <div class="pr-helper-guide-section-body">
964
+ <p class="pr-helper-guide-summary">${renderInlineText(section.oneLineSummary)}</p>
965
+ ${section.purpose ? `<p class="pr-helper-guide-purpose">${renderInlineText(section.purpose)}</p>` : ""}
966
+ <h5>Files</h5>
967
+ <ul class="pr-helper-guide-files">${files}</ul>
968
+ ${section.whyReview ? `<h5>Why review this</h5><p class="pr-helper-guide-why">${renderInlineText(section.whyReview)}</p>` : ""}
969
+ <h5>Review checklist</h5>
970
+ <ul class="pr-helper-guide-checklist">${checklist}</ul>
971
+ <div class="pr-helper-guide-section-actions">
972
+ <button type="button" class="pr-helper-guide-start-cta" data-guide-start="${escapeHtml(section.id)}">
973
+ ${isActive ? "Resume guided review →" : isCompleted ? "Review again →" : "Start guided review →"}
974
+ </button>
975
+ ${undo}
976
+ </div>
977
+ </div>
978
+ </section>
979
+ `;
980
+ }
981
+ /**
982
+ * Areas the plan named but did not read — the Guide's second half, and deliberately not mixed
983
+ * into its first.
984
+ *
985
+ * The invariant this renders: **Guide themes are based on reviewed code; suggested themes
986
+ * are identified from the manifest only.** A section can tell the reviewer what a change does
987
+ * because the model read it. These cannot — nobody read them — so they carry a heading that says
988
+ * so, no findings, and no implication that they have been assessed.
989
+ *
990
+ * They exist because the alternative is worse. A capped review used to end with 432 files simply
991
+ * absent; naming what was left and why is what turns the end of the review into the start of the
992
+ * reviewer's own work.
993
+ */
994
+ /**
995
+ * The action that turns a suggested area into an analysed one.
996
+ *
997
+ * Phrased as work the product does — "Analyse Infrastructure changes" — never as a place the
998
+ * analysis stopped. The label names the area rather than a count, because "analyse 18 files" is a
999
+ * chore and "analyse the deployment configuration" is a decision the reviewer can actually make.
1000
+ *
1001
+ * Offered only when `paths` is present. Sessions stored before that field carry a file count but
1002
+ * nothing to scope a follow-up with, and an action that cannot run is worse than no action.
1003
+ */
1004
+ function renderAreaAction(area, busyLabel) {
1005
+ if (!area.paths?.length) return "";
1006
+ if (busyLabel === area.title) return `
1007
+ <div class="pr-helper-suggested-area-running">
1008
+ <span class="pr-helper-suggested-area-busy">Analysing…</span>
1009
+ <button class="pr-helper-suggested-area-cancel" data-follow-up-cancel>Cancel</button>
1010
+ </div>
1011
+ `;
1012
+ const disabled = busyLabel !== null;
1013
+ return `
1014
+ <button class="pr-helper-suggested-area-action" data-follow-up-area="${escapeHtml(area.title)}"${disabled ? " disabled" : ""}>
1015
+ ${area.partiallyAnalysed ? "Analyse the rest of" : "Analyse"} ${escapeHtml(area.title)}
1016
+ </button>
1017
+ `;
1018
+ }
1019
+ function renderSuggestedAreas(session, busyLabel) {
1020
+ const areas = composeReview(session)?.suggestedAreas ?? [];
1021
+ const notNamed = session.metadata.fileCoverage?.notNamedCount ?? 0;
1022
+ if (areas.length === 0 && notNamed === 0) return "";
1023
+ const card = (area) => `
1024
+ <div class="pr-helper-suggested-area">
1025
+ <div class="pr-helper-suggested-area-head">
1026
+ <span class="pr-helper-suggested-area-title">${escapeHtml(area.title)}</span>
1027
+ <span class="pr-helper-suggested-area-count">${area.fileCount} file${area.fileCount === 1 ? "" : "s"}</span>
1028
+ </div>
1029
+ ${area.why ? `<p class="pr-helper-suggested-area-why">${escapeHtml(area.why)}</p>` : ""}
1030
+ ${area.riskNote ? `<p class="pr-helper-suggested-area-risk">Watch for: ${escapeHtml(area.riskNote)}</p>` : ""}
1031
+ ${renderAreaAction(area, busyLabel)}
1032
+ </div>
1033
+ `;
1034
+ const unfinished = areas.filter((area) => area.partiallyAnalysed);
1035
+ const suggested = areas.filter((area) => !area.partiallyAnalysed);
1036
+ const remainder = notNamed > 0 ? `<p class="pr-helper-suggested-area-remainder">${notNamed} further changed file${notNamed === 1 ? " was" : "s were"} not placed in any theme. See the Files tab for the full list.</p>` : "";
1037
+ return `${unfinished.length === 0 ? "" : `
1038
+ <div class="pr-helper-suggested-areas">
1039
+ <h4 class="pr-helper-suggested-areas-heading">Unfinished themes</h4>
1040
+ <p class="pr-helper-suggested-areas-intro">The analysis plan chose ${unfinished.length === 1 ? "this theme" : "these themes"} for in-depth analysis and the file ceiling stopped it partway. What is listed below is the remainder; the part that was read has ${unfinished.length === 1 ? "a theme" : "themes"} above.</p>
1041
+ ${unfinished.map(card).join("")}
1042
+ </div>
1043
+ `}${suggested.length === 0 && notNamed === 0 ? "" : `
1044
+ <div class="pr-helper-suggested-areas">
1045
+ <h4 class="pr-helper-suggested-areas-heading">Suggested themes</h4>
1046
+ <p class="pr-helper-suggested-areas-intro">Identified from the changed-file list, not analysed. Nothing below has been read, so none of it has been assessed.</p>
1047
+ ${suggested.map(card).join("")}
1048
+ ${remainder}
1049
+ </div>
1050
+ `}`;
1051
+ }
1052
+ function renderGuideTab(session, activeGuidedSectionId = null, followUpInFlight = null) {
1053
+ if (!session) return emptyState("Generate an analysis to see the Guide.");
1054
+ if (session.status === "generating") return emptyState("Analysis in progress…");
1055
+ if (session.status === "failed") return emptyState("No analysis available.");
1056
+ const result = composeReview(session);
1057
+ const sections = result?.sections ?? [];
1058
+ if (sections.length === 0) return emptyState("Guide not available for this analysis.");
1059
+ const completed = new Set(session.completedSectionIds ?? []);
1060
+ const statuses = deriveSectionStatuses(sections, completed);
1061
+ const activeBadgeLabel = [...statuses.values()].includes("completed") ? "Continue" : "Start here";
1062
+ const cards = sections.map((s) => {
1063
+ return renderGuideSectionCard(s, {
1064
+ issues: result?.issues.filter((i) => i.sectionId === s.id).length ?? 0,
1065
+ questions: result?.questions.filter((q) => q.sectionId === s.id).length ?? 0
1066
+ }, activeGuidedSectionId, statuses.get(s.id) ?? "pending", activeBadgeLabel);
1067
+ }).join("");
1068
+ return `
1069
+ <div class="pr-helper-guide">
1070
+ ${renderGuideProgressSummary(sections, completed)}
1071
+ <div class="pr-helper-guide-sections">${cards}</div>
1072
+ ${renderSuggestedAreas(session, followUpInFlight)}
1073
+ </div>
1074
+ `;
1075
+ }
1076
+ function renderSectionTag(sectionId, sections) {
1077
+ if (!sectionId) return "";
1078
+ const section = sections.get(sectionId);
1079
+ if (!section) return "";
1080
+ return `<button type="button"${pillAttrs("pr-helper-section-tag", SECTION_TAG_TIP)} data-guide-jump="${escapeHtml(section.id)}">${escapeHtml(section.title)}</button>`;
1081
+ }
1082
+ /**
1083
+ * How sure the model is that an issue is real — deliberately not shown when it is sure.
1084
+ *
1085
+ * Severity already earns a badge on every card, and a second always-present badge would turn the
1086
+ * header into a row of labels the eye stops reading. Confidence only carries information when it
1087
+ * is *not* high, so that is the only case that gets ink: "medium confidence" and "low confidence"
1088
+ * are qualifications, and a qualification nobody notices is the same as not making it.
1089
+ *
1090
+ * Absent means the model did not state one, which renders as nothing rather than a guessed middle.
1091
+ */
1092
+ function renderIssueConfidence(confidence) {
1093
+ if (!confidence || confidence === "high") return "";
1094
+ return `<span${pillAttrs(`pr-helper-issue-confidence pr-helper-issue-confidence-${confidence}`, confidenceTip(confidence))}>${escapeHtml(confidence)} confidence</span>`;
1095
+ }
1096
+ /**
1097
+ * What became of a finding when the pull request moved on. Absent on an ordinary analysis, which
1098
+ * has no prior state to report — those cards render exactly as they always have.
1099
+ *
1100
+ * Placed first among the badges, before the section tag and severity: on a reconciled analysis this
1101
+ * is the axis the reviewer is scanning for. They already know what the findings were; what they
1102
+ * came back to learn is which ones the author dealt with.
1103
+ */
1104
+ function renderFindingStatus(status) {
1105
+ if (!status) return "";
1106
+ return `<span${pillAttrs(`pr-helper-finding-status pr-helper-finding-status-${status}`, findingStatusTip(status))}>${escapeHtml(findingStatusLabel(status))}</span>`;
1107
+ }
1108
+ /**
1109
+ * The same pill, for a question.
1110
+ *
1111
+ * A separate function rather than a widened one, because the two draw from different vocabularies:
1112
+ * a finding is `resolved` and a question is `answered`, which are different events. Nothing fixed
1113
+ * the question; somebody now knows the answer.
1114
+ */
1115
+ function renderQuestionStatus(status) {
1116
+ if (!status) return "";
1117
+ return `<span${pillAttrs(`pr-helper-finding-status pr-helper-finding-status-${status === "answered" ? "resolved" : status}`, questionStatusTip(status))}>${escapeHtml(questionStatusLabel(status))}</span>`;
1118
+ }
1119
+ /** The model's one-line reason for a status, when it gave one. */
1120
+ function renderReconciliationNote(note) {
1121
+ if (!note?.trim()) return "";
1122
+ return `<p class="pr-helper-reconciliation-note">${renderInlineText(note)}</p>`;
1123
+ }
1124
+ /**
1125
+ * Escalation to a repository-aware coding agent, on the findings that might actually need one.
1126
+ *
1127
+ * Only issues and questions carry it. Those are the two things a reviewer can be *uncertain*
1128
+ * about, and uncertainty is the only state a repository investigation resolves — the Overview,
1129
+ * the Guide and the Files tab describe what the change is, which the diff already settled.
1130
+ *
1131
+ * Visible rather than tucked into a menu: if the escalation is worth building it is worth
1132
+ * seeing, and a reviewer who has to discover it will not reach for it at the moment they are
1133
+ * stuck. Outlined rather than filled — see sidebar.css.
1134
+ *
1135
+ * Text only, no leading icon. Every other action in the sidebar (Jump to file, Mark answered,
1136
+ * Explain, Analyse, Copy) is a bare label, and an emoji here read as decoration on a control
1137
+ * whose neighbours have none rather than as emphasis.
1138
+ */
1139
+ function renderInvestigateButton(attribute, id, label) {
1140
+ return `<button type="button" class="pr-helper-investigate-btn" ${attribute}="${escapeHtml(id)}" title="Generate a prompt to investigate this with your repository-aware AI">${escapeHtml(label)}</button>`;
1141
+ }
1142
+ /**
1143
+ * The two halves of the investigation loop, side by side on the finding they concern.
1144
+ *
1145
+ * Outbound ("Investigate Repository") generates the prompt; inbound ("Add Investigation") brings
1146
+ * the agent's answer back. Pairing them here rather than putting the inbound action somewhere
1147
+ * general is the whole mental model: the finding is the entry point, the reviewer goes away, and
1148
+ * they return to the same place. A generic "add context" control elsewhere in the sidebar would
1149
+ * make the round trip something the reviewer has to assemble for themselves.
1150
+ *
1151
+ * State comes from the **newest** record for this finding, because records accumulate — a reviewer
1152
+ * who has just pressed "Investigate Again" is mid-investigation whatever earlier rounds concluded.
1153
+ * "Add Investigation" names what the reviewer is doing; "Paste Results" would name the mechanism,
1154
+ * which the modal explains once they are in it.
1155
+ */
1156
+ function renderInvestigationActions(kind, id, records, investigatingId) {
1157
+ const attribute = kind === "issue" ? "data-investigate-issue" : "data-investigate-question";
1158
+ const addAttribute = kind === "issue" ? "data-add-investigation-issue" : "data-add-investigation-question";
1159
+ const newest = newestInvestigationForFinding(records, kind, id);
1160
+ if (investigatingId && newest?.id === investigatingId) return `${renderInvestigateButton(attribute, id, "Investigate Again")}<span class="pr-helper-investigate-applying" role="status">Applying…</span>`;
1161
+ if (!newest) return renderInvestigateButton(attribute, id, "Investigate Repository");
1162
+ const inbound = newest.state === "applied" ? "<span class=\"pr-helper-investigate-added\" aria-label=\"Investigation added\">Investigation Added ✓</span>" : `<button type="button" class="pr-helper-investigate-btn pr-helper-investigate-btn-add" ${addAttribute}="${escapeHtml(id)}" title="Paste the result from your repository-aware AI">Add Investigation</button>`;
1163
+ return `${renderInvestigateButton(attribute, id, "Investigate Again")}${inbound}`;
1164
+ }
1165
+ /**
1166
+ * One line per applied investigation, oldest first.
1167
+ *
1168
+ * All of them, not just the most recent: a finding investigated twice was shaped by two pieces of
1169
+ * evidence, and showing only the latest would quietly retire the first from the record while its
1170
+ * effect on the analysis remains.
1171
+ */
1172
+ function renderInvestigationProvenance(kind, id, records, now) {
1173
+ const applied = investigationsForFinding(records, kind, id).filter((r) => r.state === "applied");
1174
+ if (!applied.length) return "";
1175
+ return applied.map((record) => `
1176
+ <p class="pr-helper-investigation-provenance">
1177
+ <span class="pr-helper-investigation-provenance-label">Repository investigation</span>
1178
+ <span class="pr-helper-investigation-provenance-agent">${escapeHtml(record.agent ?? "Unknown agent")}</span>
1179
+ <span class="pr-helper-investigation-provenance-time">${escapeHtml(formatRelativeTime(record.appliedAt ?? record.requestedAt, now))}</span>
1180
+ <button type="button" class="pr-helper-investigation-view" data-view-investigation="${escapeHtml(record.id)}">View investigation</button>
1181
+ </p>
1182
+ `).join("");
1183
+ }
1184
+ /**
1185
+ * What a repository investigation concluded about this finding.
1186
+ *
1187
+ * Sits beside renderFindingStatus rather than replacing it: the two axes answer different questions
1188
+ * and a finding may carry both, so `[Still open] [Revised]` is a coherent and useful pair rather
1189
+ * than a contradiction to be resolved in the renderer.
1190
+ */
1191
+ function renderInvestigationImpact(impact) {
1192
+ if (!impact) return "";
1193
+ return `<span${pillAttrs(`pr-helper-investigation-impact pr-helper-investigation-impact-${impact}`, investigationImpactTip(impact))}>${escapeHtml(investigationImpactLabel(impact))}</span>`;
1194
+ }
1195
+ /**
1196
+ * `major → minor`, when an investigation moved a grade.
1197
+ *
1198
+ * Rendered only on an actual change, because priorSeverity/priorConfidence are only set on one (see
1199
+ * applyReassessment). Showing where a value came from matters more here than anywhere else in the
1200
+ * product: a reviewer who saw "major" yesterday and "minor" today, with no arrow, has been given a
1201
+ * different answer with no indication that anything moved it.
1202
+ */
1203
+ function renderSeverityRevision(prior, current) {
1204
+ if (!prior || prior === current) return "";
1205
+ return `<p class="pr-helper-investigation-revision"><span class="pr-helper-investigation-revision-from">${escapeHtml(prior)}</span> → <span class="pr-helper-investigation-revision-to">${escapeHtml(current)}</span></p>`;
1206
+ }
1207
+ /** The model's one-line reason for an impact, when it gave one. */
1208
+ function renderInvestigationNote(note) {
1209
+ if (!note?.trim()) return "";
1210
+ return `<p class="pr-helper-investigation-note">${renderInlineText(note)}</p>`;
1211
+ }
1212
+ function renderIssuesTab(session, investigatingId) {
1213
+ if (!session) return emptyState("Generate an analysis to see issues.");
1214
+ if (session.status === "generating") return emptyState("Analysis in progress…");
1215
+ if (session.status === "failed") return emptyState("No analysis available.");
1216
+ const result = composeReview(session);
1217
+ if (!result || result.issues.length === 0) return emptyState("No issues found.");
1218
+ const sections = buildSectionLookup(result);
1219
+ const records = session.investigations;
1220
+ const now = Date.now();
1221
+ return result.issues.map((issue) => `
1222
+ <div class="pr-helper-issue-card pr-helper-severity-${issue.severity}${issue.reconciliationStatus ? ` pr-helper-issue-${issue.reconciliationStatus}` : ""}${issue.investigationImpact ? ` pr-helper-issue-investigation-${issue.investigationImpact}` : ""}" data-finding-card="${escapeHtml(issue.id)}">
1223
+ <div class="pr-helper-issue-header">
1224
+ <p class="pr-helper-issue-title">${renderInlineText(issue.title)}</p>
1225
+ <div class="pr-helper-issue-badges">
1226
+ ${renderFindingStatus(issue.reconciliationStatus)}
1227
+ ${renderInvestigationImpact(issue.investigationImpact)}
1228
+ ${renderSectionTag(issue.sectionId, sections)}
1229
+ ${renderIssueConfidence(issue.confidence)}
1230
+ <span${pillAttrs("pr-helper-severity-badge", severityTip(issue.severity))}>${escapeHtml(issue.severity)}</span>
1231
+ </div>
1232
+ </div>
1233
+ ${renderSeverityRevision(issue.priorSeverity, issue.severity)}
1234
+ <p class="pr-helper-issue-desc">${renderInlineText(issue.description)}</p>
1235
+ ${renderReconciliationNote(issue.reconciliationNote)}
1236
+ ${renderInvestigationNote(issue.investigationNote)}
1237
+ ${renderExistingDiscussion(issue.existingDiscussion)}
1238
+ ${renderFileLocation(issue.file, "pr-helper-issue-loc", issue.line)}
1239
+ ${renderInvestigationProvenance("issue", issue.id, records, now)}
1240
+ <div class="pr-helper-issue-actions">
1241
+ ${renderInvestigationActions("issue", issue.id, records, investigatingId)}
1242
+ </div>
1243
+ </div>
1244
+ `).join("");
1245
+ }
1246
+ function renderQuestionCard(q, sections, showTag, answered, records, now = Date.now(), investigatingId, options) {
1247
+ const importanceBadge = q.importance ? `<span${pillAttrs(`pr-helper-question-importance-badge pr-helper-question-importance-${q.importance}`, importanceTip(q.importance))}>${escapeHtml(q.importance)}</span>` : "";
1248
+ const jumpLink = renderQuestionJump(q.file, options);
1249
+ const answerToggle = `<button type="button" class="pr-helper-question-answer-toggle" data-question-answered="${escapeHtml(q.id)}" data-answered="${answered}">${answered ? "Mark as unanswered" : "Mark answered"}</button>`;
1250
+ const investigationAnswer = q.investigationAnswer?.trim() ? `<h5>Answer from investigation</h5><p class="pr-helper-issue-desc pr-helper-investigation-answer">${renderInlineText(q.investigationAnswer)}</p>` : "";
1251
+ const reconciliationAnswer = q.reconciliationAnswer?.trim() ? `<h5>Answered by the new changes</h5><p class="pr-helper-issue-desc pr-helper-investigation-answer">${renderInlineText(q.reconciliationAnswer)}</p>` : "";
1252
+ return `
1253
+ <div class="pr-helper-question-card${answered ? " pr-helper-question-card-answered" : ""}${q.investigationImpact ? ` pr-helper-issue-investigation-${q.investigationImpact}` : ""}" data-finding-card="${escapeHtml(q.id)}">
1254
+ <div class="pr-helper-issue-header">
1255
+ <p class="pr-helper-issue-title">${renderInlineText(q.question)}</p>
1256
+ <div class="pr-helper-issue-badges">
1257
+ ${renderQuestionStatus(q.reconciliationStatus)}
1258
+ ${renderInvestigationImpact(q.investigationImpact)}
1259
+ ${showTag ? renderSectionTag(q.sectionId, sections) : ""}
1260
+ ${importanceBadge}
1261
+ </div>
1262
+ </div>
1263
+ ${q.whyThisMatters ? `<h5>Why this matters</h5><p class="pr-helper-issue-desc">${renderInlineText(q.whyThisMatters)}</p>` : ""}
1264
+ ${reconciliationAnswer}
1265
+ ${renderReconciliationNote(q.reconciliationNote)}
1266
+ ${investigationAnswer}
1267
+ ${renderInvestigationNote(q.investigationNote)}
1268
+ ${q.file ? `<h5>Files</h5>${renderFileLocation(q.file, "pr-helper-issue-loc", void 0)}` : ""}
1269
+ ${renderInvestigationProvenance("question", q.id, records, now)}
1270
+ <div class="pr-helper-question-actions">
1271
+ ${jumpLink}
1272
+ <div class="pr-helper-question-actions-group">
1273
+ ${renderInvestigationActions("question", q.id, records, investigatingId)}
1274
+ ${answerToggle}
1275
+ </div>
1276
+ </div>
1277
+ </div>
1278
+ `;
1279
+ }
1280
+ /**
1281
+ * The counter's contents, split out so a caller that toggles one question can refresh it without
1282
+ * re-rendering the tab.
1283
+ *
1284
+ * The extension redraws the whole Questions tab on every toggle and never needed this. The
1285
+ * plugin's workspace updates the card in place, so its counter sat frozen at "2 of 2 unanswered"
1286
+ * while a card visibly turned green above it. Exported rather than reimplemented there, because
1287
+ * two places wording the same count differently is how "All 5 questions answered" and "0 of 5
1288
+ * unanswered" end up on the same product.
1289
+ */
1290
+ function questionsProgressBody(total, outstanding) {
1291
+ return outstanding === 0 ? `<span class="pr-helper-questions-progress-check" aria-hidden="true">✓</span><span class="pr-helper-questions-progress-count">All ${total} questions answered</span>` : `<span class="pr-helper-questions-progress-count">${outstanding} of ${total} unanswered</span>`;
1292
+ }
1293
+ function renderQuestionsProgress(questions, answered) {
1294
+ const total = questions.length;
1295
+ if (total < 2) return "";
1296
+ const outstanding = questions.filter((q) => !answered.has(q.id)).length;
1297
+ return `<div class="pr-helper-questions-progress" role="status">${questionsProgressBody(total, outstanding)}</div>`;
1298
+ }
1299
+ function renderQuestionJump(file, options) {
1300
+ if (!file || options?.suppressFileJump) return "";
1301
+ return `<button type="button" class="pr-helper-question-jump" data-file="${escapeHtml(file)}">Jump to file →</button>`;
1302
+ }
1303
+ function renderQuestionsTab(session, investigatingId, options) {
1304
+ if (!session) return emptyState("Generate an analysis to see open questions.");
1305
+ if (session.status === "generating") return emptyState("Analysis in progress…");
1306
+ if (session.status === "failed") return emptyState("No analysis available.");
1307
+ const result = composeReview(session);
1308
+ if (!result || result.questions.length === 0) return emptyState("No open questions.");
1309
+ const sections = buildSectionLookup(result);
1310
+ const answered = new Set(session.answeredQuestionIds ?? []);
1311
+ const progress = renderQuestionsProgress(result.questions, answered);
1312
+ const records = session.investigations;
1313
+ const now = Date.now();
1314
+ if (result.sections.length === 0) return `${progress}${result.questions.map((q) => renderQuestionCard(q, sections, true, answered.has(q.id), records, now, investigatingId, options)).join("")}`;
1315
+ const grouped = /* @__PURE__ */ new Map();
1316
+ const ungrouped = [];
1317
+ for (const q of result.questions) if (q.sectionId && sections.has(q.sectionId)) {
1318
+ const list = grouped.get(q.sectionId) ?? [];
1319
+ list.push(q);
1320
+ grouped.set(q.sectionId, list);
1321
+ } else ungrouped.push(q);
1322
+ return `${progress}${result.sections.filter((s) => grouped.has(s.id)).map((s) => `
1323
+ <div class="pr-helper-question-group">
1324
+ <h5 class="pr-helper-question-group-title">${escapeHtml(s.title)}</h5>
1325
+ ${grouped.get(s.id).map((q) => renderQuestionCard(q, sections, false, answered.has(q.id), records, now, investigatingId, options)).join("")}
1326
+ </div>
1327
+ `).join("")}${ungrouped.map((q) => renderQuestionCard(q, sections, false, answered.has(q.id), records, now, investigatingId, options)).join("")}`;
1328
+ }
1329
+ //#endregion
1330
+ //#region ../../packages/review-harness/src/workspace/file_links.ts
1331
+ /**
1332
+ * Where a file lives on the pull request, when there is one.
1333
+ *
1334
+ * There used to be a "Jump to file" button beside a question that did exactly what clicking the
1335
+ * file name does, which is open the file in this workspace. Two controls, one behaviour, and the
1336
+ * second one quietly teaching the reviewer that the first was worth ignoring. The useful second
1337
+ * destination is the pull request itself, where the file sits next to its comments and its review
1338
+ * controls.
1339
+ *
1340
+ * Shared because three surfaces need the same answer: the rendered page, the file viewer's route,
1341
+ * and the Files tab. A second copy of this decision is how one of them ends up linking somewhere
1342
+ * else.
1343
+ */
1344
+ function buildPrFileLink(attached, path) {
1345
+ if (!attached) return null;
1346
+ const { pullRequest } = attached;
1347
+ if (pullRequest.fileUrlTemplate) return {
1348
+ href: fillFileUrlTemplate(pullRequest.fileUrlTemplate, path),
1349
+ label: "Open in pull request",
1350
+ precise: true
1351
+ };
1352
+ return {
1353
+ href: pullRequest.filesUrl || pullRequest.url,
1354
+ label: "Open the pull request",
1355
+ precise: false
1356
+ };
1357
+ }
1358
+ //#endregion
1359
+ //#region ../../packages/review-harness/src/workspace/pr_context_view.ts
1360
+ /**
1361
+ * Everything that was imported from the pull request, shown as it was imported.
1362
+ *
1363
+ * This view is not a convenience. Every "already raised" line in a review rests on a conversation
1364
+ * the daemon never fetched and cannot verify — Claude Code produced the payload, using the
1365
+ * developer's own tooling, and the daemon has no way to check it against the real pull request.
1366
+ * What makes that claim honest rather than merely asserted is that the reviewer can read the
1367
+ * threads themselves and follow each one back to the comment it came from. A finding attributed
1368
+ * to a conversation that never happened is one click from being caught here.
1369
+ *
1370
+ * So: complete, in the order the processor bucketed it, with every permalink intact. Nothing is
1371
+ * summarised and nothing is hidden behind a "show more" — the budget trimming that happens
1372
+ * upstream is about what fits in a prompt, and has nothing to do with what a reviewer may read.
1373
+ */
1374
+ /**
1375
+ * The four buckets, in the order a reviewer needs them.
1376
+ *
1377
+ * Unresolved first because it is the live disagreement; outdated-unresolved next because the
1378
+ * anchor moved but the concern did not; conversation then resolved. This is the processor's own
1379
+ * priority order, not a second opinion about it.
1380
+ */
1381
+ var BUCKETS = [
1382
+ {
1383
+ key: "unresolved",
1384
+ title: "Unresolved",
1385
+ note: "Open threads on the current code."
1386
+ },
1387
+ {
1388
+ key: "unresolvedOutdated",
1389
+ title: "Unresolved, code has moved",
1390
+ note: "Still open, but written against a revision that has since changed."
1391
+ },
1392
+ {
1393
+ key: "conversation",
1394
+ title: "Conversation",
1395
+ note: "The main discussion, not anchored to a line."
1396
+ },
1397
+ {
1398
+ key: "resolved",
1399
+ title: "Resolved",
1400
+ note: "Marked resolved. Not proof the underlying problem is fixed."
1401
+ }
1402
+ ];
1403
+ function renderPrContextView(view, attribution, refresh) {
1404
+ if (!view) return renderNothingConnected();
1405
+ const { pullRequest, discussion } = view.prContext;
1406
+ const { processed } = view;
1407
+ const buckets = BUCKETS.map((bucket) => renderBucket(bucket.title, bucket.note, processed[bucket.key])).join("");
1408
+ return `<h3 class="prb-doc-title">PR context</h3>
1409
+ ${renderProvenance(pullRequest, discussion.fetchedAt, processed)}
1410
+ ${renderRefresh(refresh)}
1411
+ ${renderAttribution(attribution, processed)}
1412
+ ${discussion.truncated ? renderTruncation() : ""}
1413
+ ${renderDecisions(processed)}
1414
+ ${processed.all.length === 0 ? "<p class=\"prb-pr-empty\">This pull request has no comments yet.</p>" : buckets}`;
1415
+ }
1416
+ /**
1417
+ * Re-reading the threads, and what the last re-read found.
1418
+ *
1419
+ * The narrowest useful action in the product, and the copy has to keep it narrow. This corrects
1420
+ * what the review already claims about other people's threads — who raised one, whether it is
1421
+ * still open — and it cannot produce a finding, because deciding whether a new comment changes
1422
+ * anything is judgement and judgement is a fresh review. A reviewer who pressed this expecting a
1423
+ * re-review would be worse off than one who never saw the button.
1424
+ */
1425
+ function renderRefresh(refresh) {
1426
+ if (!refresh?.enabled) return "";
1427
+ const last = refresh.last;
1428
+ return `<div class="prb-pr-refresh">
1429
+ <button type="button" data-pr-recheck>Re-check the conversation</button>
1430
+ <span class="prb-pr-refresh-note">
1431
+ Fetches every thread again and updates what this review says about them. If someone has
1432
+ replied or resolved a thread since the review ran, the "Already raised" notes on the Issues
1433
+ view will be corrected to match, and any note pointing at a thread that has been deleted will
1434
+ be removed. Your code is not looked at again, so no findings are added, changed or removed.
1435
+ </span>
1436
+ ${last ? `<p class="prb-pr-refresh-last" data-ok="${last.ok}">
1437
+ Last re-checked ${escapeHtml(relativeTime$1(last.at))}. ${escapeHtml(last.message)}
1438
+ ${last.attributionsRestated ? escapeHtml(` ${last.attributionsRestated} finding${last.attributionsRestated === 1 ? "" : "s"} had its thread's state corrected.`.replace("its", last.attributionsRestated === 1 ? "its" : "their")) : ""}
1439
+ ${last.attributionsWithdrawn ? escapeHtml(` ${last.attributionsWithdrawn} claim${last.attributionsWithdrawn === 1 ? "" : "s"} withdrawn: the thread it cited is no longer in the conversation.`) : ""}
1440
+ </p>` : ""}
1441
+ </div>`;
1442
+ }
1443
+ /**
1444
+ * How much of this review the conversation actually touched.
1445
+ *
1446
+ * Without this the page has a hole exactly where the feature's own promise sits. A reviewer who
1447
+ * reads the threads here and then sees no "already raised" line anywhere cannot tell whether the
1448
+ * review compared its findings against this conversation and matched none, or never compared them
1449
+ * at all — and those call for completely different responses. Zero matches is an ordinary,
1450
+ * frequent outcome; it is silence about zero matches that is the problem.
1451
+ *
1452
+ * Counted from the findings themselves, so it can never disagree with what the Issues view shows.
1453
+ */
1454
+ function renderAttribution(attribution, processed) {
1455
+ if (!attribution || processed.all.length === 0) return "";
1456
+ const { findings, matched } = attribution;
1457
+ if (findings === 0) return "";
1458
+ return `<p class="prb-pr-attribution" data-matched="${matched}">${escapeHtml(matched === 0 ? `None of the ${findings} finding${findings === 1 ? "" : "s"} in this review matched a thread here. The comparison was made; nothing lined up.` : `${matched} of ${findings} finding${findings === 1 ? "" : "s"} in this review ${matched === 1 ? "is" : "are"} matched to a thread here. Each one says so on the Issues view.`)}</p>`;
1459
+ }
1460
+ /**
1461
+ * Said plainly, because the alternative is a reviewer assuming their findings were checked
1462
+ * against a conversation that was never read. "No comments" and "never looked" are different
1463
+ * facts and this is the page that has to tell them apart.
1464
+ */
1465
+ function renderNothingConnected() {
1466
+ return `<h3 class="prb-doc-title">PR context</h3>
1467
+ <p class="prb-pr-empty">
1468
+ No pull request discussion was brought into this review, so none of the findings have been
1469
+ checked against what other reviewers have already said.
1470
+ </p>
1471
+ <p class="prb-pr-empty">
1472
+ That usually means the branch does not have a pull request open yet, or that the tools on this
1473
+ machine could not sign in to fetch one. It does not mean the pull request is free of comments.
1474
+ Nobody looked.
1475
+ </p>`;
1476
+ }
1477
+ function renderProvenance(pullRequest, fetchedAt, processed) {
1478
+ const { stats } = processed;
1479
+ const parts = [
1480
+ `${stats.totalThreads} thread${stats.totalThreads === 1 ? "" : "s"}`,
1481
+ `${stats.totalComments} comment${stats.totalComments === 1 ? "" : "s"}`,
1482
+ `${stats.unresolvedCount} unresolved`
1483
+ ];
1484
+ return `<p class="prb-pr-provenance">
1485
+ <a href="${escapeHtml(pullRequest.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(pullRequest.provider)} #${pullRequest.number}</a>
1486
+ ${pullRequest.title ? `<span class="prb-pr-title">${escapeHtml(pullRequest.title)}</span>` : ""}
1487
+ <span class="prb-pr-counts">${parts.join(" &middot; ")}</span>
1488
+ <time datetime="${new Date(fetchedAt).toISOString()}">imported ${escapeHtml(relativeTime$1(fetchedAt))}</time>
1489
+ <span class="prb-pr-asof">This is a snapshot taken at that moment, not a live view. Anything that has happened on the pull request since then, including replies and threads being resolved, will not show up here until you re-check it.</span>
1490
+ </p>`;
1491
+ }
1492
+ /** Disclosed rather than absorbed: a review working from a partial record should say which part. */
1493
+ function renderTruncation() {
1494
+ return `<p class="prb-pr-truncated">
1495
+ This discussion arrived incomplete. Whatever fetched it reported that it could not retrieve
1496
+ everything, so some threads are missing from the list below. The review did not see those
1497
+ threads either, so treat its coverage of the discussion as partial.
1498
+ </p>`;
1499
+ }
1500
+ /**
1501
+ * The verdicts people left, drawn the same way their threads are.
1502
+ *
1503
+ * These used to be a bare list while every thread beside them was a card, so the one section
1504
+ * carrying a reviewer's overall position on the change looked like a footnote next to the
1505
+ * line-level nitpicks. Same card, same author line, same body treatment. A verdict is at least as
1506
+ * important as a comment on line 317.
1507
+ */
1508
+ function renderDecisions(processed) {
1509
+ if (processed.decisions.length === 0) return "";
1510
+ const cards = processed.decisions.map((decision) => `<article class="prb-pr-thread prb-pr-decision">
1511
+ <header>
1512
+ <span class="prb-pr-author">@${escapeHtml(decision.reviewer)}${decision.reviewerIsBot ? " (bot)" : ""}</span>
1513
+ <span class="prb-pr-verdict" data-verdict="${escapeHtml(decision.verdict)}">${escapeHtml(verdictLabel(decision.verdict))}</span>
1514
+ ${decision.timestamp ? `<time datetime="${escapeHtml(decision.timestamp)}">${escapeHtml(relativeTime$1(Date.parse(decision.timestamp)))}</time>` : ""}
1515
+ </header>
1516
+ ${decision.body ? renderBody(decision.body) : "<p class=\"prb-pr-nobody\">No comment left with this review.</p>"}
1517
+ </article>`).join("");
1518
+ return `<section class="prb-pr-bucket">
1519
+ <h4>Reviews <span class="prb-pr-bucket-count">${processed.decisions.length}</span></h4>
1520
+ <p class="prb-pr-bucket-note">Where each reviewer landed on the change as a whole.</p>
1521
+ ${cards}
1522
+ </section>`;
1523
+ }
1524
+ /**
1525
+ * Someone else's words, rendered as they wrote them.
1526
+ *
1527
+ * People write review comments in Markdown, so a comment that quotes a stack trace or a snippet
1528
+ * was arriving as one unbroken wall of text with stray backticks in it. That is the comment at its
1529
+ * least readable, in the one place the reviewer most needs to read it carefully.
1530
+ *
1531
+ * `renderMarkdown` escapes before it parses, so nothing in a comment can become markup this page
1532
+ * did not emit, and its href allow-list means a link is only ever a link. It was written for model
1533
+ * output injected into github.com, which is a harsher setting than this one.
1534
+ */
1535
+ function renderBody(body) {
1536
+ return `<div class="prb-pr-comment-body prb-pr-md">${renderMarkdown(body)}</div>`;
1537
+ }
1538
+ function renderBucket(title, note, threads) {
1539
+ if (threads.length === 0) return "";
1540
+ return `<section class="prb-pr-bucket">
1541
+ <h4>${escapeHtml(title)} <span class="prb-pr-bucket-count">${threads.length}</span></h4>
1542
+ <p class="prb-pr-bucket-note">${escapeHtml(note)}</p>
1543
+ ${threads.map(renderThread).join("")}
1544
+ </section>`;
1545
+ }
1546
+ function renderThread(thread) {
1547
+ const location = thread.location ? `<span class="prb-pr-loc">${escapeHtml(thread.location.file)}${thread.location.line !== null ? `:${thread.location.line}${thread.location.lineIsOriginal ? " (original)" : ""}` : ""}</span>` : "";
1548
+ return `<article class="prb-pr-thread" data-thread-id="${escapeHtml(thread.id)}">
1549
+ <header>
1550
+ ${location}
1551
+ ${thread.outdated ? "<span class=\"prb-pr-flag\">outdated</span>" : ""}
1552
+ ${thread.resolved ? "<span class=\"prb-pr-flag\">resolved</span>" : ""}
1553
+ </header>
1554
+ ${thread.comments.map(renderComment).join("")}
1555
+ ${thread.truncatedCommentCount > 0 ? `<p class="prb-pr-truncated">${thread.truncatedCommentCount} further comment${thread.truncatedCommentCount === 1 ? "" : "s"} in this thread were not imported.</p>` : ""}
1556
+ </article>`;
1557
+ }
1558
+ function renderComment(comment) {
1559
+ const who = `@${comment.author}${comment.authorIsBot ? " (bot)" : ""}`;
1560
+ return `<div class="prb-pr-comment">
1561
+ <p class="prb-pr-comment-meta">
1562
+ ${comment.url ? `<a href="${escapeHtml(comment.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(who)}</a>` : escapeHtml(who)}
1563
+ ${comment.timestamp ? `<time datetime="${escapeHtml(comment.timestamp)}">${escapeHtml(relativeTime$1(Date.parse(comment.timestamp)))}</time>` : ""}
1564
+ </p>
1565
+ ${renderBody(comment.body)}
1566
+ </div>`;
1567
+ }
1568
+ var VERDICT_LABELS = {
1569
+ approved: "approved",
1570
+ changes_requested: "requested changes",
1571
+ commented: "commented",
1572
+ dismissed: "dismissed"
1573
+ };
1574
+ function verdictLabel(verdict) {
1575
+ return VERDICT_LABELS[verdict] ?? verdict;
1576
+ }
1577
+ //#endregion
1578
+ //#region ../../packages/review-harness/src/workspace/update_view.ts
1579
+ /**
1580
+ * The Update review control, and what it says afterwards.
1581
+ *
1582
+ * Two pieces of copy that have to carry the same idea from opposite ends. Before: what pressing
1583
+ * this will do, in enough detail that a reviewer can decide whether to spend the minutes. After:
1584
+ * what it did, in numbers they can act on without opening anything.
1585
+ *
1586
+ * The one thing neither may do is describe the machinery. A reviewer does not want to know that a
1587
+ * fast-forward preceded a reconciliation. They want to know that the review they are reading is
1588
+ * about the code they have, and what changed in it while they were away.
1589
+ */
1590
+ /**
1591
+ * The primary action on a moved branch.
1592
+ *
1593
+ * A button rather than a command to copy, and that is a deliberate reversal of what the freshness
1594
+ * strip used to offer. The old reasoning was that a re-analysis is minutes of work that belongs in
1595
+ * the session where the reviewer can watch it, which is true of a *fresh review* and was quietly
1596
+ * being applied to everything. This is not a fresh review: it is bounded, it reads only the new
1597
+ * commits, it keeps the reviewer's place, and it refuses rather than improvising whenever the
1598
+ * working tree would need a judgement call. That is a button.
1599
+ *
1600
+ * The fresh review stays on offer beside it, because a branch rebuilt from scratch genuinely needs
1601
+ * one, but behind a disclosure rather than in the open. It is the rarer answer, and a raw slash
1602
+ * command sitting on the panel is the implementation showing through the product: the reviewer has
1603
+ * to read and dismiss it every time to get to the button they actually want.
1604
+ */
1605
+ function renderUpdateButton() {
1606
+ return `<button type="button" class="prb-update-btn" data-review-update>Update review</button>`;
1607
+ }
1608
+ /**
1609
+ * What pressing it does, and the way out for someone who wants a different thing entirely.
1610
+ *
1611
+ * Separate from the button rather than wrapped with it, because the panel lays out its own rows:
1612
+ * the controls sit on one line and this sits under them. Trying to do that with flex ordering
1613
+ * inside a wrapper put the sentence after `Dismiss`, which is exactly where it does not belong.
1614
+ */
1615
+ function renderUpdateAside(command) {
1616
+ return {
1617
+ alt: `<details class="prb-update-alt">
1618
+ <summary>Start fresh instead</summary>
1619
+ <span>A brand new review, with its own findings and its own path through the change. Run <code>${escapeHtml(command)}</code> in your session.<button type="button" class="prb-fresh-quiet" data-copy-command="${escapeHtml(command)}">Copy</button></span>
1620
+ </details>`,
1621
+ explain: `<p class="prb-update-explain">Reanalyses the new commits and re-reads the pull request conversation. Your progress and Buddy AI history are kept.</p>`
1622
+ };
1623
+ }
1624
+ /**
1625
+ * What the last update did, on the review it did it to.
1626
+ *
1627
+ * Shown after the reload that follows an update, and only then: it reports an event rather than a
1628
+ * state, so it is dismissible and does not come back. A stop is shown with the same weight as a
1629
+ * success, because "nothing happened and here is why" is the outcome most likely to be missed and
1630
+ * the only one the reviewer has to act on.
1631
+ */
1632
+ function renderUpdateOutcome(outcome) {
1633
+ if (!outcome) return "";
1634
+ return `<div class="prb-update-result" data-state="${outcome.ok ? "done" : outcome.stop === "refused" ? "refused" : "stopped"}" data-update-result role="status">
1635
+ <div class="prb-update-result-body">
1636
+ <p><strong>${escapeHtml(outcome.ok ? "Review updated" : outcome.stop === "refused" ? "There was nothing to update against" : "The review was not updated")}</strong> ${escapeHtml(outcome.message)}</p>
1637
+ ${renderFootnotes(outcome)}
1638
+ </div>
1639
+ <button type="button" class="prb-fresh-dismiss" data-update-close>Close</button>
1640
+ </div>`;
1641
+ }
1642
+ /**
1643
+ * The two things that can go quietly half-wrong, said out loud.
1644
+ *
1645
+ * Both are cases where the headline is a success and part of the work did not happen. A reviewer
1646
+ * who is told "Review updated" and not told the conversation could not be re-read will read the
1647
+ * "already raised by @sarah" lines as current, which is exactly the confident staleness the
1648
+ * freshness strip exists to prevent.
1649
+ */
1650
+ function renderFootnotes(outcome) {
1651
+ const notes = [];
1652
+ if (outcome.fetchFailed) notes.push("The remote could not be reached, so this used the commits your copy of the repository already had.");
1653
+ if (outcome.conversationOk === false) notes.push("The pull request conversation could not be re-read, so the notes about who raised what are as old as the review.");
1654
+ if (!notes.length) return "";
1655
+ return `<p class="prb-update-footnote">${notes.map((note) => escapeHtml(note)).join(" ")}</p>`;
1656
+ }
1657
+ //#endregion
1658
+ //#region ../../packages/review-harness/src/workspace/freshness_view.ts
1659
+ /**
1660
+ * Freshness, said in sentences.
1661
+ *
1662
+ * Rendered by one function shared between the server and the page's own client, because this is
1663
+ * the second renderer of a thing that already exists on the server and the codebase has been
1664
+ * bitten by that before — "two viewers would drift the first time one of them was improved". The
1665
+ * server paints it on load; the client repaints it after a re-check; both call this.
1666
+ *
1667
+ * Nothing here decides what the reviewer should do. It reports three comparisons and, where it
1668
+ * cannot make one, says so — including the third, which it can never make. The reason that matters
1669
+ * is the one the whole module is against: a review that goes stale silently is more dangerous than
1670
+ * one that admits it does not know, because the reader supplies the confidence themselves.
1671
+ */
1672
+ /**
1673
+ * What the reviewer runs to get a brand new review of the change.
1674
+ *
1675
+ * Still handed over rather than run from here, and the boundary is real: a fresh review is a fresh
1676
+ * set of findings, a fresh path through the change, and questions it may need answering along the
1677
+ * way. That belongs in the session where the analyser lives and the reviewer can watch it.
1678
+ *
1679
+ * Its own command rather than `/prreviewbuddy:review`, which now hands back the review of this
1680
+ * branch that already exists rather than making a second one. That is the safe default for the
1681
+ * command someone types most often, and it makes this string the only way to ask for another
1682
+ * review on purpose — which is exactly what the reviewer reading it is trying to do.
1683
+ *
1684
+ * What *is* run from here is the update, which is a different thing wearing similar clothes. It
1685
+ * reads only the commits since this review was made, it keeps the review and everything marked in
1686
+ * it, and it refuses rather than improvising whenever the working tree would need a judgement call.
1687
+ * See `update_view.ts`, which offers both side by side.
1688
+ */
1689
+ var REANALYSE_COMMAND = "/prreviewbuddy:fresh-review";
1690
+ /** Short summary for the header control. The reviewer should not have to open a panel to worry. */
1691
+ function freshnessLabel(report) {
1692
+ if (!report) return {
1693
+ label: "Check for updates",
1694
+ state: "unknown"
1695
+ };
1696
+ if (report.code.state === "changed") return {
1697
+ label: report.code.rewritten ? "Code rewritten" : "Code changed",
1698
+ state: "changed"
1699
+ };
1700
+ if (report.checkout.state === "changed") return {
1701
+ label: "Checkout differs",
1702
+ state: "changed"
1703
+ };
1704
+ if (report.code.state === "current") return {
1705
+ label: "Up to date",
1706
+ state: "current"
1707
+ };
1708
+ return {
1709
+ label: "Freshness unknown",
1710
+ state: "unknown"
1711
+ };
1712
+ }
1713
+ /**
1714
+ * The banner under the header, or nothing.
1715
+ *
1716
+ * Silent when every comparison came back clean and the reviewer has not asked, because a strip
1717
+ * that is always there is a strip nobody reads — and the one time it says something new is the
1718
+ * time it has to be noticed.
1719
+ */
1720
+ /**
1721
+ * The panel under the header: one status, one consequence, one primary action.
1722
+ *
1723
+ * This used to be a stack of independent sentence-rows, one per comparison, each with its own
1724
+ * amber dot and its own paragraph. Three comparisons meant three warnings of equal weight, so a
1725
+ * rewritten branch and a checkout that is merely behind shouted equally loudly, the action was
1726
+ * buried in the middle of the prose, and the whole thing pushed the review it describes off the
1727
+ * screen. Everything below is one restructure of that into a hierarchy.
1728
+ *
1729
+ * The code comparison is the status. Everything else is a footnote to it, because everything else
1730
+ * is only interesting once you know whether the findings still describe the code. The header chip
1731
+ * carries the same status in two words; this panel exists to say what it means and offer the way
1732
+ * out, so it does not repeat the chip's job.
1733
+ */
1734
+ function renderFreshness(report, options = {}) {
1735
+ if (!report) return "";
1736
+ const alarming = report.code.state === "changed" || report.checkout.state === "changed";
1737
+ const lost = report.code.reason === "missing-commit";
1738
+ const dismissed = options.dismissedSha !== void 0 && options.dismissedSha === report.code.headSha;
1739
+ if (!options.open && (!(alarming || lost) || dismissed)) return "";
1740
+ const status = codeStatus(report.code);
1741
+ const facts = [...status.facts, `checked ${escapeHtml(relativeTime$1(report.checkedAt))}`];
1742
+ const notes = [checkoutNote(report.checkout), options.open ? conversationNote$1(report, options.actions === true) : ""].filter((note) => note !== "");
1743
+ return `<div class="prb-fresh" data-state="${alarming || lost ? "changed" : "quiet"}" role="status">
1744
+ <div class="prb-fresh-head">
1745
+ <span class="prb-fresh-dot" data-freshness="${status.state}" aria-hidden="true"></span>
1746
+ <strong class="prb-fresh-title">${escapeHtml(status.title)}</strong>
1747
+ <span class="prb-fresh-facts">${facts.join(" &middot; ")}</span>
1748
+ </div>
1749
+ ${status.consequence ? `<p class="prb-fresh-say">${status.consequence}</p>` : ""}
1750
+ ${renderActions(report, options, alarming || lost)}
1751
+ ${notes.length === 0 ? "" : `<div class="prb-fresh-notes">${notes.map((note) => `<p>${note}</p>`).join("")}</div>`}
1752
+ </div>`;
1753
+ }
1754
+ /**
1755
+ * One primary action, then the two that only adjust the panel itself.
1756
+ *
1757
+ * `Update review` is the only control here that changes the review, so it is the only one wearing
1758
+ * the brand colour. `Check again` and `Dismiss` are quiet text buttons: as equal-weight boxes they
1759
+ * competed with it, which is what made the area read as busy rather than as a decision.
1760
+ */
1761
+ function renderActions(report, options, alarming) {
1762
+ const offerUpdate = options.actions && alarming;
1763
+ const dismiss = alarming ? `<button type="button" class="prb-fresh-quiet" data-fresh-dismiss="${escapeHtml(report.code.headSha ?? "")}">Dismiss</button>` : `<button type="button" class="prb-fresh-quiet" data-fresh-close>Close</button>`;
1764
+ const aside = offerUpdate ? renderUpdateAside(REANALYSE_COMMAND) : {
1765
+ alt: "",
1766
+ explain: ""
1767
+ };
1768
+ return `<div class="prb-fresh-actions">
1769
+ ${offerUpdate ? renderUpdateButton() : ""}
1770
+ <button type="button" class="prb-fresh-quiet" data-fresh-recheck>Check again</button>
1771
+ ${dismiss}
1772
+ ${aside.alt}
1773
+ </div>
1774
+ ${aside.explain}`;
1775
+ }
1776
+ function files(n, verb) {
1777
+ return n === null ? [] : [`${n} file${n === 1 ? "" : "s"} ${verb}`];
1778
+ }
1779
+ /** Seven characters is how everyone reads a commit, and the full sha is on the element for copying. */
1780
+ function short(sha) {
1781
+ return sha ? `<code title="${escapeHtml(sha)}">${escapeHtml(sha.slice(0, 7))}</code>` : "";
1782
+ }
1783
+ /** The reviewed commit, which is the one fact worth repeating on every variant. */
1784
+ function reviewed(sha) {
1785
+ return sha ? [`reviewed ${short(sha)}`] : [];
1786
+ }
1787
+ function codeStatus(code) {
1788
+ if (code.state === "current") return {
1789
+ state: "current",
1790
+ title: "Up to date",
1791
+ facts: reviewed(code.reviewedSha),
1792
+ consequence: ""
1793
+ };
1794
+ if (code.state === "changed") {
1795
+ if (code.rewritten) return {
1796
+ state: "changed",
1797
+ title: "Code rewritten",
1798
+ facts: [...reviewed(code.reviewedSha), ...files(code.changedFiles, "differ")],
1799
+ consequence: "The branch was rebased, amended or reset, so the reviewed commit is no longer on it. Every finding below describes the older code."
1800
+ };
1801
+ const commits = code.newCommits === null ? [] : [`${code.newCommits} new commit${code.newCommits === 1 ? "" : "s"}`];
1802
+ return {
1803
+ state: "changed",
1804
+ title: "Code changed",
1805
+ facts: [
1806
+ ...reviewed(code.reviewedSha),
1807
+ ...commits,
1808
+ ...files(code.changedFiles, "changed")
1809
+ ],
1810
+ consequence: "Findings below describe the reviewed commit, and the lines they cite may no longer be those lines."
1811
+ };
1812
+ }
1813
+ switch (code.reason) {
1814
+ case "uncommitted": return {
1815
+ state: "unknown",
1816
+ title: "Cannot be compared",
1817
+ facts: reviewed(code.reviewedSha),
1818
+ consequence: "This review read uncommitted work and nothing recorded what the working tree held, so whether the files still match cannot be told from here."
1819
+ };
1820
+ case "missing-commit": return {
1821
+ state: "unknown",
1822
+ title: "Reviewed commit is gone",
1823
+ facts: reviewed(code.reviewedSha),
1824
+ consequence: "It is not in this repository any more, after a force-push or a branch rebuilt from scratch, so nothing can be measured against it."
1825
+ };
1826
+ default: return {
1827
+ state: "unknown",
1828
+ title: "Cannot be compared",
1829
+ facts: [],
1830
+ consequence: "Git could not be read in this checkout."
1831
+ };
1832
+ }
1833
+ }
1834
+ /**
1835
+ * The checkout, demoted to a footnote.
1836
+ *
1837
+ * It was a second amber warning carrying the same weight as the code status, which is the wrong
1838
+ * ranking: a checkout that is behind changes nothing about whether the findings are true, it only
1839
+ * explains why the conversation reads the way it does. Muted, one line, inside the same panel.
1840
+ */
1841
+ function checkoutNote(checkout) {
1842
+ if (checkout.reason === "no-pull-request") return "";
1843
+ if (checkout.reason === "no-head-sha" || checkout.reason === "git-failed") return "Your checkout cannot be compared against the pull request.";
1844
+ const untouched = "Updating leaves your checkout alone.";
1845
+ switch (checkout.relation) {
1846
+ case "same": return "";
1847
+ case "checkout-behind": return `Your checkout is behind the pull request head ${short(checkout.prHeadSha)}, recorded at import. ${untouched}`;
1848
+ case "checkout-ahead": return `Your checkout is ahead of the pull request head ${short(checkout.prHeadSha)} recorded at import, so the conversation below describes earlier code. ${untouched}`;
1849
+ case "diverged": return `Your checkout and the pull request have diverged, measured against ${short(checkout.prHeadSha)} at import. ${untouched}`;
1850
+ default: return "The relationship between your checkout and the pull request could not be worked out.";
1851
+ }
1852
+ }
1853
+ /**
1854
+ * When the conversation was last read, and the offer to read it again.
1855
+ *
1856
+ * The wording changed with the thing it describes. This used to say the page "has no connection to
1857
+ * the site your pull request lives on, so someone has to go and look", which was true while the
1858
+ * only way to fetch a conversation was to ask a model to go and do it. A forge adapter drives the
1859
+ * CLI the reviewer has already signed in to now, so re-checking takes a couple of seconds and the
1860
+ * sentence calling it impossible had outlived the limitation it described.
1861
+ *
1862
+ * Still safe to run from here for the reason it always was: re-reading threads can only correct
1863
+ * the state of claims that already exist, never produce a finding.
1864
+ */
1865
+ function conversationNote$1(report, actions) {
1866
+ if (!report.conversation.attached) return "";
1867
+ const when = escapeHtml(relativeTime$1(report.conversation.importedAt));
1868
+ if (!actions) return `The pull request conversation was read ${when}.`;
1869
+ return `The pull request conversation was read ${when}. Re-checking fetches every thread again and corrects the &ldquo;already raised&rdquo; notes on your findings, leaving the findings themselves alone.<button type="button" class="prb-fresh-quiet" data-pr-recheck>Re-check the conversation</button>`;
1870
+ }
1871
+ //#endregion
1872
+ //#region ../../packages/review-harness/src/workspace/job_view.ts
1873
+ /**
1874
+ * The workspace while its review is still being made, and what to do about it if it fails.
1875
+ *
1876
+ * A reviewer can now open a link before the review behind it exists: the review runs in a spawned
1877
+ * process against an isolated checkout, and this is minutes long by design. A silent wait was
1878
+ * reported as a hang once already in this product, on a step that took far less time than this
1879
+ * one does, so every state here says which phase it is in and how long it has been going rather
1880
+ * than leaving a blank space where the review will eventually appear.
1881
+ *
1882
+ * A conversation that could not be fetched is not folded in here as a failure: it is a line on a
1883
+ * review that finished, not a state this module renders while the job is still working.
1884
+ */
1885
+ /** What the reviewer reads while each phase runs. The only copy visible during the wait. */
1886
+ var PHASE_COPY = {
1887
+ preparing: "Preparing an isolated checkout. This can take a minute on a large repository.",
1888
+ context: "Reading what changed.",
1889
+ conversation: "Reading the pull request conversation.",
1890
+ analysing: "Analysing the change."
1891
+ };
1892
+ /**
1893
+ * The same phases, as a noun, for the retry sentence.
1894
+ *
1895
+ * "picks up from the analysis" reads as an answer to "will this redo work"; "picks up from the
1896
+ * analysing" does not, which is why this is a separate table from `PHASE_COPY` rather than a
1897
+ * reuse of it.
1898
+ */
1899
+ var PHASE_NOUN = {
1900
+ preparing: "checkout",
1901
+ context: "context reading",
1902
+ conversation: "conversation reading",
1903
+ analysing: "analysis"
1904
+ };
1905
+ function isWorkPhase(phase) {
1906
+ return phase === "preparing" || phase === "context" || phase === "conversation" || phase === "analysing";
1907
+ }
1908
+ /** How long a phase has been running, in words a reviewer reads as reassurance rather than alarm. */
1909
+ function elapsedFor(startedAt) {
1910
+ const minutes = Math.round((Date.now() - startedAt) / 6e4);
1911
+ if (minutes < 1) return "under a minute";
1912
+ return `${minutes} minute${minutes === 1 ? "" : "s"}`;
1913
+ }
1914
+ /**
1915
+ * What to say about a conversation that is not attached, and when to say nothing at all.
1916
+ *
1917
+ * Two of the six outcomes are silence. An attached conversation speaks for itself, on the review.
1918
+ * A change that is not on a pull request has nothing missing from it, and a permanent note saying
1919
+ * so on every branch review would be the house style's own bad habit: a status strip that is only
1920
+ * ever true.
1921
+ *
1922
+ * The other four all change what the "already raised" notes on this review can claim, which is
1923
+ * exactly the thing a reviewer would otherwise assume they had.
1924
+ */
1925
+ function conversationNote(outcome) {
1926
+ switch (outcome.kind) {
1927
+ case "attached":
1928
+ case "no-request": return "";
1929
+ case "unsupported-forge": return outcome.host ? `PR Review Buddy does not read pull request conversation on ${outcome.host} yet, so this review is of the code alone.` : "This repository has no remote to look a pull request up on, so this review is of the code alone.";
1930
+ case "cli-missing": return `This review was made without the pull request conversation, because \`${outcome.cli}\` is not installed.`;
1931
+ case "not-authenticated": return `This review was made without the pull request conversation, because \`${outcome.cli}\` is not signed in. Run \`${outcome.cli} auth login\` and update the review to read it.`;
1932
+ case "failed": return `This review was made without the pull request conversation attached. ${outcome.detail}`;
1933
+ }
1934
+ }
1935
+ /**
1936
+ * The workspace page above the review: what the job is doing, or nothing at all.
1937
+ *
1938
+ * Nothing is rendered once the job has finished cleanly, because a permanent status strip on a
1939
+ * complete review is exactly the noise this house style avoids elsewhere. The one exception is a
1940
+ * conversation that could not be attached: that is worth a line even on a review that otherwise
1941
+ * succeeded, because it changes what the "already raised" notes on this review can claim.
1942
+ */
1943
+ function renderJobState(job) {
1944
+ if (!job) return "";
1945
+ if (job.phase === "failed" && job.failure) {
1946
+ const noun = isWorkPhase(job.failure.phase) ? PHASE_NOUN[job.failure.phase] : job.failure.phase;
1947
+ return `<div class="prb-job" data-job-phase="failed" role="status">
1948
+ <p><strong>Review paused.</strong> ${escapeHtml(job.failure.message)}</p>
1949
+ <button type="button" class="prb-job-retry" data-job-retry>Retry</button>
1950
+ <p class="prb-job-note">Retry picks up from the ${escapeHtml(noun)}, not from the start.</p>
1951
+ </div>`;
1952
+ }
1953
+ if (job.phase === "done") {
1954
+ const note = job.conversation ? conversationNote(job.conversation) : "";
1955
+ if (!note) return "";
1956
+ return `<div class="prb-job" data-job-phase="done" role="status">
1957
+ <p>${escapeHtml(note)}</p>
1958
+ </div>`;
1959
+ }
1960
+ const copy = isWorkPhase(job.phase) ? PHASE_COPY[job.phase] : "";
1961
+ const progress = job.progress ? ` <span class="prb-job-progress">${escapeHtml(job.progress)}</span>` : "";
1962
+ return `<div class="prb-job" data-job-phase="${escapeHtml(job.phase)}" role="status" aria-live="polite">
1963
+ <p><span class="prb-spinner" aria-hidden="true"></span> ${escapeHtml(copy)}${progress}</p>
1964
+ <p class="prb-job-elapsed">Running for ${escapeHtml(elapsedFor(job.startedAt))}.</p>
1965
+ </div>`;
1966
+ }
1967
+ /**
1968
+ * What the page's poll asks for, and what the client needs to decide whether to keep asking.
1969
+ *
1970
+ * `'none'` covers a workspace with no job at all, which is an ordinary state for anything made
1971
+ * before jobs existed, or a review whose job record has since been swept.
1972
+ */
1973
+ function jobStatus(job) {
1974
+ if (!job) return {
1975
+ phase: "none",
1976
+ progress: "",
1977
+ failed: false
1978
+ };
1979
+ const progress = job.phase === "failed" ? job.failure?.message ?? "" : job.progress ?? (isWorkPhase(job.phase) ? PHASE_COPY[job.phase] : "");
1980
+ return {
1981
+ phase: job.phase,
1982
+ progress,
1983
+ failed: job.phase === "failed"
1984
+ };
1985
+ }
1986
+ //#endregion
1987
+ //#region ../../packages/review-harness/src/workspace/page.ts
1988
+ function renderWorkspace(view) {
1989
+ const { session, changeSet } = view;
1990
+ const result = session.result;
1991
+ const sections = result?.sections ?? [];
1992
+ const title = changeSet.data.pullRequest.title;
1993
+ /**
1994
+ * Freshness is a claim about findings, so a review with no findings has none to make.
1995
+ *
1996
+ * "The code has moved on, the findings below describe that commit" said on a review that stopped
1997
+ * before producing a finding describes nothing, and the Update review button beside it offers to
1998
+ * reconcile a review against itself. The CLI already refuses this; the page was showing it.
1999
+ *
2000
+ * Decided once here rather than at each of the three places it is drawn, because the header pill,
2001
+ * the `data-report` the client repaints from, and the banner itself have to agree. Suppressing
2002
+ * only the markup would leave the pill able to paint it straight back.
2003
+ */
2004
+ const freshness = result ? view.freshness : void 0;
2005
+ return `<!doctype html>
2006
+ <html lang="en">
2007
+ <head>
2008
+ <meta charset="utf-8">
2009
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2010
+ <title>${escapeHtml(changeSet.isLinkedWorktree ? `${changeSet.checkoutName}: ${title}` : title)} · PR Review Buddy</title>
2011
+ <link rel="icon" type="image/png" href="/assets/icon128.png">
2012
+ <link rel="stylesheet" href="/assets/sidebar.css">
2013
+ <link rel="stylesheet" href="/assets/shell.css">
2014
+ </head>
2015
+ <body data-api-base="${escapeHtml(view.apiBase)}"${view.token ? ` data-token="${escapeHtml(view.token)}"` : ""}${view.editor ? ` data-editor-label="${escapeHtml(view.editor.label)}" data-editor-template="${escapeHtml(view.editor.template)}"` : ""}${view.repoPath ? ` data-repo-path="${escapeHtml(view.repoPath)}"` : ""} data-explain-prompt="${escapeHtml(EXPLAIN_SIMPLY_PROMPT)}">
2016
+ ${renderHeader(view, freshness)}
2017
+ ${renderJobState(view.job ?? null)}
2018
+ <div class="prb-fresh-region" data-fresh-region${freshness ? ` data-report="${escapeHtml(JSON.stringify(freshness))}"` : ""}${view.dismissedFreshnessSha ? ` data-dismissed-sha="${escapeHtml(view.dismissedFreshnessSha)}"` : ""}>${renderFreshness(freshness ?? null, {
2019
+ dismissedSha: view.dismissedFreshnessSha,
2020
+ actions: view.token !== void 0
2021
+ })}</div>
2022
+ ${renderUpdateOutcome(view.lastUpdate)}
2023
+ ${renderLineageNotice(view)}
2024
+ ${result ? renderReviewBody(view, sections) : ""}
2025
+ ${view.token ? renderShutdownDialog() : ""}
2026
+ ${view.token ? renderPromptDialog() : ""}
2027
+ <script type="module" src="/assets/workspace.js"><\/script>
2028
+ </body>
2029
+ </html>`;
2030
+ }
2031
+ /**
2032
+ * Everything the review actually shows: the rail, the document, the assistant.
2033
+ *
2034
+ * Pulled out of `renderWorkspace` so it can be skipped whole rather than rendered around a null
2035
+ * result. A review with no result yet has no sections, no issues, no files worth listing — every
2036
+ * one of those panes would draw as empty rather than as absent, and a reviewer cannot tell "this
2037
+ * finished with nothing to say" from "this has not run yet" by looking at an empty list.
2038
+ */
2039
+ function renderReviewBody(view, sections) {
2040
+ const { session } = view;
2041
+ const result = session.result;
2042
+ return `<div class="prb-body"${view.askWidth ? ` style="--ask-width: ${Math.round(view.askWidth)}px"` : ""}>
2043
+ ${renderRail(view, sections)}
2044
+ <main class="prb-doc">
2045
+ <div class="pr-helper-sidebar prb-pane">
2046
+ ${renderDisclosure(view)}
2047
+ <section class="prb-view" data-view="overview">${renderOverviewTab(session)}</section>
2048
+ <section class="prb-view" data-view="guide" hidden>${renderGuideTab(session, null, null)}</section>
2049
+ <section class="prb-view" data-view="issues" hidden>
2050
+ <h3 class="prb-doc-title">Issues</h3>
2051
+ ${renderAttributionAge(view)}
2052
+ ${renderIssuesTab(session, null)}
2053
+ </section>
2054
+ <section class="prb-view" data-view="questions" hidden>
2055
+ <h3 class="prb-doc-title">Questions</h3>
2056
+ ${renderQuestionsTab(session, null, { suppressFileJump: true })}
2057
+ </section>
2058
+ <section class="prb-view" data-view="files" hidden>
2059
+ <h3 class="prb-doc-title">Files</h3>
2060
+ ${renderFiles(view, sections)}
2061
+ </section>
2062
+ <section class="prb-view" data-view="prcontext" hidden>
2063
+ ${renderPrContextView(view.prContext ?? null, {
2064
+ findings: result?.issues.length ?? 0,
2065
+ matched: (result?.issues ?? []).filter((issue) => issue.existingDiscussion).length
2066
+ }, {
2067
+ enabled: view.token !== void 0 && view.prContext !== void 0,
2068
+ last: view.lastPrRefresh
2069
+ })}
2070
+ </section>
2071
+ </div>
2072
+ </main>
2073
+ ${renderAsk(view, sections)}
2074
+ </div>`;
2075
+ }
2076
+ /**
2077
+ * When the "already raised" claims below were last true.
2078
+ *
2079
+ * Every attribution on this view carries a thread's state — "still unresolved", "since marked
2080
+ * resolved" — and that state was frozen the moment the conversation was imported. Nothing
2081
+ * re-checks it, and nothing here can: the daemon reaches no forge. So a card can go on telling
2082
+ * the reviewer that Sergei's thread is open an hour after Sergei closed it.
2083
+ *
2084
+ * Every other kind of staleness in this product degrades into vagueness. This one degrades into a
2085
+ * confident, specific, wrong claim about what a named colleague currently thinks, which is worth
2086
+ * one line above the list rather than an explanation the reviewer has to go and find.
2087
+ *
2088
+ * Only where a conversation was attached: there are no such claims to qualify otherwise.
2089
+ */
2090
+ function renderAttributionAge(view) {
2091
+ if (!view.prContext) return "";
2092
+ return `<p class="prb-doc-note">Any &ldquo;already raised&rdquo; note below describes the pull request as it stood ${escapeHtml(relativeTime$1(view.prContext.prContext.discussion.fetchedAt))}, when its discussion was fetched. That includes whether each thread was still open. If someone has replied or resolved a thread since then, this page has no way of knowing. Use <b>Re-check the conversation</b> on the PR context view to bring it up to date.</p>`;
2093
+ }
2094
+ /**
2095
+ * The link out to this file on the pull request.
2096
+ *
2097
+ * Wraps the shared builder in `file_links.ts`, which the file viewer's own route uses too so the
2098
+ * two cannot disagree about where a file lives.
2099
+ */
2100
+ function prFileLink(view, path) {
2101
+ return buildPrFileLink(view.prContext?.prContext, path);
2102
+ }
2103
+ /**
2104
+ * Whose work this is, beside the branch it is on.
2105
+ *
2106
+ * Here rather than in the Overview because it is part of what identifies the change, and a
2107
+ * reviewer wants it before they read a line rather than after. Absent when nothing recorded it,
2108
+ * which is an ordinary state for a review made before this existed.
2109
+ */
2110
+ function renderAuthor(view) {
2111
+ const author = describeAuthorship(view.changeSet, view.prContext?.prContext.pullRequest);
2112
+ if (!author) return "";
2113
+ const title = author.title ? ` title="${escapeHtml(author.title)}"` : "";
2114
+ const handle = author.handle ? `<span class="prb-author-handle">@${escapeHtml(author.handle)}</span>` : "";
2115
+ const body = `<span class="prb-author-name">${escapeHtml(author.label)}</span>${handle}`;
2116
+ return author.url ? `<a class="prb-author" href="${escapeHtml(author.url)}" target="_blank" rel="noopener noreferrer"${title}>${body}</a>` : `<span class="prb-author"${title}>${body}</span>`;
2117
+ }
2118
+ /**
2119
+ * "You are reading review 1 of 3", when that is true and only then.
2120
+ *
2121
+ * Silent on the latest review, which is the overwhelming majority of page loads: a strip that
2122
+ * appears on every review is a strip nobody reads, and the one time this has something to say is
2123
+ * the time it has to be noticed.
2124
+ *
2125
+ * The link goes to the index and carries no token of its own. That is the whole reason this is a
2126
+ * sentence and a link rather than a dropdown: the index is the only page that holds every review's
2127
+ * credential, and a reviewer arriving there has their own way in.
2128
+ */
2129
+ function renderLineageNotice(view) {
2130
+ const lineage = view.lineage;
2131
+ if (!lineage || lineage.position >= lineage.total) return "";
2132
+ const newer = lineage.total - lineage.position;
2133
+ return `<div class="prb-lineage" role="status">
2134
+ <p><strong>You are reading review ${lineage.position} of ${lineage.total}.</strong> ${newer === 1 ? "A newer review" : `${newer} newer reviews`} of this branch ${newer === 1 ? "exists" : "exist"}, so the findings below may already have been answered. Nothing here is out of bounds, but the current one is somewhere else.</p>
2135
+ <a class="prb-lineage-link" href="/">See every review of this branch</a>
2136
+ </div>`;
2137
+ }
2138
+ function renderHeader(view, freshness) {
2139
+ const { changeSet } = view;
2140
+ const additions = changeSet.data.files.reduce((sum, f) => sum + f.additions, 0);
2141
+ const deletions = changeSet.data.files.reduce((sum, f) => sum + f.deletions, 0);
2142
+ const fileCount = changeSet.data.files.length;
2143
+ return `<header class="prb-header">
2144
+ <span class="prb-wordmark"><b>pr</b>review<b>buddy</b></span>
2145
+ <span class="prb-change-ref" title="${escapeHtml(changeSet.repoPath)}">
2146
+ ${changeSet.isLinkedWorktree ? `<span class="prb-checkout">${escapeHtml(changeSet.checkoutName)}</span>` : ""}
2147
+ ${view.prContext ? `<a class="prb-pr-chip" href="${escapeHtml(view.prContext.prContext.pullRequest.url)}" target="_blank" rel="noopener noreferrer" title="${escapeHtml(view.prContext.prContext.pullRequest.title ?? "")}">#${view.prContext.prContext.pullRequest.number}</a>` : ""}
2148
+ <strong>${escapeHtml(changeSet.headRef)}</strong>
2149
+ <span class="prb-arrow" aria-label="compared against">&rarr;</span>
2150
+ <span>${escapeHtml(changeSet.baseRef)}</span>
2151
+ ${changeSet.includesUncommitted ? "<span class=\"prb-file-status\">+ working tree</span>" : ""}
2152
+ ${renderAuthor(view)}
2153
+ </span>
2154
+ <div class="prb-header-stats">
2155
+ <span>${fileCount} file${fileCount === 1 ? "" : "s"}</span>
2156
+ <span><span class="prb-add">+${additions}</span> <span class="prb-del">&minus;${deletions}</span></span>
2157
+ ${view.token && freshness ? (() => {
2158
+ const { label, state } = freshnessLabel(freshness ?? null);
2159
+ return `<button type="button" class="prb-fresh-check" data-fresh-check data-state="${state}" aria-expanded="false" title="How this review stands against the branch as it is now">${escapeHtml(label)}</button>`;
2160
+ })() : ""}
2161
+ ${view.token ? `<button type="button" class="prb-complete" data-complete aria-pressed="${view.complete === true}">${view.complete ? "Reviewed" : "Mark review complete"}</button>` : ""}
2162
+ <button type="button" class="prb-ask-toggle" data-ask-toggle aria-expanded="false">Buddy AI</button>
2163
+ </div>
2164
+ </header>`;
2165
+ }
2166
+ /**
2167
+ * Everything below the review: where else to go, and what to do to the server.
2168
+ *
2169
+ * These are in the rail rather than the header because they are not about this review. The header
2170
+ * answers "what am I looking at" — branch, size, whether I am done with it, and Buddy AI — and
2171
+ * anything global sitting up there competed with that. Leaving the review is navigation, and it
2172
+ * belongs with the other navigation.
2173
+ *
2174
+ * Stopping the server is separated from the links because it is operational and it ends every open
2175
+ * review, not just this one. The build sits beside it: it is provenance, wanted when something is
2176
+ * wrong, and that is the same corner as the feedback link that carries it.
2177
+ *
2178
+ * The same feedback form the extension uses, so replies land in one place rather than two. What
2179
+ * it carries is decided in `version.ts` — version, client, OS — and deliberately not the
2180
+ * repository or its path.
2181
+ */
2182
+ function renderRailFoot(view) {
2183
+ return `<div class="prb-rail-group prb-rail-foot">
2184
+ ${view.token ? `<a class="prb-rail-item" href="/">
2185
+ <span class="prb-rail-order">&lsaquo;</span>
2186
+ <span class="prb-rail-label"><span class="prb-rail-title">All reviews</span></span>
2187
+ </a>` : ""}
2188
+ <a class="prb-rail-item" href="${escapeHtml(feedbackUrl())}" target="_blank" rel="noopener noreferrer">
2189
+ <span class="prb-rail-order"></span>
2190
+ <span class="prb-rail-label"><span class="prb-rail-title">Send feedback</span></span>
2191
+ </a>
2192
+ </div>
2193
+ <div class="prb-rail-group prb-rail-ops">
2194
+ ${view.token ? `<button type="button" class="prb-rail-item prb-rail-danger" data-shutdown>
2195
+ <span class="prb-rail-order"></span>
2196
+ <span class="prb-rail-label"><span class="prb-rail-title">Stop server</span></span>
2197
+ </button>` : ""}
2198
+ <p class="prb-build">v${escapeHtml(BUILD_VERSION)}</p>
2199
+ </div>`;
2200
+ }
2201
+ /**
2202
+ * The review path, nested under the Overview.
2203
+ *
2204
+ * The themes are the Overview made walkable, not a second place to go, so the rail says that by
2205
+ * containing them rather than by listing them alongside it. Collapsing is a plain button beside
2206
+ * the Overview rather than a <details>: the row has to stay a link to the Overview, and a control
2207
+ * inside a <summary> is toggled by the browser as well as by us in some engines.
2208
+ *
2209
+ * Themes are numbered because the guide genuinely is an ordered walkthrough — `order` is the
2210
+ * model's answer to "what sequence should a reviewer take through this change" — so the number
2211
+ * tells the reader something they need. It is not a decorative index.
2212
+ */
2213
+ function renderRail(view, sections) {
2214
+ const result = view.session.result;
2215
+ const issues = result?.issues ?? [];
2216
+ const questions = result?.questions ?? [];
2217
+ return `<nav class="prb-rail" aria-label="Review path">
2218
+ <div class="prb-rail-group">
2219
+ <div class="prb-rail-row">
2220
+ <button type="button" class="prb-rail-item" data-view-nav="overview" aria-current="true">
2221
+ <span class="prb-rail-order"></span>
2222
+ <span class="prb-rail-label"><span class="prb-rail-title">Overview</span></span>
2223
+ </button>
2224
+ <button type="button" class="prb-rail-twisty" data-rail-toggle aria-expanded="true" aria-controls="prb-rail-path" aria-label="Collapse the review path">
2225
+ <svg viewBox="0 0 12 12" aria-hidden="true" focusable="false"><path d="M3 4.5 6 8l3-3.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
2226
+ </button>
2227
+ </div>
2228
+ <div class="prb-rail-path" id="prb-rail-path">
2229
+ <p class="prb-rail-heading">Review path</p>
2230
+ ${[...sections].sort((a, b) => a.order - b.order).map((section, index) => {
2231
+ const sectionIssues = issues.filter((issue) => issue.sectionId === section.id).length;
2232
+ const note = section.recommendedStartingPoint ? "<span class=\"prb-rail-start\">Start here</span>" : `<span class="prb-rail-note">${section.files.length} file${section.files.length === 1 ? "" : "s"}</span>`;
2233
+ return `<button type="button" class="prb-rail-item" data-goto-section="${escapeHtml(section.id)}" data-theme-index="${index}" data-recommended-start="${section.recommendedStartingPoint}">
2234
+ <span class="prb-rail-order">${index + 1}</span>
2235
+ <span class="prb-rail-label">
2236
+ <span class="prb-rail-title">${escapeHtml(section.title)}</span>
2237
+ ${note}
2238
+ </span>
2239
+ ${sectionIssues > 0 ? `<span class="prb-rail-count" title="${sectionIssues} issue${sectionIssues === 1 ? "" : "s"} in this theme">${sectionIssues}</span>` : ""}
2240
+ <span class="prb-attention" data-attention="${escapeHtml(section.reviewAttention)}" title="Needs ${escapeHtml(section.reviewAttention)} attention"></span>
2241
+ </button>`;
2242
+ }).join("") || "<p class=\"prb-rail-heading\">No themes</p>"}
2243
+ </div>
2244
+ </div>
2245
+ <div class="prb-rail-group">
2246
+ ${railLink("issues", "Issues", issues.length)}
2247
+ ${railLink("questions", "Questions", questions.length)}
2248
+ ${railLink("files", "Files", view.changeSet.data.files.length)}
2249
+ ${view.prContext ? railLink("prcontext", "PR context", view.prContext.processed.stats.totalThreads) : railLink("prcontext", "PR context", null, "No pull request was connected to this review")}
2250
+ </div>
2251
+ ${renderRailFoot(view)}
2252
+ </nav>`;
2253
+ }
2254
+ /** A null count renders no badge, for the case where zero would be a claim rather than a total. */
2255
+ function railLink(view, label, count, note) {
2256
+ return `<button type="button" class="prb-rail-item" data-view-nav="${view}"${note ? ` title="${escapeHtml(note)}"` : ""}>
2257
+ <span class="prb-rail-order"></span>
2258
+ <span class="prb-rail-label"><span class="prb-rail-title">${escapeHtml(label)}</span></span>
2259
+ ${count === null ? "" : `<span class="prb-rail-count" title="${count} ${escapeHtml(label.toLowerCase())}">${count}</span>`}
2260
+ </button>`;
2261
+ }
2262
+ /**
2263
+ * What the harness corrected, stated above the review rather than buried in a details element.
2264
+ * A guide quietly repaired is a guide trusted more than it deserves.
2265
+ *
2266
+ * Corrections only. There is deliberately nothing here about how much of the change travelled
2267
+ * inline: that is provenance, it belongs in Analysis details on the Overview, and putting it in a
2268
+ * box under this heading would restate a limit that no longer exists as though it did.
2269
+ */
2270
+ function renderDisclosure(view) {
2271
+ const { audit } = view;
2272
+ const notes = [];
2273
+ if (audit.unknownPaths.length > 0) notes.push(`${audit.unknownPaths.length} referenced path${audit.unknownPaths.length === 1 ? "" : "s"} could not be found in this change and ${audit.unknownPaths.length === 1 ? "was" : "were"} removed: ` + audit.unknownPaths.map((path) => `<code>${escapeHtml(path)}</code>`).join(", ") + ".");
2274
+ if (audit.relocatedFindings > 0) notes.push(`${audit.relocatedFindings} finding${audit.relocatedFindings === 1 ? "" : "s"} pointed at a file that is not in this change. The finding is kept; its location is not.`);
2275
+ if (notes.length === 0) return "";
2276
+ return `<aside class="prb-disclosure">
2277
+ <strong>What the harness corrected</strong>
2278
+ <ul>${notes.map((note) => `<li>${note}</li>`).join("")}</ul>
2279
+ </aside>`;
2280
+ }
2281
+ /**
2282
+ * Every changed file, split by whether the guide sends the reviewer there.
2283
+ *
2284
+ * The split used to be analysed / not analysed, which was the truth under a file ceiling and is a
2285
+ * lie without one: nothing here was beyond the analysis's reach. What the two groups now record
2286
+ * is a priority the model set after reading, so the second group is a "later, if you want to",
2287
+ * not a gap the reviewer has to go and close.
2288
+ */
2289
+ /**
2290
+ * The Files tab: the escape hatch from the guide.
2291
+ *
2292
+ * Everywhere else in the workspace the code shown is the code some finding is *about* — "here is
2293
+ * the evidence for this claim". This tab answers the other question a reviewer asks, which no
2294
+ * guide can anticipate: "let me look at that file myself". So it lists every changed file, groups
2295
+ * them by the theme that owns them, and expands them in place.
2296
+ *
2297
+ * The viewer is not a second implementation — every row carries `data-file`, which is the same
2298
+ * attribute the guide's file rows, an issue's location and a question's jump button carry, so all
2299
+ * four go through one code path. Two viewers would drift the first time one of them was improved.
2300
+ */
2301
+ function renderFiles(view, sections) {
2302
+ const files = view.changeSet.data.files;
2303
+ const byPath = new Map(files.map((file) => [file.name, file]));
2304
+ const references = collectReferences(view, sections);
2305
+ const claimed = /* @__PURE__ */ new Set();
2306
+ const groups = sections.map((section) => {
2307
+ const sectionFiles = section.files.map((file) => byPath.get(file.path)).filter((file) => {
2308
+ if (!file || claimed.has(file.name)) return false;
2309
+ claimed.add(file.name);
2310
+ return true;
2311
+ });
2312
+ return fileGroup(view, `${section.order}. ${section.title}`, sectionFiles, references);
2313
+ }).join("");
2314
+ const unclaimed = files.filter((file) => !claimed.has(file.name));
2315
+ return `<div class="prb-file-search">
2316
+ <input type="search" data-file-filter placeholder="Search files…" aria-label="Filter files by path" autocomplete="off" spellcheck="false">
2317
+ <span class="prb-file-search-count" data-file-count>${files.length} file${files.length === 1 ? "" : "s"}</span>
2318
+ </div>
2319
+ ${groups}
2320
+ ${fileGroup(view, "Not in the review path", unclaimed, references)}
2321
+ <p class="prb-file-none" data-file-none hidden>No file matches that.</p>`;
2322
+ }
2323
+ /**
2324
+ * Which findings point at each file.
2325
+ *
2326
+ * Worth showing on the row because it is the one thing the file list cannot say for itself: a file
2327
+ * with four issues against it and a file with none look identical by path, status and line count.
2328
+ */
2329
+ function collectReferences(view, sections) {
2330
+ const result = /* @__PURE__ */ new Map();
2331
+ const owner = /* @__PURE__ */ new Map();
2332
+ for (const section of sections) for (const file of section.files) if (!owner.has(file.path)) owner.set(file.path, section.id);
2333
+ const titles = new Map(sections.map((section) => [section.id, `${section.order}. ${section.title}`]));
2334
+ const entry = (path) => {
2335
+ const existing = result.get(path);
2336
+ if (existing) return existing;
2337
+ const created = {
2338
+ issues: 0,
2339
+ questions: 0,
2340
+ otherSections: []
2341
+ };
2342
+ result.set(path, created);
2343
+ return created;
2344
+ };
2345
+ const note = (path, sectionId, kind) => {
2346
+ if (!path) return;
2347
+ const record = entry(path);
2348
+ record[kind] += 1;
2349
+ const title = sectionId && sectionId !== owner.get(path) ? titles.get(sectionId) : void 0;
2350
+ if (title && !record.otherSections.includes(title)) record.otherSections.push(title);
2351
+ };
2352
+ for (const issue of view.session.result?.issues ?? []) note(issue.file, issue.sectionId, "issues");
2353
+ for (const question of view.session.result?.questions ?? []) note(question.file, question.sectionId, "questions");
2354
+ return result;
2355
+ }
2356
+ function fileGroup(view, heading, files, references) {
2357
+ if (files.length === 0) return "";
2358
+ const rows = files.map((file) => fileRow(view, file, references.get(file.name))).join("");
2359
+ return `<div class="prb-file-group" data-file-group>
2360
+ <h4 data-group-heading="${escapeHtml(heading)}" data-group-total="${files.length}">${escapeHtml(heading)} · ${files.length}</h4>${rows}
2361
+ </div>`;
2362
+ }
2363
+ function fileRow(view, file, refs) {
2364
+ const { dir, name } = splitFilePath(file.name);
2365
+ const badges = [
2366
+ ...refs?.issues ? [`<span class="prb-file-ref prb-file-ref-issue">${refs.issues} issue${refs.issues === 1 ? "" : "s"}</span>`] : [],
2367
+ ...refs?.questions ? [`<span class="prb-file-ref">${refs.questions} question${refs.questions === 1 ? "" : "s"}</span>`] : [],
2368
+ ...(refs?.otherSections ?? []).map((title) => `<span class="prb-file-ref">also ${escapeHtml(title)}</span>`)
2369
+ ].join("");
2370
+ const readable = !(file.additions === 0 && file.deletions === 0 && !file.patch);
2371
+ const openEditor = !readable && view.editor ? `<button type="button" class="prb-file-open" data-open-editor="${escapeHtml(file.name)}" data-open-line="1" title="Open in ${escapeHtml(view.editor.label)}">Open in ${escapeHtml(view.editor.label)}</button>` : "";
2372
+ const link = readable ? null : prFileLink(view, file.name);
2373
+ const openPr = link ? `<a class="prb-file-open prb-file-pr" href="${escapeHtml(link.href)}" target="_blank" rel="noopener noreferrer" title="${escapeHtml(link.label)}">Pull request</a>` : "";
2374
+ const attributes = readable ? ` data-file="${escapeHtml(file.name)}" role="button" tabindex="0" aria-expanded="false"` : "";
2375
+ return `<div class="prb-file${readable ? " prb-file-expandable" : ""}"${attributes} data-search="${escapeHtml(file.name.toLowerCase())}" title="${escapeHtml(file.name)}">
2376
+ <span class="prb-file-status">${escapeHtml(file.status)}</span>
2377
+ <span class="prb-file-path">
2378
+ ${dir ? `<span class="pr-helper-path-dir">${escapeHtml(dir)}</span>` : ""}
2379
+ <span class="pr-helper-path-name">${escapeHtml(name)}</span>
2380
+ </span>
2381
+ ${openPr}
2382
+ ${openEditor}
2383
+ ${badges ? `<span class="prb-file-badges">${badges}</span>` : ""}
2384
+ <span class="prb-file-stat"><span class="prb-add">+${file.additions}</span> <span class="prb-del">&minus;${file.deletions}</span></span>
2385
+ </div>`;
2386
+ }
2387
+ /**
2388
+ * Saving and editing one prompt.
2389
+ *
2390
+ * A dialog rather than an inline field: a prompt is a paragraph often enough that a one-line
2391
+ * input would misrepresent what belongs here, and editing needs somewhere to put Delete that is
2392
+ * not next to the button that inserts.
2393
+ *
2394
+ * One dialog, reused for add and for edit — `data-prompt-id` says which, and it is empty for a
2395
+ * new one. Two dialogs would be two places for the field wiring to drift.
2396
+ */
2397
+ function renderPromptDialog() {
2398
+ return `<dialog class="prb-dialog prb-prompt-dialog" data-prompt-dialog aria-labelledby="prb-prompt-title">
2399
+ <h2 id="prb-prompt-title">Save a prompt</h2>
2400
+ <label class="prb-field">
2401
+ <span>Name</span>
2402
+ <input type="text" data-prompt-name maxlength="60" placeholder="N+1 queries">
2403
+ </label>
2404
+ <label class="prb-field">
2405
+ <span>Prompt</span>
2406
+ <textarea data-prompt-text rows="5" maxlength="2000" placeholder="Check this change for N+1 database queries."></textarea>
2407
+ </label>
2408
+ <p class="prb-dialog-reassure">Saved prompts are yours and stay on this machine. They are offered in every review, and on any text you select.</p>
2409
+ <div class="prb-dialog-actions">
2410
+ <button type="button" class="prb-dialog-danger" data-prompt-delete hidden>Delete</button>
2411
+ <span class="prb-dialog-spacer"></span>
2412
+ <button type="button" data-prompt-cancel>Cancel</button>
2413
+ <button type="button" class="prb-dialog-primary" data-prompt-save>Save</button>
2414
+ </div>
2415
+ </dialog>`;
2416
+ }
2417
+ /**
2418
+ * A real dialog rather than a button that relabels itself.
2419
+ *
2420
+ * The relabelling version had a disarm timer, and an uncancelled timer from an earlier arming
2421
+ * could fire after the server had already stopped and reset the label to an offer that no longer
2422
+ * meant anything. A dialog has no timer to race, and it has room to say what is about to happen.
2423
+ */
2424
+ function renderShutdownDialog() {
2425
+ return `<dialog class="prb-dialog" data-shutdown-dialog aria-labelledby="prb-shutdown-title">
2426
+ <h2 id="prb-shutdown-title">Stop the workspace server?</h2>
2427
+ <p>One server serves every review on this machine, so <b>every open workspace stops</b>, not just this one.</p>
2428
+ <p class="prb-dialog-reassure">Nothing is deleted. Your reviews stay on disk for thirty days. Run <code>/prreviewbuddy:start-server</code> to start the server again and get their links back.</p>
2429
+ <div class="prb-dialog-actions">
2430
+ <button type="button" data-shutdown-cancel>Cancel</button>
2431
+ <button type="button" class="prb-dialog-danger" data-shutdown-confirm>Stop server</button>
2432
+ </div>
2433
+ </dialog>`;
2434
+ }
2435
+ function renderAsk(view, sections) {
2436
+ const suggestions = buildSuggestions(view, sections);
2437
+ const transcript = view.transcript ?? [];
2438
+ return `<aside class="prb-ask" aria-label="Ask Buddy AI">
2439
+ <div
2440
+ class="prb-ask-resize"
2441
+ data-ask-resize
2442
+ role="separator"
2443
+ aria-orientation="vertical"
2444
+ aria-label="Resize the assistant. Arrow keys adjust; Home resets."
2445
+ tabindex="0"
2446
+ ></div>
2447
+ <div class="prb-ask-header">
2448
+ <h2>Ask Buddy AI</h2>
2449
+ <span class="prb-ask-scope" data-ask-scope>whole change</span>
2450
+ </div>
2451
+ ${renderPromptBank(view.prompts ?? [])}
2452
+ <div class="prb-ask-log" data-ask-log>
2453
+ ${transcript.length > 0 ? transcript.map((exchange) => `<div class="prb-msg prb-msg-user">${escapeHtml(exchange.question)}</div>
2454
+ <div class="prb-msg prb-msg-assistant prb-md">${renderMarkdown(exchange.answer)}</div>`).join("") : `<div class="prb-ask-empty">
2455
+ <p>Ask about this change. Answers come from the review, the diff and the repository on disk — not from anywhere else.</p>
2456
+ ${suggestions.map((text) => `<button type="button" class="prb-suggestion" data-suggestion>${escapeHtml(text)}</button>`).join("")}
2457
+ </div>`}
2458
+ </div>
2459
+ <form class="prb-ask-form" data-ask-form>
2460
+ <textarea name="question" rows="1" placeholder="Ask about this change…" aria-label="Your question"></textarea>
2461
+ <button type="submit">Ask</button>
2462
+ </form>
2463
+ </aside>`;
2464
+ }
2465
+ /**
2466
+ * The reviewer's own prompts, above the conversation.
2467
+ *
2468
+ * Rendered server-side rather than fetched, so the bank is there on first paint — a list that
2469
+ * appears a moment after the page does reads as something that loaded, and a prompt bank is
2470
+ * furniture rather than content.
2471
+ *
2472
+ * The list is always present, even when empty: an empty bank with an Add button explains what
2473
+ * the feature is, where a hidden one leaves it undiscoverable until someone reads a changelog.
2474
+ */
2475
+ function renderPromptBank(prompts) {
2476
+ return `<div class="prb-bank" data-prompt-bank data-prompts="${escapeHtml(JSON.stringify(prompts))}">
2477
+ <div class="prb-bank-head">
2478
+ <span>Your prompts</span>
2479
+ <button type="button" class="prb-bank-add" data-prompt-add>Add</button>
2480
+ </div>
2481
+ <div class="prb-bank-list" data-prompt-list>${prompts.map(renderPromptChip).join("")}</div>
2482
+ </div>`;
2483
+ }
2484
+ function renderPromptChip(prompt) {
2485
+ return `<span class="prb-bank-item">
2486
+ <button type="button" class="prb-bank-use" data-prompt-use="${escapeHtml(prompt.id)}" title="${escapeHtml(prompt.prompt)}">${escapeHtml(prompt.name)}</button>
2487
+ <button type="button" class="prb-bank-edit" data-prompt-edit="${escapeHtml(prompt.id)}" aria-label="Edit ${escapeHtml(prompt.name)}">&#9998;</button>
2488
+ </span>`;
2489
+ }
2490
+ /**
2491
+ * Openers drawn from this change rather than a fixed list.
2492
+ *
2493
+ * A generic "explain this code" prompt teaches the reviewer nothing about what the assistant is
2494
+ * for. Naming the actual highest-severity finding and the actual starting theme shows them that it
2495
+ * already knows what they are looking at.
2496
+ */
2497
+ function buildSuggestions(view, sections) {
2498
+ const suggestions = [];
2499
+ const worst = (view.session.result?.issues ?? [])[0];
2500
+ if (worst) suggestions.push(`Why is "${worst.title}" risky?`);
2501
+ const start = sections.find((section) => section.recommendedStartingPoint) ?? sections[0];
2502
+ if (start) suggestions.push(`Walk me through the ${start.title.toLowerCase()} changes.`);
2503
+ suggestions.push("Would this break existing users?");
2504
+ return suggestions.slice(0, 3);
2505
+ }
2506
+ //#endregion
2507
+ //#region ../../packages/review-harness/src/workspace/reviews_page.ts
2508
+ function renderReviewsPage(view) {
2509
+ const now = view.now ?? Date.now();
2510
+ const ordered = [...view.groups].sort((a, b) => Number(b.latest.pinned) - Number(a.latest.pinned) || touchedAt(b.latest) - touchedAt(a.latest));
2511
+ return `<!doctype html>
2512
+ <html lang="en">
2513
+ <head>
2514
+ <meta charset="utf-8">
2515
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2516
+ <title>Reviews · PR Review Buddy</title>
2517
+ <link rel="icon" type="image/png" href="/assets/icon128.png">
2518
+ <link rel="stylesheet" href="/assets/sidebar.css">
2519
+ <link rel="stylesheet" href="/assets/shell.css">
2520
+ </head>
2521
+ <body class="prb-index-body">
2522
+ <header class="prb-header">
2523
+ <span class="prb-wordmark"><b>pr</b>review<b>buddy</b></span>
2524
+ <span class="prb-change-ref">Reviews</span>
2525
+ <div class="prb-header-stats">
2526
+ <span>${storedLabel(view.groups.reduce((total, group) => total + group.history.length, 0), ordered.length)}</span>
2527
+ <span class="prb-build">v${escapeHtml(BUILD_VERSION)}</span>
2528
+ <a class="prb-feedback" href="${escapeHtml(feedbackUrl())}" target="_blank" rel="noopener noreferrer">Send feedback</a>
2529
+ </div>
2530
+ </header>
2531
+ <main class="prb-index">
2532
+ ${ordered.length === 0 ? "" : renderControls()}
2533
+ <div class="prb-cards" data-cards>
2534
+ ${ordered.map((group) => renderCard(group, now)).join("")}
2535
+ </div>
2536
+ ${ordered.length === 0 ? `<p class="prb-index-empty">No reviews stored yet. Ask Claude Code to review a change and one will appear here.</p>` : `<p class="prb-index-none" data-no-matches hidden>Nothing matches that.</p>`}
2537
+ <p class="prb-index-note">Reviews are kept for thirty days, on this machine only. Stopping the server deletes nothing, but the next start gives every review a new link, so come back to this page rather than to a bookmarked review.</p>
2538
+ </main>
2539
+ <script src="/assets/reviews.js" type="module"><\/script>
2540
+ </body>
2541
+ </html>`;
2542
+ }
2543
+ /**
2544
+ * Search and filters, above the grid.
2545
+ *
2546
+ * Search covers everything a person might remember about a review — the repository, the branch,
2547
+ * the title, the model's summary, and the paths it touched — because which of those they recall
2548
+ * is not predictable. The filters are the three states and nothing else; a filter per risk level
2549
+ * would be four more controls for a question ("show me only the risky ones") that sorting by
2550
+ * recency already answers badly and that scanning answers well.
2551
+ */
2552
+ function renderControls() {
2553
+ return `<div class="prb-index-controls">
2554
+ <input type="search" class="prb-index-search" data-search placeholder="Search repo, branch, title, summary or file…" aria-label="Search reviews" spellcheck="false">
2555
+ <div class="prb-index-filters" role="group" aria-label="Filter reviews">
2556
+ <button type="button" data-filter="all" aria-pressed="true">All</button>
2557
+ <button type="button" data-filter="in-progress" aria-pressed="false">In progress</button>
2558
+ <button type="button" data-filter="complete" aria-pressed="false">Reviewed</button>
2559
+ </div>
2560
+ </div>`;
2561
+ }
2562
+ /**
2563
+ * "13 reviews of 9 branches", once those are different numbers.
2564
+ *
2565
+ * The count used to be the number of cards, and it still could be, but the card no longer stands
2566
+ * for one review. Saying "9 stored" over a page holding thirteen analyses would be quietly wrong
2567
+ * about the one thing this header exists to state.
2568
+ */
2569
+ function storedLabel(reviews, branches) {
2570
+ if (reviews === branches) return `${reviews} stored`;
2571
+ return `${reviews} reviews of ${branches} branch${branches === 1 ? "" : "es"}`;
2572
+ }
2573
+ /**
2574
+ * One branch.
2575
+ *
2576
+ * The order down the card is the order the questions get asked: what is this (summary), where is
2577
+ * it (repo and branch), how big and how risky, what is left, and when was I last here. Everything
2578
+ * above the history line describes the **latest** review, because that is the state of this branch
2579
+ * as far as the product knows; the earlier ones are history and are folded away until asked for.
2580
+ */
2581
+ function renderCard(group, now) {
2582
+ const review = group.latest;
2583
+ const status = statusOf(review);
2584
+ const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
2585
+ return `<article class="prb-card" data-card data-status="${status}" data-pinned="${review.pinned}" data-search-text="${escapeHtml(groupSearchText(group))}">
2586
+ <button type="button" class="prb-card-pin" data-pin="${escapeHtml(review.id)}" aria-pressed="${review.pinned}" aria-label="${review.pinned ? "Unpin" : "Pin"} this review" title="${review.pinned ? "Unpin" : "Pin to the top"}">${review.pinned ? "&#9733;" : "&#9734;"}</button>
2587
+ <a class="prb-card-link" href="${escapeHtml(href)}">
2588
+ <div class="prb-card-head">
2589
+ <h2>${escapeHtml(review.title)}</h2>
2590
+ ${renderStatus(status)}
2591
+ </div>
2592
+ ${review.summary ? `<p class="prb-card-summary">${escapeHtml(review.summary)}</p>` : ""}
2593
+ <p class="prb-card-where">
2594
+ <span class="prb-card-repo">${escapeHtml(lastSegment(review.repoPath))}</span>
2595
+ <span class="prb-card-branch">${escapeHtml(review.branch)}</span>
2596
+ ${review.author ? `<span class="prb-card-author">${escapeHtml(review.author)}</span>` : ""}
2597
+ </p>
2598
+ <p class="prb-card-stats">
2599
+ ${renderRisk(review.overallRisk)}
2600
+ <span>${review.changedFiles} file${review.changedFiles === 1 ? "" : "s"}</span>
2601
+ <span class="prb-card-diffstat"><span class="prb-add">+${review.additions}</span> <span class="prb-del">&minus;${review.deletions}</span></span>
2602
+ </p>
2603
+ <p class="prb-card-left">${escapeHtml(whatIsLeft(review))}</p>
2604
+ <p class="prb-card-when"><time datetime="${new Date(touchedAt(review)).toISOString()}" title="${escapeHtml(new Date(touchedAt(review)).toLocaleString())}">${escapeHtml(whenLabel(review, now))}</time></p>
2605
+ </a>
2606
+ ${renderHistory(group, now)}
2607
+ </article>`;
2608
+ }
2609
+ /**
2610
+ * Every analysis of this branch, folded away.
2611
+ *
2612
+ * Absent entirely on a branch reviewed once, which is most of them: a disclosure reading
2613
+ * "1 review" is a control that can only ever tell you what the card already said.
2614
+ *
2615
+ * Outside the card's link rather than inside it, because a link inside a link is not a thing HTML
2616
+ * has, and each row is its own link into its own review with its own token.
2617
+ */
2618
+ function renderHistory(group, now) {
2619
+ if (group.history.length < 2) return "";
2620
+ const count = group.history.length;
2621
+ return `<div class="prb-card-history">
2622
+ <button type="button" class="prb-card-history-toggle" data-history-toggle aria-expanded="false">
2623
+ <span class="prb-card-history-caret" aria-hidden="true">&#9662;</span>${count} reviews
2624
+ </button>
2625
+ <ol class="prb-card-history-list" data-history-list hidden>
2626
+ ${group.history.map((review, index) => renderHistoryRow(review, count - index, index === 0, now)).join("")}
2627
+ </ol>
2628
+ </div>`;
2629
+ }
2630
+ /**
2631
+ * One analysis in the list.
2632
+ *
2633
+ * The commit is on every row, including the latest, and it is the field that does the real work
2634
+ * here: two reviews an hour apart are told apart by what they were of, not by when they happened.
2635
+ * A review whose commit was never recorded shows none rather than a placeholder.
2636
+ *
2637
+ * Numbered from the oldest, so the newest has the highest number and "Review 1" is where this
2638
+ * branch started. The same numbering the workspace itself uses, so "Review 1 of 3" there and
2639
+ * "Review 1" here are the same review.
2640
+ *
2641
+ * **Only the latest row carries a workflow status.** `New`, `In progress` and `Reviewed` describe
2642
+ * where someone is in a piece of work, and on a superseded review that is a claim about work
2643
+ * nobody should be continuing: a row reading `IN PROGRESS` under a newer review of the same branch
2644
+ * invites exactly the wrong click. The second slot is the lineage instead, `Latest` or `Previous`,
2645
+ * which is the only thing about an older review that a reader needs before opening it.
2646
+ */
2647
+ function renderHistoryRow(review, number, latest, now) {
2648
+ const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
2649
+ return `<li class="prb-card-history-row"${latest ? " data-latest" : ""}>
2650
+ <a href="${escapeHtml(href)}">
2651
+ <span class="prb-card-history-name">Review ${number} ${latest ? "<span class=\"prb-card-history-latest\">Latest</span>" : "<span class=\"prb-card-history-previous\">Previous</span>"}</span>
2652
+ ${review.commitSha ? `<code class="prb-card-history-sha" title="${escapeHtml(review.commitSha)}">${escapeHtml(review.commitSha.slice(0, 7))}</code>` : ""}
2653
+ <span class="prb-card-history-when">${escapeHtml(relativeTime(review.createdAt, now))}</span>
2654
+ ${latest ? renderStatus(statusOf(review)) : ""}
2655
+ </a>
2656
+ </li>`;
2657
+ }
2658
+ /**
2659
+ * What this review still has open.
2660
+ *
2661
+ * Unresolved counts, not totals: "1 unanswered question" says where the review stands, where
2662
+ * "1/2 questions" makes the reader do the subtraction. Nothing here is a completion metric — the
2663
+ * product does not track file-by-file review, so the card does not pretend it does.
2664
+ */
2665
+ function whatIsLeft(review) {
2666
+ const parts = [];
2667
+ if (review.issues > 0) parts.push(`${review.issues} issue${review.issues === 1 ? "" : "s"}`);
2668
+ if (review.unansweredQuestions > 0) parts.push(`${review.unansweredQuestions} unanswered question${review.unansweredQuestions === 1 ? "" : "s"}`);
2669
+ else if (review.questions > 0) parts.push("all questions answered");
2670
+ if (review.messages > 0) parts.push(`${review.messages} Buddy AI message${review.messages === 1 ? "" : "s"}`);
2671
+ return parts.length > 0 ? parts.join(" · ") : "Nothing flagged";
2672
+ }
2673
+ /**
2674
+ * Three states, and only one of them is inferred from anything.
2675
+ *
2676
+ * `complete` is set by the reviewer pressing a button and never derived. Every signal that looks
2677
+ * like completion — questions ticked, issues read — means the checklist is empty, which is not
2678
+ * the same as being done; a reviewer can still be reading code with nothing left to tick.
2679
+ */
2680
+ function statusOf(review) {
2681
+ if (review.completedAt) return "complete";
2682
+ return review.lastViewedAt ? "in-progress" : "new";
2683
+ }
2684
+ function renderStatus(status) {
2685
+ return `<span class="prb-card-status" data-status="${status}">${status === "complete" ? "Reviewed" : status === "in-progress" ? "In progress" : "New"}</span>`;
2686
+ }
2687
+ function renderRisk(risk) {
2688
+ if (risk === "unknown") return "";
2689
+ return `<span class="prb-card-risk" data-risk="${risk}">${risk} risk</span>`;
2690
+ }
2691
+ /** Last opened where that is known, created otherwise. */
2692
+ function touchedAt(review) {
2693
+ return review.lastViewedAt ?? review.createdAt;
2694
+ }
2695
+ function whenLabel(review, now) {
2696
+ return review.lastViewedAt ? `Opened ${relativeTime(review.lastViewedAt, now)}` : `Created ${relativeTime(review.createdAt, now)}`;
2697
+ }
2698
+ /**
2699
+ * Everything the search field matches, flattened into one string on the card.
2700
+ *
2701
+ * Matching happens in the browser against this attribute rather than over the rendered text: the
2702
+ * paths are searchable without being displayed, and a filter that walked the DOM would match the
2703
+ * word "issue" in a label as readily as in a title.
2704
+ */
2705
+ function groupSearchText(group) {
2706
+ return group.history.map(searchText).join(" ");
2707
+ }
2708
+ function searchText(review) {
2709
+ return [
2710
+ lastSegment(review.repoPath),
2711
+ review.repoPath,
2712
+ review.branch,
2713
+ review.title,
2714
+ review.summary,
2715
+ review.author ?? "",
2716
+ ...review.paths
2717
+ ].join(" ").toLowerCase();
2718
+ }
2719
+ function lastSegment(path) {
2720
+ const segments = path.split("/").filter(Boolean);
2721
+ return segments[segments.length - 1] ?? path;
2722
+ }
2723
+ /**
2724
+ * "12 minutes ago" rather than a timestamp.
2725
+ *
2726
+ * What the reader is deciding is which of these is the one they were just working on, and a
2727
+ * relative age answers that in a glance where a clock time makes them do the subtraction. The
2728
+ * exact time is on the element's `title` for when several read the same.
2729
+ */
2730
+ function relativeTime(at, now) {
2731
+ const seconds = Math.max(0, Math.round((now - at) / 1e3));
2732
+ if (seconds < 60) return "just now";
2733
+ const minutes = Math.round(seconds / 60);
2734
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
2735
+ const hours = Math.round(minutes / 60);
2736
+ if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
2737
+ const days = Math.round(hours / 24);
2738
+ return `${days} day${days === 1 ? "" : "s"} ago`;
2739
+ }
2740
+ //#endregion
2741
+ //#region ../../packages/review-harness/src/workspace/ask.ts
2742
+ /** Long enough for a real look at the repository; short enough that a hang is not forever. */
2743
+ var ASK_TIMEOUT_MS = 18e4;
2744
+ var SYSTEM_PROMPT = `You are Buddy AI, PR Review Buddy's review assistant. Refer to yourself as Buddy AI on the rare occasion you need to name yourself.
2745
+
2746
+ A developer is reviewing a specific code change and is looking at a review guide you have been given below. They ask you questions while they read.
2747
+
2748
+ Answer only in relation to this change and the repository it lives in. You have the working tree, so open the files, trace callers and read history wherever that settles the question. Do not speculate about code you could have read.
2749
+
2750
+ If a question cannot be answered from this change and this repository, say so plainly and say what would answer it. Do not pad the answer out with general advice about the topic; a developer mid-review wants the specific answer or an honest "that isn't determinable from here".
2751
+
2752
+ Write in short paragraphs and keep it to what was asked. Markdown renders, so use it where it carries meaning: backticks for identifiers and paths, a fenced block for a snippet worth reading as code rather than described in a sentence, a bullet list where the answer genuinely is a list. Prose is still the default. No headings, and do not break a paragraph into bullets to look organised.`;
2753
+ var AskUnavailableError = class extends Error {};
2754
+ /**
2755
+ * Answer one question about the change, by running the agent in a checkout of the reviewed commit.
2756
+ *
2757
+ * Not the reviewer's own checkout, which is what this used to read. They are mid-review: the tree
2758
+ * has their edits in it, or a branch they switched to, or a rebase half done, and an assistant
2759
+ * reading that answers confidently about code the review is not about. The checkout here is pinned
2760
+ * to the commit the review was made from, so the answer describes the change the reviewer is
2761
+ * looking at, and the isolation the analysis has always had now covers the questions too.
2762
+ *
2763
+ * The agent rather than a provider API on purpose: it uses the authentication the developer already
2764
+ * has, needs no API key, and costs them nothing beyond their existing subscription. It also means
2765
+ * the assistant keeps working after the session that produced the review has closed, which is the
2766
+ * whole reason the workspace is served rather than written to a file.
2767
+ */
2768
+ async function askAboutChange(workspace, question, sectionId) {
2769
+ const cwd = await askCheckout(workspace);
2770
+ const env = agentEnvOf(workspace);
2771
+ try {
2772
+ return extractAnswer(await agentFor(env).run({
2773
+ capability: "read-code",
2774
+ prompt: buildPrompt(workspace, question, sectionId, cwd),
2775
+ cwd,
2776
+ originRepoPath: workspace.repoPath,
2777
+ env,
2778
+ timeoutMs: ASK_TIMEOUT_MS
2779
+ }));
2780
+ } catch (error) {
2781
+ if (error instanceof AgentUnavailableError) throw new AskUnavailableError("The agent is not available here, so the assistant cannot answer. Install Claude Code, or start the workspace from a shell where `claude` runs.");
2782
+ throw error;
2783
+ }
2784
+ }
2785
+ /**
2786
+ * The answer, checked before it is shown.
2787
+ *
2788
+ * An agent that produced nothing is a failure with a clear cause, and rendering its empty string as
2789
+ * an answer would show the reviewer a blank panel and call it a reply.
2790
+ */
2791
+ function extractAnswer(raw) {
2792
+ const answer = raw.trim();
2793
+ if (!answer) throw new Error("The assistant returned an empty answer.");
2794
+ return answer;
2795
+ }
2796
+ /**
2797
+ * Bound the assistant to this change.
2798
+ *
2799
+ * The guide and the whole diff go in every time. The theme the reviewer is currently reading is
2800
+ * named rather than used to filter the diff: they routinely ask "does this interact with X",
2801
+ * where X is in another theme, and a prompt that had quietly dropped X would answer confidently
2802
+ * and wrongly.
2803
+ */
2804
+ function buildPrompt(workspace, question, sectionId, cwd) {
2805
+ const section = workspace.session.result?.sections.find((s) => s.id === sectionId);
2806
+ const recent = workspace.transcript.slice(-4);
2807
+ return [
2808
+ SYSTEM_PROMPT,
2809
+ "---",
2810
+ `CHANGE: ${workspace.changeSet.headRef} compared against ${workspace.changeSet.baseRef}, in the checkout at ${cwd}`,
2811
+ "---",
2812
+ "REVIEW GUIDE (JSON):",
2813
+ JSON.stringify(workspace.session.result),
2814
+ "---",
2815
+ "DIFF:",
2816
+ workspace.diff,
2817
+ ...section ? ["---", `The reviewer is currently reading the "${section.title}" theme. Answer with that in view, but do not ignore the rest of the change if it bears on the question.`] : [],
2818
+ ...recent.length > 0 ? [
2819
+ "---",
2820
+ "EARLIER IN THIS CONVERSATION:",
2821
+ recent.map((entry) => `Q: ${entry.question}\nA: ${entry.answer}`).join("\n\n")
2822
+ ] : [],
2823
+ "---",
2824
+ `QUESTION: ${question}`
2825
+ ].join("\n");
2826
+ }
2827
+ var FileOutsideRepositoryError = class extends Error {};
2828
+ /**
2829
+ * Resolve a path against the reviewed repository, refusing anything outside it.
2830
+ *
2831
+ * The token already gates every route. This is the second lock: a path escape would turn one
2832
+ * review's link into a read of anything the developer can read, which is a much larger failure
2833
+ * than an unauthorised look at a review.
2834
+ */
2835
+ function resolveInRepository(repoPath, path) {
2836
+ const root = resolve(repoPath);
2837
+ const target = resolve(root, path);
2838
+ const rel = relative(root, target);
2839
+ if (rel.startsWith("..") || isAbsolute(rel)) throw new FileOutsideRepositoryError("That path is outside the repository.");
2840
+ return target;
2841
+ }
2842
+ function readFileWindow(workspace, path, options = {}) {
2843
+ const target = resolveInRepository(workspace.repoPath, path);
2844
+ const all = readFileSync(target, "utf8").split("\n");
2845
+ if (all.length > 0 && all[all.length - 1] === "") all.pop();
2846
+ const totalLines = all.length;
2847
+ const added = addedLinesFor(workspace, path);
2848
+ let from = 1;
2849
+ let to = Math.min(totalLines, 600);
2850
+ if (options.line && !options.expand) {
2851
+ from = Math.max(1, options.line - 12);
2852
+ to = Math.min(totalLines, options.line + 12);
2853
+ } else if (options.expand) to = Math.min(totalLines, 600);
2854
+ return {
2855
+ path,
2856
+ totalLines,
2857
+ from,
2858
+ to,
2859
+ lines: all.slice(from - 1, to),
2860
+ addedLines: added.filter((n) => n >= from && n <= to),
2861
+ truncated: to < totalLines
2862
+ };
2863
+ }
2864
+ /**
2865
+ * Which lines of the *current* file this change added, from the patch we already hold.
2866
+ *
2867
+ * Read out of the stored change set rather than by running git again: the review describes one
2868
+ * specific state of the working tree, and a second `git diff` could answer about a different one.
2869
+ */
2870
+ function addedLinesFor(workspace, path) {
2871
+ const file = workspace.changeSet.data.files.find((entry) => entry.name === path);
2872
+ if (!file?.patch) return [];
2873
+ return addedLinesFromPatch(file.patch);
2874
+ }
2875
+ var HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
2876
+ function addedLinesFromPatch(patch) {
2877
+ const added = [];
2878
+ let lineNumber = 0;
2879
+ for (const line of patch.split("\n")) {
2880
+ const hunk = HUNK.exec(line);
2881
+ if (hunk) {
2882
+ lineNumber = Number(hunk[1]);
2883
+ continue;
2884
+ }
2885
+ if (lineNumber === 0) continue;
2886
+ if (line.startsWith("+++")) continue;
2887
+ if (line.startsWith("+")) {
2888
+ added.push(lineNumber);
2889
+ lineNumber += 1;
2890
+ } else if (line.startsWith("-") || line.startsWith("\\")) {} else lineNumber += 1;
2891
+ }
2892
+ return added;
2893
+ }
2894
+ //#endregion
2895
+ //#region ../../packages/review-harness/src/workspace/editor.ts
2896
+ /**
2897
+ * How to hand a file back to the reviewer's real environment.
2898
+ *
2899
+ * A review tool is a navigation layer, not an IDE. The moment someone wants to actually change
2900
+ * code they should leave, and the value of this button is that leaving is one click rather than a
2901
+ * path copied out of a citation.
2902
+ *
2903
+ * `{path}` is substituted with the absolute path, `{line}` with a 1-based line number.
2904
+ */
2905
+ var EDITOR_TEMPLATES = {
2906
+ vscode: "vscode://file{path}:{line}",
2907
+ "vscode-insiders": "vscode-insiders://file{path}:{line}",
2908
+ cursor: "cursor://file{path}:{line}",
2909
+ windsurf: "windsurf://file{path}:{line}",
2910
+ zed: "zed://file{path}:{line}",
2911
+ sublime: "subl://open?url=file://{path}&line={line}",
2912
+ intellij: "idea://open?file={path}&line={line}"
2913
+ };
2914
+ var DEFAULT_EDITOR = "vscode";
2915
+ var LABELS = {
2916
+ vscode: "VS Code",
2917
+ "vscode-insiders": "VS Code Insiders",
2918
+ cursor: "Cursor",
2919
+ windsurf: "Windsurf",
2920
+ zed: "Zed",
2921
+ sublime: "Sublime Text",
2922
+ intellij: "IntelliJ"
2923
+ };
2924
+ /**
2925
+ * Read the configured editor. There is no way to detect one from a browser, so this is asked
2926
+ * rather than guessed: a button that opens the wrong application is worse than one that says
2927
+ * plainly which application it opens.
2928
+ */
2929
+ function editorChoice() {
2930
+ const configured = readConfiguredEditor();
2931
+ if (configured?.includes("{path}")) return {
2932
+ label: "editor",
2933
+ template: configured
2934
+ };
2935
+ const key = configured && EDITOR_TEMPLATES[configured] ? configured : DEFAULT_EDITOR;
2936
+ return {
2937
+ label: LABELS[key] ?? key,
2938
+ template: EDITOR_TEMPLATES[key]
2939
+ };
2940
+ }
2941
+ function readConfiguredEditor() {
2942
+ if (process.env.PRB_EDITOR) return process.env.PRB_EDITOR;
2943
+ try {
2944
+ const config = JSON.parse(readFileSync(join(STORE_ROOT, "config.json"), "utf8"));
2945
+ return typeof config.editor === "string" && config.editor ? config.editor : null;
2946
+ } catch {
2947
+ return null;
2948
+ }
2949
+ }
2950
+ //#endregion
2951
+ //#region ../../packages/review-harness/src/workspace/preferences.ts
2952
+ /**
2953
+ * Workspace preferences that belong to the reviewer rather than to a review.
2954
+ *
2955
+ * Kept server-side, in their own file, for two reasons. `localStorage` is keyed by origin, and
2956
+ * this server takes whatever port the OS offers on each start — so a restart would silently
2957
+ * discard a width the reviewer had deliberately chosen, which is the one failure a preference
2958
+ * must not have. And `config.json` next door is hand-edited: writing to a file someone maintains
2959
+ * by hand reformats it under them the first time they save.
2960
+ *
2961
+ * Everything here is best-effort. A workspace that will not open because a preferences file is
2962
+ * unreadable would be a preference costing more than it is worth.
2963
+ */
2964
+ var PREFERENCES_PATH = join(STORE_ROOT, "ui.json");
2965
+ var MAX_ASK_WIDTH = 1200;
2966
+ function readPreferences() {
2967
+ try {
2968
+ const parsed = JSON.parse(readFileSync(PREFERENCES_PATH, "utf8"));
2969
+ if (!parsed || typeof parsed !== "object") return {};
2970
+ const askWidth = parsed.askWidth;
2971
+ return typeof askWidth === "number" && Number.isFinite(askWidth) ? { askWidth: clampAskWidth(askWidth) } : {};
2972
+ } catch {
2973
+ return {};
2974
+ }
2975
+ }
2976
+ function writeAskWidth(width) {
2977
+ if (!Number.isFinite(width)) return;
2978
+ try {
2979
+ mkdirSync(STORE_ROOT, { recursive: true });
2980
+ const next = {
2981
+ ...readPreferences(),
2982
+ askWidth: clampAskWidth(width)
2983
+ };
2984
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify(next, null, 2)}\n`);
2985
+ } catch {}
2986
+ }
2987
+ /** Forget a stated width, so the stylesheet's proportional default applies again. */
2988
+ function clearAskWidth() {
2989
+ try {
2990
+ const { askWidth: _dropped, ...rest } = readPreferences();
2991
+ mkdirSync(STORE_ROOT, { recursive: true });
2992
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify(rest, null, 2)}\n`);
2993
+ } catch {}
2994
+ }
2995
+ /**
2996
+ * Absolute bounds only. The upper limit the reviewer actually feels is half the viewport, which
2997
+ * the page enforces because it is the only party that knows how wide the window is — this is the
2998
+ * far cruder job of keeping a hand-edited or stale number from producing an unusable layout.
2999
+ */
3000
+ function clampAskWidth(width) {
3001
+ return Math.round(Math.min(MAX_ASK_WIDTH, Math.max(320, width)));
3002
+ }
3003
+ /** Long enough for a paragraph of standing instructions, short enough not to crowd the question. */
3004
+ var MAX_PROMPT_CHARS = 2e3;
3005
+ /**
3006
+ * Everything reaching this function came from a client, so nothing here is trusted.
3007
+ *
3008
+ * Entries are repaired where the repair is unambiguous — trimming, truncating — and dropped where
3009
+ * it is not. An entry with no name has nothing to show on a chip; an entry with no prompt has
3010
+ * nothing to insert. Neither is worth rejecting the whole bank over: the reviewer would lose
3011
+ * everything they had saved to one bad row.
3012
+ */
3013
+ function sanitisePromptBank(value) {
3014
+ if (!Array.isArray(value)) return [];
3015
+ const seen = /* @__PURE__ */ new Set();
3016
+ const clean = [];
3017
+ for (const entry of value) {
3018
+ if (!entry || typeof entry !== "object") continue;
3019
+ const { id, name, prompt } = entry;
3020
+ if (typeof id !== "string" || typeof name !== "string" || typeof prompt !== "string") continue;
3021
+ const trimmed = {
3022
+ id: id.trim(),
3023
+ name: name.trim().slice(0, 60),
3024
+ prompt: prompt.trim().slice(0, MAX_PROMPT_CHARS)
3025
+ };
3026
+ if (!trimmed.id || !trimmed.name || !trimmed.prompt) continue;
3027
+ if (seen.has(trimmed.id)) continue;
3028
+ seen.add(trimmed.id);
3029
+ clean.push(trimmed);
3030
+ if (clean.length === 50) break;
3031
+ }
3032
+ return clean;
3033
+ }
3034
+ //#endregion
3035
+ //#region ../../packages/review-harness/src/workspace/prompt_bank.ts
3036
+ /**
3037
+ * The reviewer's own prompts, kept on disk.
3038
+ *
3039
+ * Saved next to the workspaces rather than inside one: a prompt belongs to the person, not to the
3040
+ * review they happened to think of it during. The rules for what counts as a saved prompt live in
3041
+ * `domain/ai_review/prompt_bank` and are shared with the extension — this file is only the storage.
3042
+ *
3043
+ * Best-effort throughout, exactly as `preferences.ts` is. A bank that cannot be read must never be
3044
+ * a workspace that will not open.
3045
+ */
3046
+ var PROMPTS_PATH = join(STORE_ROOT, "prompts.json");
3047
+ function readPromptBank() {
3048
+ try {
3049
+ return sanitisePromptBank(JSON.parse(readFileSync(PROMPTS_PATH, "utf8")));
3050
+ } catch {
3051
+ return [];
3052
+ }
3053
+ }
3054
+ /** Returns the bank as stored, which is not always the bank that was passed in. */
3055
+ function writePromptBank(bank) {
3056
+ const clean = sanitisePromptBank(bank);
3057
+ try {
3058
+ mkdirSync(STORE_ROOT, { recursive: true });
3059
+ writeFileSync(PROMPTS_PATH, `${JSON.stringify(clean, null, 2)}\n`, { mode: 384 });
3060
+ } catch {}
3061
+ return clean;
3062
+ }
3063
+ //#endregion
3064
+ //#region ../../packages/review-harness/src/telemetry/rollup.ts
3065
+ /**
3066
+ * Close out a workspace session by summarising its own events.
3067
+ *
3068
+ * Every number is read back out of the log rather than tracked alongside it. A rollup that keeps
3069
+ * its own counters can drift from the events it claims to summarise, and the resulting number
3070
+ * looks exactly as authoritative as a correct one — the same failure the review engine avoids by
3071
+ * counting what the model produced instead of letting it report its own coverage.
3072
+ */
3073
+ function recordReviewCompleted(reviewId) {
3074
+ const events = readEvents(reviewId);
3075
+ if (events.length === 0) return;
3076
+ if (events.some((event) => event.event === "review_completed")) return;
3077
+ const opened = events.find((event) => event.event === "workspace_opened");
3078
+ const themesTotal = opened?.event === "workspace_opened" ? opened.themes : 0;
3079
+ record({
3080
+ event: "review_completed",
3081
+ reviewId,
3082
+ activeSeconds: sum(events, "workspace_heartbeat", (event) => event.event === "workspace_heartbeat" ? event.visibleSeconds : 0),
3083
+ themesOpened: distinct(events, (event) => event.event === "theme_opened" ? event.themeIndex : null),
3084
+ themesTotal,
3085
+ viewsOpened: distinct(events, (event) => event.event === "view_opened" ? event.view : null),
3086
+ filesOpened: events.filter((event) => event.event === "file_opened").length,
3087
+ editorOpened: events.some((event) => event.event === "editor_opened"),
3088
+ questionsAsked: events.filter((event) => event.event === "assistant_question_asked").length,
3089
+ assistantUsed: events.some((event) => event.event === "assistant_answered" && event.ok)
3090
+ });
3091
+ }
3092
+ function sum(events, name, value) {
3093
+ return events.filter((event) => event.event === name).reduce((total, event) => total + value(event), 0);
3094
+ }
3095
+ function distinct(events, pick) {
3096
+ const seen = /* @__PURE__ */ new Set();
3097
+ for (const event of events) {
3098
+ const value = pick(event);
3099
+ if (value !== null) seen.add(value);
3100
+ }
3101
+ return seen.size;
3102
+ }
3103
+ //#endregion
3104
+ //#region ../../packages/review-harness/src/workspace/http.ts
3105
+ /**
3106
+ * Where the page's assets live, in each of the two layouts this file actually runs in.
3107
+ *
3108
+ * Bundled, it is `dist/server.js`, so the assets are one level up. In the source tree under
3109
+ * vitest, it is `src/workspace/http.ts`, so they are two. Both candidates are real, neither is a
3110
+ * guess, and resolving both is what lets the tests exercise the same code path the product uses —
3111
+ * the previous single-candidate version silently resolved to a directory that has never existed
3112
+ * (`src/static`), which is why nothing ever noticed that asset serving was untested.
3113
+ */
3114
+ var STATIC_DIR = ["../static", "../../static"].map((candidate) => fileURLToPath(new URL(candidate, import.meta.url))).find(existsSync) ?? fileURLToPath(new URL("../static", import.meta.url));
3115
+ /**
3116
+ * The page's own assets, read into memory when the server starts.
3117
+ *
3118
+ * They used to be read from disk on each request, which quietly made a running workspace depend on
3119
+ * its own installation directory still being there. That is not a safe assumption for this
3120
+ * process: it is detached so a review outlives the session, so it equally outlives whatever
3121
+ * temporary directory a plugin archive was unpacked into to launch it. When that directory goes,
3122
+ * every asset starts 404ing while the pages keep rendering perfectly — the HTML is built from code
3123
+ * already in memory. The result looks exactly like broken CSS and points nowhere near the cause.
3124
+ *
3125
+ * A quarter of a megabyte, read once. Anything detached should carry what it needs.
3126
+ */
3127
+ var ASSETS = loadAssets();
3128
+ function loadAssets() {
3129
+ const assets = /* @__PURE__ */ new Map();
3130
+ try {
3131
+ for (const name of readdirSync(STATIC_DIR)) try {
3132
+ assets.set(name, readFileSync(join(STATIC_DIR, name)));
3133
+ } catch {}
3134
+ } catch {}
3135
+ return assets;
3136
+ }
3137
+ var CONTENT_TYPES = {
3138
+ ".css": "text/css; charset=utf-8",
3139
+ ".js": "text/javascript; charset=utf-8",
3140
+ ".woff2": "font/woff2",
3141
+ ".png": "image/png"
3142
+ };
3143
+ function createWorkspaceServer(options = {}) {
3144
+ return createServer((req, res) => {
3145
+ handle(req, res, options).catch((error) => {
3146
+ send(res, 500, { error: error instanceof Error ? error.message : "Unknown error" });
3147
+ });
3148
+ });
3149
+ }
3150
+ async function handle(req, res, options) {
3151
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
3152
+ if (url.pathname === "/health") return send(res, 200, {
3153
+ ok: true,
3154
+ version: BUILD_VERSION,
3155
+ assets: ASSETS.size
3156
+ });
3157
+ if (url.pathname.startsWith("/assets/")) return sendAsset(res, url.pathname);
3158
+ if (url.pathname === "/") return sendReviewsIndex(req, res, url.searchParams.get("t"), options.indexToken);
3159
+ if (url.pathname === "/pin") return handlePin(req, res, options.indexToken);
3160
+ const match = /^\/(?:r|api)\/([0-9a-f-]{36})(\/[a-z]+)?$/.exec(url.pathname);
3161
+ if (!match) return send(res, 404, { error: "Not found" });
3162
+ const [, id, action] = match;
3163
+ const workspace = loadWorkspace(id);
3164
+ if (!workspace) return send(res, 404, { error: "No such review" });
3165
+ if (!tokenMatches(workspace, url.searchParams.get("t"))) return send(res, 403, { error: "This link is missing its access token." });
3166
+ if (url.pathname.startsWith("/r/")) {
3167
+ touchWorkspace(workspace);
3168
+ return sendHtml(res, workspace);
3169
+ }
3170
+ if (action === "/job") return send(res, 200, jobStatus(workspace.jobId ? loadJob(workspace.jobId) : null));
3171
+ if (action === "/retry") return handleRetry(res, workspace);
3172
+ if (action === "/freshness") return handleFreshness(res, workspace);
3173
+ if (action === "/refresh") return handleRefreshPr(res, workspace);
3174
+ if (action === "/update") return handleUpdate(res, workspace);
3175
+ if (action === "/review") return send(res, 200, workspace.session.result ?? {});
3176
+ if (action === "/file") return sendFile(res, workspace, url.searchParams);
3177
+ if (action === "/ask") return handleAsk(req, res, workspace);
3178
+ if (action === "/state") return handleState(req, res, workspace);
3179
+ if (action === "/event") return handleEvent(req, res, workspace);
3180
+ if (action === "/prefs") return handlePrefs(req, res);
3181
+ if (action === "/prompts") return handlePrompts(req, res);
3182
+ if (action === "/shutdown") return handleShutdown(res, options);
3183
+ return send(res, 404, { error: "Not found" });
3184
+ }
3185
+ /**
3186
+ * Constant-time, and length-checked first.
3187
+ *
3188
+ * `timingSafeEqual` throws on a length mismatch rather than returning false, so an unequal-length
3189
+ * token would surface as a 500 instead of a refusal.
3190
+ */
3191
+ function tokenMatches(workspace, supplied) {
3192
+ return secretMatches(workspace.token, supplied);
3193
+ }
3194
+ function secretMatches(expected, supplied) {
3195
+ if (!supplied) return false;
3196
+ const a = Buffer.from(expected);
3197
+ const b = Buffer.from(supplied);
3198
+ return a.length === b.length && timingSafeEqual(a, b);
3199
+ }
3200
+ /**
3201
+ * The list of every stored review, with a working link into each.
3202
+ *
3203
+ * Its own credential, not any review's. This page is the one place that holds all the per-review
3204
+ * tokens, so authorising it with one of them would make that review's token a key to the rest —
3205
+ * and a workspace page never receives this one, so it stays a key only to itself.
3206
+ *
3207
+ * The refusal names the command that produces a working link rather than saying "forbidden": the
3208
+ * likely reader is the owner of the machine arriving from a bookmark whose token is from a server
3209
+ * that has since restarted, and they need the way back, not a verdict.
3210
+ */
3211
+ function sendReviewsIndex(req, res, supplied, indexToken) {
3212
+ if (!indexToken || !(secretMatches(indexToken, supplied) || secretMatches(indexToken, indexCookie(req)))) return send(res, 403, { error: "This link is missing its access token. Run /prreviewbuddy:start-server to get the current one." });
3213
+ const html = renderReviewsPage({ groups: recentReviewGroups(MAX_LISTED_BRANCHES) });
3214
+ res.writeHead(200, {
3215
+ "content-type": "text/html; charset=utf-8",
3216
+ "set-cookie": `${INDEX_COOKIE}=${indexToken}; Path=/; HttpOnly; SameSite=Strict`
3217
+ });
3218
+ res.end(html);
3219
+ }
3220
+ /**
3221
+ * Pin or unpin a review.
3222
+ *
3223
+ * Authorised by the index credential rather than the review's own, because pinning is a fact about
3224
+ * the list and the caller is the list. It reaches this route as the cookie: the index page holds
3225
+ * no token in its markup, which is the point of the cookie in the first place.
3226
+ */
3227
+ async function handlePin(req, res, indexToken) {
3228
+ if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
3229
+ const body = await readJson(req);
3230
+ const id = typeof body?.id === "string" ? body.id : null;
3231
+ const workspace = id ? loadWorkspace(id) : null;
3232
+ if (!workspace) return send(res, 404, { error: "No such review" });
3233
+ workspace.pinned = body?.pinned === true;
3234
+ saveWorkspace(workspace);
3235
+ send(res, 200, { ok: true });
3236
+ }
3237
+ var INDEX_COOKIE = "prb_index";
3238
+ /**
3239
+ * The reviews index, remembered by the browser that has already proved it may see it.
3240
+ *
3241
+ * A workspace needs a way back to `/`, and it cannot carry the index token — that was the whole
3242
+ * point of not building a switcher: a page holding a credential for every review is a master key,
3243
+ * and one that then lives in a URL, in history, and in whatever reads either.
3244
+ *
3245
+ * A cookie moves that credential out of the page entirely. It is set only when someone arrives at
3246
+ * the index with the real token, it is `HttpOnly` so no script can read it back out, and it is
3247
+ * `SameSite=Strict` so another origin cannot make the browser spend it. A browser that never had
3248
+ * the token gets a 403 from the link, which is correct: it was never shown the list.
3249
+ */
3250
+ function indexCookie(req) {
3251
+ const header = req.headers.cookie;
3252
+ if (!header) return null;
3253
+ for (const part of header.split(";")) {
3254
+ const [name, ...rest] = part.trim().split("=");
3255
+ if (name === INDEX_COOKIE) return rest.join("=");
3256
+ }
3257
+ return null;
3258
+ }
3259
+ /**
3260
+ * Enough to cover the reviews anyone still has in mind, and a bound on a page that is read by
3261
+ * scanning it. Older reviews are still on disk and still listed by the MCP tool.
3262
+ */
3263
+ /** Branches, not reviews: a branch reviewed five times is one card and must not cost five slots. */
3264
+ var MAX_LISTED_BRANCHES = 50;
3265
+ async function sendHtml(res, workspace) {
3266
+ const freshness = await freshnessOrNull(workspace);
3267
+ const notice = workspace.lastUpdate?.acknowledged ? void 0 : workspace.lastUpdate;
3268
+ if (notice) {
3269
+ notice.acknowledged = true;
3270
+ saveWorkspace(workspace);
3271
+ }
3272
+ const html = renderWorkspace({
3273
+ session: workspace.session,
3274
+ changeSet: workspace.changeSet,
3275
+ audit: workspace.audit,
3276
+ apiBase: `/api/${workspace.id}`,
3277
+ token: workspace.token,
3278
+ editor: editorChoice(),
3279
+ repoPath: workspace.repoPath,
3280
+ askWidth: readPreferences().askWidth,
3281
+ transcript: workspace.transcript,
3282
+ prompts: readPromptBank(),
3283
+ complete: workspace.completedAt !== void 0,
3284
+ prContext: workspace.prContext ? {
3285
+ prContext: workspace.prContext,
3286
+ processed: processDiscussion(workspace.prContext.discussion)
3287
+ } : void 0,
3288
+ freshness: freshness ?? void 0,
3289
+ dismissedFreshnessSha: workspace.dismissedFreshnessSha,
3290
+ lastPrRefresh: workspace.lastPrRefresh,
3291
+ lastUpdate: notice,
3292
+ lineage: lineagePosition(workspace),
3293
+ job: workspace.jobId ? loadJob(workspace.jobId) : void 0
3294
+ });
3295
+ if (freshness) record({
3296
+ event: "freshness_checked",
3297
+ reviewId: workspace.id,
3298
+ source: "page_load",
3299
+ code: freshness.code.state,
3300
+ checkout: freshness.checkout.state
3301
+ });
3302
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
3303
+ res.end(html);
3304
+ }
3305
+ function sendAsset(res, pathname) {
3306
+ const name = pathname.slice(8);
3307
+ if (!/^[a-z0-9_-]+\.(css|js|woff2|png)$/i.test(name)) return send(res, 404, { error: "Not found" });
3308
+ const body = ASSETS.get(name);
3309
+ if (!body) return send(res, 404, { error: "Not found" });
3310
+ const ext = name.slice(name.lastIndexOf("."));
3311
+ res.writeHead(200, {
3312
+ "content-type": CONTENT_TYPES[ext] ?? "application/octet-stream",
3313
+ "cache-control": "no-cache"
3314
+ });
3315
+ res.end(body);
3316
+ }
3317
+ /**
3318
+ * A window onto a file from the reviewed repository.
3319
+ *
3320
+ * A window rather than the file, and JSON rather than text, because the caller is a review pane
3321
+ * showing why a file matters — not a code browser. Containment is enforced in file_view.ts.
3322
+ */
3323
+ function sendFile(res, workspace, params) {
3324
+ const path = params.get("path");
3325
+ if (!path) return send(res, 400, { error: "No path given" });
3326
+ const lineParam = Number(params.get("line"));
3327
+ try {
3328
+ send(res, 200, {
3329
+ ...readFileWindow(workspace, path, {
3330
+ line: Number.isFinite(lineParam) && lineParam > 0 ? lineParam : void 0,
3331
+ expand: params.get("expand") === "1"
3332
+ }),
3333
+ pullRequest: buildPrFileLink(workspace.prContext, path) ?? void 0
3334
+ });
3335
+ } catch (error) {
3336
+ if (error instanceof FileOutsideRepositoryError) return send(res, 403, { error: error.message });
3337
+ send(res, 404, { error: "That file is not in the working tree. This change may have deleted it." });
3338
+ }
3339
+ }
3340
+ /**
3341
+ * The three comparisons, on demand.
3342
+ *
3343
+ * Separate from the page render so a tab that has been open for an hour can ask again without
3344
+ * losing its place — which is the case the button exists for. Read-only and cheap: four git
3345
+ * plumbing commands against the local object store, no network anywhere in it.
3346
+ */
3347
+ async function handleFreshness(res, workspace) {
3348
+ const report = await freshnessOrNull(workspace);
3349
+ if (!report) return send(res, 200, { report: null });
3350
+ record({
3351
+ event: "freshness_checked",
3352
+ reviewId: workspace.id,
3353
+ source: "button",
3354
+ code: report.code.state,
3355
+ checkout: report.checkout.state
3356
+ });
3357
+ send(res, 200, {
3358
+ report,
3359
+ dismissedSha: workspace.dismissedFreshnessSha ?? null
3360
+ });
3361
+ }
3362
+ /**
3363
+ * Re-read the pull request conversation, at the reviewer's request.
3364
+ *
3365
+ * Answers 200 with `ok: false` rather than an error status when it does not work. The page's job
3366
+ * here is to tell the reviewer what happened in a sentence, and a failed re-read is an outcome —
3367
+ * the conversation on disk is untouched and the review is exactly as usable as it was a moment
3368
+ * ago. An HTTP error would make the page choose between a stack trace and silence.
3369
+ */
3370
+ async function handleRefreshPr(res, workspace) {
3371
+ let outcome;
3372
+ try {
3373
+ outcome = await refreshPrContext(workspace);
3374
+ } catch {
3375
+ outcome = {
3376
+ at: Date.now(),
3377
+ ok: false,
3378
+ message: "The pull request could not be re-read. Nothing here has changed."
3379
+ };
3380
+ }
3381
+ workspace.lastPrRefresh = outcome;
3382
+ saveWorkspace(workspace);
3383
+ record({
3384
+ event: "pr_conversation_rechecked",
3385
+ reviewId: workspace.id,
3386
+ ok: outcome.ok,
3387
+ changed: outcome.changes ? !isUnchanged(outcome.changes) : false,
3388
+ attributionsRestated: outcome.attributionsRestated ?? 0,
3389
+ attributionsWithdrawn: outcome.attributionsWithdrawn ?? 0
3390
+ });
3391
+ send(res, 200, outcome);
3392
+ }
3393
+ /**
3394
+ * Bring the whole review up to date with the checkout, at the reviewer's request.
3395
+ *
3396
+ * Answers 200 with `ok: false` when it refuses or fails, for the reason the conversation re-read
3397
+ * does: a refusal is an outcome the page has to explain in a sentence, and the review is exactly
3398
+ * as usable as it was a moment ago. An HTTP error would make the page choose between a stack trace
3399
+ * and silence.
3400
+ *
3401
+ * The only place this reaches disk. `updateReview` computes and mutates in memory, so a failure
3402
+ * before this line leaves the stored review untouched, and the snapshot it takes of the result it
3403
+ * replaced is written in this same save.
3404
+ */
3405
+ async function handleUpdate(res, workspace) {
3406
+ let outcome;
3407
+ try {
3408
+ outcome = await updateReview(workspace);
3409
+ } catch (error) {
3410
+ outcome = {
3411
+ at: Date.now(),
3412
+ ok: false,
3413
+ message: error instanceof RefreshUnavailableError ? error.message : "This review could not be brought up to date. Nothing here has changed.",
3414
+ stop: "analysis-failed",
3415
+ fetchFailed: false,
3416
+ moved: false,
3417
+ fromSha: null,
3418
+ toSha: null
3419
+ };
3420
+ }
3421
+ workspace.lastUpdate = outcome;
3422
+ saveWorkspace(workspace);
3423
+ record({
3424
+ event: "review_updated",
3425
+ reviewId: workspace.id,
3426
+ ok: outcome.ok,
3427
+ stop: outcome.stop,
3428
+ moved: outcome.moved,
3429
+ resolved: outcome.counts?.resolved ?? 0,
3430
+ stillOpen: outcome.counts?.stillOpen ?? 0,
3431
+ affected: outcome.counts?.affected ?? 0,
3432
+ newIssues: outcome.counts?.newIssues ?? 0,
3433
+ questionsAnswered: outcome.counts?.questionsAnswered ?? 0
3434
+ });
3435
+ send(res, 200, outcome);
3436
+ }
3437
+ /**
3438
+ * Resume a job that stopped, rather than start one over.
3439
+ *
3440
+ * Fires and returns at once, exactly as starting a job does: `runJob` can take minutes, and this
3441
+ * route answers a button click, not a session that is waiting for the review. `runJob` records
3442
+ * every outcome, success or failure, on the job itself before it resolves or rejects, so there is
3443
+ * nothing left worth doing with a rejection here except not letting it become an unhandled one.
3444
+ *
3445
+ * Refused up front when the job is not terminal, rather than left to `runJob`'s own guard: a job
3446
+ * that is still running has nothing here worth retrying, and saying so is cheaper and clearer than
3447
+ * a 200 that quietly does nothing.
3448
+ *
3449
+ * Refused again when a driver still holds the claim, which is not the same question. A job can be
3450
+ * terminal and driven at once: the sweep marks a job failed after half an hour of silence, and a
3451
+ * long analysis that streams nothing is silent while being perfectly alive. Without this line that
3452
+ * job is retryable, and pressing Retry starts a second driver against the worktree the first one is
3453
+ * still using. `runJob` refuses it too, but only after this route has already answered 200 and told
3454
+ * the reviewer their retry began.
3455
+ */
3456
+ async function handleRetry(res, workspace) {
3457
+ if (!workspace.jobId) return send(res, 404, { error: "No job for this review." });
3458
+ const job = loadJob(workspace.jobId);
3459
+ if (!job || !isTerminal(job) || isClaimed(job.id)) return send(res, 409, { error: "This review is still being made. Wait for it to finish before retrying." });
3460
+ runJob(workspace.id).catch(() => {});
3461
+ send(res, 200, { ok: true });
3462
+ }
3463
+ /**
3464
+ * Never throws.
3465
+ *
3466
+ * The checkout may have been deleted, moved, or turned into something that is not a repository
3467
+ * since the review was made — all of which are ordinary, and none of which is a reason to refuse
3468
+ * to serve a review that is sitting complete on disk. The page renders without the banner, which
3469
+ * reads as "not checked" rather than as "nothing has changed".
3470
+ */
3471
+ async function freshnessOrNull(workspace) {
3472
+ try {
3473
+ return await checkFreshness(workspace);
3474
+ } catch {
3475
+ return null;
3476
+ }
3477
+ }
3478
+ async function handleAsk(req, res, workspace) {
3479
+ const body = await readJson(req);
3480
+ const question = typeof body?.question === "string" ? body.question.trim() : "";
3481
+ if (!question) return send(res, 400, { error: "No question given." });
3482
+ const sectionId = typeof body?.sectionId === "string" ? body.sectionId : null;
3483
+ record({
3484
+ event: "assistant_question_asked",
3485
+ reviewId: workspace.id,
3486
+ source: askSource(body?.source),
3487
+ scoped: sectionId !== null,
3488
+ turn: workspace.transcript.length + 1
3489
+ });
3490
+ const startedAt = Date.now();
3491
+ let answer;
3492
+ try {
3493
+ answer = await askAboutChange(workspace, question, sectionId);
3494
+ } catch (error) {
3495
+ record({
3496
+ event: "assistant_answered",
3497
+ reviewId: workspace.id,
3498
+ durationMs: Date.now() - startedAt,
3499
+ ok: false
3500
+ });
3501
+ throw error;
3502
+ }
3503
+ record({
3504
+ event: "assistant_answered",
3505
+ reviewId: workspace.id,
3506
+ durationMs: Date.now() - startedAt,
3507
+ ok: true
3508
+ });
3509
+ workspace.transcript.push({
3510
+ question,
3511
+ answer,
3512
+ sectionId,
3513
+ askedAt: Date.now()
3514
+ });
3515
+ saveWorkspace(workspace);
3516
+ send(res, 200, { answer });
3517
+ }
3518
+ /**
3519
+ * Stop serving, at the request of a workspace.
3520
+ *
3521
+ * The reply goes first and the exit follows on the next tick: a process that dies mid-response
3522
+ * leaves the page unable to tell "stopped" from "crashed", which are the two things the reviewer
3523
+ * most needs to distinguish here.
3524
+ *
3525
+ * One server serves every review, so this ends all of them. The page says so before asking.
3526
+ */
3527
+ function handleShutdown(res, options) {
3528
+ send(res, 200, { ok: true });
3529
+ const stop = options.onShutdown ?? (() => process.exit(0));
3530
+ setTimeout(stop, 50).unref();
3531
+ }
3532
+ /**
3533
+ * Reviewer preferences, which outlive any one review.
3534
+ *
3535
+ * Stored server-side rather than in the page's `localStorage` because that is keyed by origin and
3536
+ * this server takes a fresh port on every start — a restart would silently forget a width the
3537
+ * reviewer chose deliberately. A null width means "back to the stylesheet's default".
3538
+ */
3539
+ /**
3540
+ * The reviewer's saved prompts.
3541
+ *
3542
+ * The page sends the whole bank rather than a delta, and gets back the bank as stored — which is
3543
+ * not necessarily the bank it sent. `writePromptBank` trims, truncates and drops, so returning
3544
+ * the result is what stops the page from showing an entry the file does not hold.
3545
+ *
3546
+ * Global state behind a per-review route, exactly as the assistant width already is. The token
3547
+ * that authorises it belongs to a review, but what it protects is the machine's user.
3548
+ */
3549
+ async function handlePrompts(req, res) {
3550
+ if (req.method !== "POST") return send(res, 200, { prompts: readPromptBank() });
3551
+ send(res, 200, { prompts: writePromptBank((await readJson(req))?.prompts) });
3552
+ }
3553
+ async function handlePrefs(req, res) {
3554
+ const askWidth = (await readJson(req))?.askWidth;
3555
+ if (typeof askWidth === "number") writeAskWidth(askWidth);
3556
+ else if (askWidth === null) clearAskWidth();
3557
+ send(res, 200, { ok: true });
3558
+ }
3559
+ async function handleState(req, res, workspace) {
3560
+ const body = await readJson(req);
3561
+ let changed = false;
3562
+ if (Array.isArray(body?.answeredQuestionIds)) {
3563
+ workspace.session.answeredQuestionIds = body.answeredQuestionIds.filter((id) => typeof id === "string");
3564
+ changed = true;
3565
+ }
3566
+ if (typeof body?.dismissFreshnessSha === "string" || body?.dismissFreshnessSha === null) {
3567
+ workspace.dismissedFreshnessSha = body.dismissFreshnessSha ?? void 0;
3568
+ changed = true;
3569
+ }
3570
+ if (typeof body?.complete === "boolean") {
3571
+ workspace.completedAt = body.complete ? Date.now() : void 0;
3572
+ changed = true;
3573
+ }
3574
+ if (changed) saveWorkspace(workspace);
3575
+ send(res, 200, {
3576
+ ok: true,
3577
+ completedAt: workspace.completedAt ?? null
3578
+ });
3579
+ }
3580
+ /** 1 MB is far more than any question or id list, and far less than a memory problem. */
3581
+ var MAX_BODY_BYTES = 1048576;
3582
+ async function readJson(req) {
3583
+ const chunks = [];
3584
+ let size = 0;
3585
+ for await (const chunk of req) {
3586
+ size += chunk.length;
3587
+ if (size > MAX_BODY_BYTES) throw new Error("Request body too large.");
3588
+ chunks.push(chunk);
3589
+ }
3590
+ try {
3591
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
3592
+ } catch {
3593
+ return null;
3594
+ }
3595
+ }
3596
+ function send(res, status, payload) {
3597
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
3598
+ res.end(JSON.stringify(payload));
3599
+ }
3600
+ var ASK_SOURCES = [
3601
+ "suggestion",
3602
+ "free_text",
3603
+ "explain_simply",
3604
+ "selection"
3605
+ ];
3606
+ function askSource(value) {
3607
+ return ASK_SOURCES.includes(value) ? value : "free_text";
3608
+ }
3609
+ /**
3610
+ * Usage events from the page.
3611
+ *
3612
+ * Rebuilt field by field from a fixed list rather than forwarded, so the log holds only what this
3613
+ * file constructs. A page could otherwise post anything into the record — and the point of the
3614
+ * event union is that there is nowhere for a path, a title or a question to end up.
3615
+ */
3616
+ async function handleEvent(req, res, workspace) {
3617
+ const body = await readJson(req);
3618
+ const event = toEvent(body, workspace.id);
3619
+ if (event) record(event);
3620
+ if (body?.event === "review_completed") {
3621
+ const trailing = toEvent({
3622
+ event: "workspace_heartbeat",
3623
+ visibleSeconds: body.trailingSeconds
3624
+ }, workspace.id);
3625
+ if (trailing && trailing.visibleSeconds > 0) record(trailing);
3626
+ recordReviewCompleted(workspace.id);
3627
+ }
3628
+ send(res, 200, { ok: true });
3629
+ }
3630
+ var VIEWS = [
3631
+ "overview",
3632
+ "guide",
3633
+ "issues",
3634
+ "questions",
3635
+ "files",
3636
+ "prcontext"
3637
+ ];
3638
+ var ATTENTIONS = [
3639
+ "high",
3640
+ "medium",
3641
+ "low"
3642
+ ];
3643
+ function toEvent(body, reviewId) {
3644
+ const int = (value) => Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
3645
+ switch (body?.event) {
3646
+ case "view_opened": {
3647
+ const view = VIEWS.find((candidate) => candidate === body.view);
3648
+ return view ? {
3649
+ event: "view_opened",
3650
+ reviewId,
3651
+ view
3652
+ } : null;
3653
+ }
3654
+ case "theme_opened": {
3655
+ const attention = ATTENTIONS.find((candidate) => candidate === body.attention);
3656
+ return attention ? {
3657
+ event: "theme_opened",
3658
+ reviewId,
3659
+ themeIndex: int(body.themeIndex),
3660
+ attention,
3661
+ isRecommendedStart: body.isRecommendedStart === true
3662
+ } : null;
3663
+ }
3664
+ case "file_opened": return {
3665
+ event: "file_opened",
3666
+ reviewId,
3667
+ fromFinding: body.fromFinding === true,
3668
+ targeted: body.targeted === true
3669
+ };
3670
+ case "editor_opened": return {
3671
+ event: "editor_opened",
3672
+ reviewId,
3673
+ fromFinding: body.fromFinding === true
3674
+ };
3675
+ case "question_marked_answered": return {
3676
+ event: "question_marked_answered",
3677
+ reviewId,
3678
+ answered: int(body.answered),
3679
+ total: int(body.total)
3680
+ };
3681
+ case "workspace_heartbeat": return {
3682
+ event: "workspace_heartbeat",
3683
+ reviewId,
3684
+ visibleSeconds: Math.min(int(body.visibleSeconds), 120)
3685
+ };
3686
+ default: return null;
3687
+ }
3688
+ }
3689
+ /**
3690
+ * The reviewer-facing half of that. A job whose driver was killed did not fail: it stopped
3691
+ * updating, which the page had no way to tell from working. This is the sentence that makes the
3692
+ * difference visible, and marking the job failed is what puts Retry on the page.
3693
+ */
3694
+ var ABANDONED_MESSAGE = "Nothing has updated it for over half an hour, so whatever was making it is no longer running.";
3695
+ async function sweep(now = Date.now()) {
3696
+ const report = {
3697
+ abandoned: 0,
3698
+ retired: 0,
3699
+ orphansRemoved: 0,
3700
+ refusals: []
3701
+ };
3702
+ const jobs = allJobs();
3703
+ for (const job of jobs) {
3704
+ if (!isTerminal(job)) {
3705
+ if (markAbandoned(job, now)) report.abandoned += 1;
3706
+ continue;
3707
+ }
3708
+ if (now - finishedAt(job) < 216e5) continue;
3709
+ if (await retire(job, report)) report.retired += 1;
3710
+ }
3711
+ await removeOrphans(jobs, report);
3712
+ return report;
3713
+ }
3714
+ /** When this job stopped doing work. `finishedAt` predates nothing, but a record can be old. */
3715
+ function finishedAt(job) {
3716
+ return job.finishedAt ?? job.updatedAt;
3717
+ }
3718
+ /**
3719
+ * Record that a job stopped, unless it has moved since this pass read it.
3720
+ *
3721
+ * The reload is the whole of the safety here. Between `allJobs` and this line a driver in another
3722
+ * process can have written a progress line, and saving a failure over that would take a review
3723
+ * that is being made and tell its reviewer it stopped.
3724
+ */
3725
+ function markAbandoned(job, now) {
3726
+ if (now - job.updatedAt < 18e5) return false;
3727
+ if (isClaimed(job.id, now)) return false;
3728
+ const current = loadJob(job.id);
3729
+ if (!current || current.updatedAt !== job.updatedAt) return false;
3730
+ saveJob(fail(current, ABANDONED_MESSAGE));
3731
+ return true;
3732
+ }
3733
+ /**
3734
+ * Remove a finished job's checkout, and then its record.
3735
+ *
3736
+ * In that order, and the record only if the directory went. The record is the only thing that
3737
+ * names the directory; deleting it first would turn a refusal into an orphan and lose the origin
3738
+ * repository that the removal guard needs to ask git about it.
3739
+ */
3740
+ async function retire(job, report) {
3741
+ if (job.worktreePath) {
3742
+ const outcome = await removeWorktree({
3743
+ originRepoPath: job.target.originRepoPath,
3744
+ worktreePath: job.worktreePath
3745
+ });
3746
+ if (!outcome.removed) {
3747
+ report.refusals.push(outcome.message);
3748
+ return false;
3749
+ }
3750
+ }
3751
+ deleteJob(job.id);
3752
+ clearClaim(job.id);
3753
+ return true;
3754
+ }
3755
+ /**
3756
+ * Directories under the managed root that no job record names.
3757
+ *
3758
+ * A crash between creating a worktree and saving the record that points at it leaves exactly this.
3759
+ * The origin repository comes from the directory's own marker, because there is nothing else left
3760
+ * to ask, and that does make `removeWorktree`'s marker check tautological on this path. It is not
3761
+ * the guard here and was never meant to be: git is asked first and answers about a real repository,
3762
+ * and where git has disowned the path what remains is the managed root and `ORPHAN_MIN_AGE_MS`,
3763
+ * which is the ordering the removal guard has always had.
3764
+ */
3765
+ async function removeOrphans(jobs, report) {
3766
+ const claimed = new Set(jobs.map((job) => job.worktreePath).filter((path) => !!path));
3767
+ let names;
3768
+ try {
3769
+ names = readdirSync(MANAGED_ROOT);
3770
+ } catch {
3771
+ return;
3772
+ }
3773
+ for (const name of names) {
3774
+ const path = join(MANAGED_ROOT, name);
3775
+ if (claimed.has(path)) continue;
3776
+ try {
3777
+ if (!statSync(path).isDirectory()) continue;
3778
+ } catch {
3779
+ continue;
3780
+ }
3781
+ const marker = readMarker(path);
3782
+ if (!marker) continue;
3783
+ const outcome = await removeWorktree({
3784
+ originRepoPath: marker.originRepoPath,
3785
+ worktreePath: path
3786
+ });
3787
+ if (outcome.removed) report.orphansRemoved += 1;
3788
+ else if (outcome.reason !== "too-young") report.refusals.push(outcome.message);
3789
+ }
3790
+ }
3791
+ /**
3792
+ * How often the pass runs.
3793
+ *
3794
+ * Chosen from what it costs to be late rather than what it costs to run. A sweep is a directory
3795
+ * listing and a `git worktree list` per finished job, so ten minutes is nothing; being an hour
3796
+ * late telling a reviewer their review stopped is not. Ten minutes puts that answer between thirty
3797
+ * and forty minutes after the last sign of life.
3798
+ */
3799
+ var SWEEP_INTERVAL_MS = 6e5;
3800
+ /**
3801
+ * Run the pass on a timer, and return the stop.
3802
+ *
3803
+ * Once immediately, because a server that has just started is very often one that restarted after
3804
+ * whatever killed the last one, and the jobs that died with it are exactly what this finds.
3805
+ *
3806
+ * Nothing here throws outward. A sweep that fails is disk it did not reclaim, and the server it
3807
+ * runs inside is serving reviews, which matters more.
3808
+ */
3809
+ function startSweeping() {
3810
+ const run = () => {
3811
+ sweep().catch(() => {});
3812
+ };
3813
+ run();
3814
+ const timer = setInterval(run, SWEEP_INTERVAL_MS);
3815
+ timer.unref();
3816
+ return () => clearInterval(timer);
3817
+ }
3818
+ //#endregion
3819
+ //#region ../../packages/review-harness/src/cli/server.ts
3820
+ /**
3821
+ * The workspace server process.
3822
+ *
3823
+ * Started detached by `ensureServer` and left running, so a workspace stays usable after the
3824
+ * Claude Code session that produced it has closed. One server serves every review; individual
3825
+ * reviews are addressed by id and gated by their own token.
3826
+ */
3827
+ var indexToken = randomBytes(24).toString("base64url");
3828
+ var server = createWorkspaceServer({
3829
+ indexToken,
3830
+ onShutdown: () => process.kill(process.pid, "SIGTERM")
3831
+ });
3832
+ server.listen(0, "127.0.0.1", () => {
3833
+ const address = server.address();
3834
+ if (address === null || typeof address === "string") process.exit(1);
3835
+ writeServerRecord({
3836
+ pid: process.pid,
3837
+ port: address.port,
3838
+ startedAt: Date.now(),
3839
+ token: indexToken
3840
+ });
3841
+ });
3842
+ var stopSweeping = startSweeping();
3843
+ for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => {
3844
+ stopSweeping();
3845
+ clearServerRecord();
3846
+ server.close(() => process.exit(0));
3847
+ });
3848
+ //#endregion
3849
+ export {};