prreviewbuddy 0.31.0 → 0.34.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 CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as fillFileUrlTemplate, $t as writeAgentPreference, A as removeWorktree, At as workspaceRevision, Bt as isQuestionOutstanding, C as recordTelemetryUploadConsent, Ct as previousKissResult, D as ReviewBeingDeletedError, Dt as saveWorkspace, E as EXPLAIN_SIMPLY_PROMPT, F as clearServerRecord, Ft as queueStamp, G as DEFAULT_WORKSPACE_PORT, Gt as describeAuthorship, H as BUILD_VERSION, Ht as questionsOutstanding, It as readQueue, J as parseWorkspacePort, Jt as git, K as MAX_WORKSPACE_PORT, L as readServerRecord, Lt as writeQueue, M as relativeTime$1, Mt as dequeueOnComplete, N as readIndexToken, Nt as queueAdd, O as MANAGED_ROOT, Pt as queueRemove, Qt as readAgentPreference, Rt as doneVerb, S as record, T as withUsageRecorded, Tt as reviewedCommit, Ut as reviewerDispositions, V as writeServerRecord, Vt as issuesOutstanding, W as feedbackUrl, Wt as processDiscussion, X as statedWorkspacePort, Xt as resolveModel, Y as resolveWorkspacePort, Z as writePortPreference, Zt as clearAgentPreference, _ as startKissJob, _t as latestKissRun, a as wasBlocked, an as agentFor, at as isTerminal, bt as loadWorkspace, c as updateReview, cn as AgentUnavailableError, d as askCheckout, dt as purposeOf, et as checkCodeFreshness, f as UpdateAlreadyRunningError, gt as indexReviewGroups, h as agentEnvOf, i as liveJobsFor, it as fail, jt as workspaceStamps, k as readMarker, kt as touchWorkspace, l as refreshPrContext, lt as phasesFor, mt as followedRefName, n as deleteReview, nn as AGENT_IDS, nt as allJobs, o as discardJob, ot as loadJob, p as liveUpdateFor, pt as isClaimed, q as MIN_WORKSPACE_PORT, r as lineageIds, rt as endedAt, s as RefreshUnavailableError, sn as AgentCancelledError, st as saveJob, t as deleteLineage, tn as STORE_ROOT, tt as checkReview, u as isUnchanged, ut as progressSteps, w as telemetryUploadConsent, wt as recentWorkspaces, x as readEvents, y as runJob, yt as lineagePosition, zt as isIssueOutstanding } from "./delete_review-C2wax0ED.js";
1
+ import { $ as feedbackUrl, $t as reviewerDispositions, A as markRead, At as loadWorkspace, Bt as workspaceRevision, C as recordTelemetryUploadConsent, Ct as isClaimed, D as headline, Dt as latestKissRun, E as eventName, Et as indexReviewGroups, F as EXPLAIN_SIMPLY_PROMPT, Ft as reviewedCommit, Gt as queueStamp, H as readIndexToken, Ht as dequeueOnComplete, I as ReviewBeingDeletedError, Jt as doneVerb, K as readServerRecord, Kt as readQueue, L as MANAGED_ROOT, Lt as saveWorkspace, M as readNotification, N as unreadCount$1, Nt as previousKissResult, O as listNotifications, P as withUsageRecorded, Pt as recentWorkspaces, Qt as questionsOutstanding, R as readMarker, S as record, T as announceJob, Ut as queueAdd, V as relativeTime$1, Vt as workspaceStamps, W as clearServerRecord, Wt as queueRemove, X as writeServerRecord, Xt as isQuestionOutstanding, Yt as isIssueOutstanding, Z as BUILD_VERSION, Zt as issuesOutstanding, _ as startKissJob, _n as AgentCancelledError, a as wasBlocked, at as statedWorkspacePort, bt as purposeOf, c as updateReview, cn as readAgentPreference, ct as fillFileUrlTemplate, d as askCheckout, dn as STORE_ROOT, dt as allJobs, en as processDiscussion, et as DEFAULT_WORKSPACE_PORT, f as UpdateAlreadyRunningError, fn as AGENT_IDS, ft as endedAt, gt as saveJob, h as agentEnvOf, hn as agentFor, ht as loadJob, i as liveJobsFor, in as git, it as resolveWorkspacePort, j as pruneNotifications, k as markAllRead, kt as lineagePosition, l as refreshPrContext, ln as writeAgentPreference, lt as checkCodeFreshness, mt as isTerminal, n as deleteReview, nt as MIN_WORKSPACE_PORT, o as discardJob, on as resolveModel, ot as writePortPreference, p as liveUpdateFor, pt as fail, qt as writeQueue, r as lineageIds, rt as parseWorkspacePort, s as RefreshUnavailableError, sn as clearAgentPreference, t as deleteLineage, tn as describeAuthorship, tt as MAX_WORKSPACE_PORT, u as isUnchanged, ut as checkReview, vn as AgentUnavailableError, vt as phasesFor, w as telemetryUploadConsent, wt as followedRefName, x as readEvents, y as runJob, yt as progressSteps, z as removeWorktree, zt as touchWorkspace } from "./delete_review-TNIFNct3.js";
2
2
  import { isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
4
  import { createHash, timingSafeEqual } from "node:crypto";
@@ -250,6 +250,7 @@ var PATHS = {
250
250
  prcontext: "<path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"/>",
251
251
  recommendations: "<path d=\"M9 18h6M10 22h4\"/><path d=\"M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.2 1 2V17h6v-.3c0-.8.4-1.5 1-2A7 7 0 0 0 12 2z\"/>",
252
252
  reviews: "<path d=\"M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01\"/>",
253
+ notifications: "<path d=\"M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9\"/><path d=\"M13.7 21a2 2 0 0 1-3.4 0\"/>",
253
254
  settings: "<circle cx=\"12\" cy=\"12\" r=\"3\"/><path d=\"M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z\"/>",
254
255
  feedback: "<path d=\"m22 2-7 20-4-9-9-4z\"/><path d=\"M22 2 11 13\"/>",
255
256
  stop: "<path d=\"M12 2v10\"/><path d=\"M18.4 6.6a9 9 0 1 1-12.8 0\"/>"
@@ -267,6 +268,122 @@ function railIcon(icon) {
267
268
  function iconSvg(icon) {
268
269
  return `<svg viewBox="0 0 24 24" focusable="false" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">${PATHS[icon]}</svg>`;
269
270
  }
271
+ /**
272
+ * Which new notifications become toasts, and how many are left over.
273
+ *
274
+ * Never more than `MAX_TOASTS` toasts from one poll, the overflow toast included: past the cap, the
275
+ * last slot says how many more there are rather than one of them being dropped without a word.
276
+ */
277
+ function splitForToasts(fresh) {
278
+ if (fresh.length <= 3) return {
279
+ shown: [...fresh],
280
+ more: 0
281
+ };
282
+ const shown = fresh.slice(0, 2);
283
+ return {
284
+ shown,
285
+ more: fresh.length - shown.length
286
+ };
287
+ }
288
+ function unreadLabel(unread) {
289
+ return unread === 0 ? "Nothing unread" : `${unread} unread`;
290
+ }
291
+ //#endregion
292
+ //#region ../../packages/review-harness/src/workspace/workspace_nav.ts
293
+ /**
294
+ * The workspace's own places, and the one model every page draws them from.
295
+ *
296
+ * A review page adds its review-level groups above these; every other workspace page draws these
297
+ * alone. There is no second list: the rail, the narrow header and the review page's rail foot all
298
+ * read `WORKSPACE_PLACES`, so a place added here appears everywhere and a place renamed here is
299
+ * renamed everywhere.
300
+ */
301
+ var WORKSPACE_PLACES = [
302
+ {
303
+ place: "reviews",
304
+ href: "/",
305
+ icon: "reviews",
306
+ label: "Reviews"
307
+ },
308
+ {
309
+ place: "notifications",
310
+ href: "/notifications",
311
+ icon: "notifications",
312
+ label: "Notifications"
313
+ },
314
+ {
315
+ place: "settings",
316
+ href: "/settings",
317
+ icon: "settings",
318
+ label: "Settings"
319
+ }
320
+ ];
321
+ /**
322
+ * The count beside Notifications. Always rendered, hidden at zero, so the page can reveal it when
323
+ * `/revision` or Mark all read reports a new number. See `client/unread_counts.ts`.
324
+ */
325
+ function unreadCount(unread, style) {
326
+ const className = style === "rail" ? "prb-rail-count prb-rail-unread" : "prb-unread-badge";
327
+ const text = unread === 0 ? "" : style === "rail" ? unreadLabel(unread) : String(unread);
328
+ return `<span class="${className}" data-unread-count data-unread-style="${style}"${unread === 0 ? " hidden" : ""}>${text}</span>`;
329
+ }
330
+ /** The workspace rows, for a rail group. */
331
+ function renderWorkspaceNav(options) {
332
+ return WORKSPACE_PLACES.map((item) => `<a class="prb-rail-item prb-rail-link" href="${item.href}"${item.place === options.current ? " aria-current=\"true\"" : ""}>
333
+ ${railIcon(item.icon)}
334
+ <span class="prb-rail-label"><span class="prb-rail-title">${escapeHtml$1(item.label)}</span></span>
335
+ ${item.place === "notifications" ? unreadCount(options.unread, "rail") : ""}
336
+ </a>`).join("");
337
+ }
338
+ /**
339
+ * Stop server. Operational rather than navigation, and it ends every open review, so it sits apart
340
+ * from the places at the foot of the rail. Pair it with `renderShutdownDialog` on the same page.
341
+ */
342
+ function renderStopServer() {
343
+ return `<button type="button" class="prb-rail-item prb-rail-link prb-rail-danger" data-shutdown>
344
+ ${railIcon("stop")}
345
+ <span class="prb-rail-label"><span class="prb-rail-title">Stop server</span></span>
346
+ </button>`;
347
+ }
348
+ function renderShutdownDialog() {
349
+ return `<dialog class="prb-dialog" data-shutdown-dialog aria-labelledby="prb-shutdown-title">
350
+ <h2 id="prb-shutdown-title">Stop the workspace server?</h2>
351
+ <p>One server serves every review on this machine, so <b>every open workspace stops</b>, not just this one.</p>
352
+ <p class="prb-dialog-reassure">Nothing is deleted. Your reviews stay on disk for thirty days, and their links keep working. Run <code>${escapeHtml$1(wayBackSentence())}</code> to start the server again.</p>
353
+ <div class="prb-dialog-actions">
354
+ <button type="button" data-shutdown-cancel>Cancel</button>
355
+ <button type="button" class="prb-dialog-danger" data-shutdown-confirm>Stop server</button>
356
+ </div>
357
+ </dialog>`;
358
+ }
359
+ /**
360
+ * The feedback form, in a new tab. What it carries is decided in `version.ts`: version, client, OS,
361
+ * and deliberately not the repository or its path.
362
+ */
363
+ function renderSendFeedback() {
364
+ return `<a class="prb-rail-item prb-rail-link" href="${escapeHtml$1(feedbackUrl())}" target="_blank" rel="noopener noreferrer">
365
+ ${railIcon("feedback")}
366
+ <span class="prb-rail-label"><span class="prb-rail-title">Send feedback</span></span>
367
+ </a>`;
368
+ }
369
+ /**
370
+ * The whole rail of a workspace page that is not a review: the places at the top, then Send
371
+ * feedback and Stop server at the foot, in the order a review page's rail ends with them.
372
+ */
373
+ function renderWorkspaceRail(options) {
374
+ return `<nav class="prb-rail" aria-label="Workspace">
375
+ <div class="prb-rail-group">${renderWorkspaceNav(options)}</div>
376
+ <div class="prb-rail-group prb-rail-foot">${renderSendFeedback()}</div>
377
+ <div class="prb-rail-group prb-rail-ops">${renderStopServer()}</div>
378
+ </nav>`;
379
+ }
380
+ /**
381
+ * The rail's links in the header, shown only while the rail is hidden by a narrow window. Without it
382
+ * a narrow window would have no way to Notifications, Settings or the feedback form at all.
383
+ */
384
+ function renderHeaderNav(options) {
385
+ return `<nav class="prb-header-nav" aria-label="Workspace">${WORKSPACE_PLACES.map((item) => `<a class="prb-feedback${item.place === "notifications" ? " prb-notifications-link" : ""}" href="${item.href}"${item.place === options.current ? " aria-current=\"true\"" : ""}>${escapeHtml$1(item.label)}${item.place === "notifications" ? unreadCount(options.unread, "badge") : ""}</a>`).join("")}<a class="prb-feedback" href="${escapeHtml$1(feedbackUrl())}" target="_blank" rel="noopener noreferrer">Send feedback</a></nav>`;
386
+ }
270
387
  //#endregion
271
388
  //#region ../../packages/review-harness/src/domain/utils/file_path.ts
272
389
  /**
@@ -1282,13 +1399,13 @@ function discussionDetails(session) {
1282
1399
  if (reviewThreads > 0) {
1283
1400
  const outdated = stats.outdatedThreads ?? 0;
1284
1401
  const state = stats.unresolvedThreads > 0 ? `${stats.unresolvedThreads} unresolved${outdated > 0 ? `, ${outdated} on outdated lines` : ""}` : "all resolved";
1285
- parts.push(`${reviewThreads} ${plural(reviewThreads, "review thread")} (${state})`);
1402
+ parts.push(`${reviewThreads} ${plural$1(reviewThreads, "review thread")} (${state})`);
1286
1403
  }
1287
- if (conversation > 0) parts.push(`${conversation} ${plural(conversation, "conversation comment")}`);
1288
- if (decisions > 0) parts.push(`${decisions} ${plural(decisions, "review decision")}`);
1404
+ if (conversation > 0) parts.push(`${conversation} ${plural$1(conversation, "conversation comment")}`);
1405
+ if (decisions > 0) parts.push(`${decisions} ${plural$1(decisions, "review decision")}`);
1289
1406
  return parts;
1290
1407
  }
1291
- function plural(count, word) {
1408
+ function plural$1(count, word) {
1292
1409
  return count === 1 ? word : `${word}s`;
1293
1410
  }
1294
1411
  function renderExistingDiscussion(ref) {
@@ -1337,6 +1454,17 @@ function deriveSectionStatuses(sections, completed) {
1337
1454
  const active = nextRecommendedSection(sections, completed);
1338
1455
  return new Map(sections.map((s) => [s.id, completed.has(s.id) ? "completed" : s.id === active?.id ? "active" : "pending"]));
1339
1456
  }
1457
+ /**
1458
+ * Roughly how long one theme takes, for the rail.
1459
+ *
1460
+ * The same weight ranges the theme card quotes, reduced to a single number because a rail row has
1461
+ * room for one. The 1-minute floor exists for the same reason the whole-review estimate has a
1462
+ * 5-minute one: "~0 min" reads as "skip this".
1463
+ */
1464
+ function themeMinutes(section) {
1465
+ const [min, max] = estimateMinutesForWeight(section.reviewWeight);
1466
+ return Math.max(1, Math.round((min + max) / 2));
1467
+ }
1340
1468
  function remainingMinutes(sections, completed) {
1341
1469
  const outstanding = sections.filter((s) => !completed.has(s.id));
1342
1470
  if (outstanding.length === 0) return 0;
@@ -1383,6 +1511,29 @@ function renderGuideProgressSummary(sections, completed) {
1383
1511
  </div>
1384
1512
  `;
1385
1513
  }
1514
+ /**
1515
+ * The theme's header: a disclosure button where it can fold something away, a heading where it
1516
+ * cannot.
1517
+ *
1518
+ * The two differ only in the element and the arrow. Splitting them is what keeps a host that
1519
+ * shows one theme at a time from shipping a button whose only reachable outcome is an empty pane,
1520
+ * without either host describing a theme differently from the other.
1521
+ */
1522
+ function renderSectionHeader(section, badges, statParts, collapsible) {
1523
+ const inside = `
1524
+ <div class="pr-helper-guide-section-badges">
1525
+ ${badges}
1526
+ <span class="pr-helper-guide-badge pr-helper-attention-badge pr-helper-attention-${section.reviewAttention}">${escapeHtml$1(SECTION_ATTENTION_LABELS[section.reviewAttention])}</span>
1527
+ </div>
1528
+ <div class="pr-helper-guide-section-header-top">
1529
+ ${collapsible ? "<span class=\"pr-helper-guide-disclosure\">▼</span>" : ""}
1530
+ <span class="pr-helper-guide-section-title">${escapeHtml$1(section.title)}</span>
1531
+ </div>
1532
+ <div class="pr-helper-guide-meta-row" title="Review weight ${section.reviewWeight}/5">${escapeHtml$1(statParts.join(" · "))}</div>`;
1533
+ return collapsible ? `<button type="button" class="pr-helper-guide-section-header" data-guide-toggle="${escapeHtml$1(section.id)}">${inside}
1534
+ </button>` : `<div class="pr-helper-guide-section-header pr-helper-guide-section-header-static">${inside}
1535
+ </div>`;
1536
+ }
1386
1537
  function renderGuideSectionCard(section, counts, activeGuidedSectionId, status, activeBadgeLabel, options) {
1387
1538
  const files = section.files.map((f) => {
1388
1539
  const focus = f.focus ? `<div class="pr-helper-guide-file-focus">${renderInlineText(f.focus)}</div>` : "";
@@ -1398,31 +1549,21 @@ function renderGuideSectionCard(section, counts, activeGuidedSectionId, status,
1398
1549
  statParts.push(`~${minMinutes}-${maxMinutes} min`);
1399
1550
  const isActive = activeGuidedSectionId === section.id;
1400
1551
  const isCompleted = status === "completed";
1552
+ const collapsible = options?.collapsible !== false;
1401
1553
  const check = isCompleted ? `<span class="pr-helper-guide-check" aria-hidden="true">✓</span>` : "";
1402
1554
  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$1(activeBadgeLabel)}</span>` : "";
1403
1555
  const undo = isCompleted ? `<button type="button" class="pr-helper-guide-uncomplete" data-guide-uncomplete="${escapeHtml$1(section.id)}">Mark as not reviewed</button>` : "";
1404
1556
  return `
1405
1557
  <section class="pr-helper-guide-section${isCompleted ? " pr-helper-guide-section-completed" : ""}${options?.collapsedSections ? " pr-helper-guide-section-collapsed" : ""}" id="pr-helper-guide-section-${escapeHtml$1(section.id)}" data-guide-section-card="${escapeHtml$1(section.id)}">
1406
- <button type="button" class="pr-helper-guide-section-header" data-guide-toggle="${escapeHtml$1(section.id)}">
1407
- <div class="pr-helper-guide-section-badges">
1408
- ${check}
1409
- ${statusBadge}
1410
- <span class="pr-helper-guide-badge pr-helper-attention-badge pr-helper-attention-${section.reviewAttention}">${escapeHtml$1(SECTION_ATTENTION_LABELS[section.reviewAttention])}</span>
1411
- </div>
1412
- <div class="pr-helper-guide-section-header-top">
1413
- <span class="pr-helper-guide-disclosure">▼</span>
1414
- <span class="pr-helper-guide-section-title">${escapeHtml$1(section.title)}</span>
1415
- </div>
1416
- <div class="pr-helper-guide-meta-row" title="Review weight ${section.reviewWeight}/5">${escapeHtml$1(statParts.join(" · "))}</div>
1417
- </button>
1558
+ ${renderSectionHeader(section, `${check}${statusBadge}`, statParts, collapsible)}
1418
1559
  <div class="pr-helper-guide-section-body">
1419
1560
  <p class="pr-helper-guide-summary">${renderInlineText(section.oneLineSummary)}</p>
1420
1561
  ${section.purpose ? `<p class="pr-helper-guide-purpose">${renderInlineText(section.purpose)}</p>` : ""}
1421
- <h5>Files</h5>
1422
- <ul class="pr-helper-guide-files">${files}</ul>
1423
1562
  ${section.whyReview ? `<h5>Why review this</h5><p class="pr-helper-guide-why">${renderInlineText(section.whyReview)}</p>` : ""}
1424
1563
  <h5>Review checklist</h5>
1425
1564
  <ul class="pr-helper-guide-checklist">${checklist}</ul>
1565
+ <h5>Files</h5>
1566
+ <ul class="pr-helper-guide-files">${files}</ul>
1426
1567
  <div class="pr-helper-guide-section-actions">
1427
1568
  <button type="button" class="pr-helper-guide-start-cta" data-guide-start="${escapeHtml$1(section.id)}">
1428
1569
  ${isActive ? "Resume guided review →" : isCompleted ? "Review again →" : "Start guided review →"}
@@ -1898,6 +2039,90 @@ function renderQuestionsTab(session, investigatingId, options) {
1898
2039
  `).join("")}${ungrouped.map(card).join("")}${closed}`;
1899
2040
  }
1900
2041
  //#endregion
2042
+ //#region ../../packages/review-harness/src/workspace/guide_view.ts
2043
+ /**
2044
+ * The workspace's Guide: one theme at a time.
2045
+ *
2046
+ * The extension renders the Guide as a stack of collapsible cards because a 460px drawer beside a
2047
+ * diff has no room for anything else. The workspace has an 860px pane and a rail that already
2048
+ * lists every theme as a selectable row, so stacking them here bought a long scroll and nothing
2049
+ * else: reading the second theme meant scrolling past the whole of the first.
2050
+ *
2051
+ * So this is a composition, not a second Guide. The theme body is still
2052
+ * `renderGuideSectionCard`, and the extension's `renderGuideTab` is untouched. What changes is
2053
+ * which themes are on screen and what sits around them.
2054
+ *
2055
+ * Every theme is rendered and all but one carries `hidden`, the same way `.prb-view` works on this
2056
+ * page: switching a theme is then a class change rather than a request, and the reviewer's place
2057
+ * in a long checklist survives it.
2058
+ */
2059
+ /**
2060
+ * What the workspace asks of a theme card: a diagram button, never a folded card, and no control
2061
+ * to fold it. Collapsing made sense when a theme was one of several stacked in a column and
2062
+ * folding it brought the next into view. Here it would leave a title over an empty pane.
2063
+ */
2064
+ var CARD_OPTIONS = {
2065
+ diagrams: true,
2066
+ collapsible: false
2067
+ };
2068
+ /** What the shared renderer is asked for when this view has nothing of its own to draw. */
2069
+ var DELEGATED_OPTIONS = {
2070
+ diagrams: true,
2071
+ collapsedSections: true
2072
+ };
2073
+ function renderWorkspaceGuide(session, followUpInFlight = null) {
2074
+ const outcome = outcomeOf(session);
2075
+ if (!session || outcome.kind !== "empty" && outcome.kind !== "findings") return renderGuideTab(session, null, followUpInFlight, DELEGATED_OPTIONS);
2076
+ const result = outcome.review;
2077
+ const sections = result.sections;
2078
+ if (sections.length === 0) return renderGuideTab(session, null, followUpInFlight, DELEGATED_OPTIONS);
2079
+ const completed = new Set(session.completedSectionIds ?? []);
2080
+ const statuses = deriveSectionStatuses(sections, completed);
2081
+ const activeBadgeLabel = [...statuses.values()].includes("completed") ? "Continue" : "Start here";
2082
+ const selected = nextRecommendedSection(sections, completed) ?? sections[0];
2083
+ const details = sections.map((section) => {
2084
+ const card = renderGuideSectionCard(section, {
2085
+ issues: result.issues.filter((issue) => issue.sectionId === section.id).length,
2086
+ questions: result.questions.filter((question) => question.sectionId === section.id).length
2087
+ }, null, statuses.get(section.id) ?? "pending", activeBadgeLabel, CARD_OPTIONS);
2088
+ const hidden = section.id === selected.id ? "" : " hidden";
2089
+ return `<div class="prb-guide-detail" data-guide-detail="${escapeHtml$1(section.id)}"${hidden}>${card}</div>`;
2090
+ }).join("");
2091
+ return `
2092
+ <div class="pr-helper-guide prb-guide">
2093
+ ${renderGuideProgress(sections, completed)}
2094
+ ${renderThemePicker(sections, selected.id)}
2095
+ <div class="prb-guide-details">${details}</div>
2096
+ <div class="prb-guide-foot">${renderSuggestedAreas(session, followUpInFlight)}</div>
2097
+ </div>
2098
+ `;
2099
+ }
2100
+ /**
2101
+ * One line, because the rail carries the rest.
2102
+ *
2103
+ * The shared Guide opens with a suggested-path box listing every theme in order. Here the rail is
2104
+ * that list, and repeating it above the theme would be the same sentence twice. What the rail does
2105
+ * not carry is time, so that is what this adds to the count.
2106
+ */
2107
+ function renderGuideProgress(sections, completed) {
2108
+ const total = sections.length;
2109
+ const done = sections.filter((section) => completed.has(section.id)).length;
2110
+ const left = remainingMinutes(sections, completed);
2111
+ const time = left > 0 ? ` &middot; ~${left} min` : "";
2112
+ return `<p class="prb-guide-progress">${done > 0 ? `${done} of ${total} theme${total === 1 ? "" : "s"} completed` : `${total} theme${total === 1 ? "" : "s"}`}${time}</p>`;
2113
+ }
2114
+ /**
2115
+ * The same theme list the rail draws, for the width where the rail is not drawn.
2116
+ *
2117
+ * Below 860px the rail is hidden, and a Guide whose only theme navigation lives there would have
2118
+ * none at all. This is not a second navigation model: it is the same sections, reusing
2119
+ * `data-goto-section` so the existing handler drives it, and CSS shows it only where the rail is
2120
+ * absent. The workspace places in the narrow header follow exactly this pattern.
2121
+ */
2122
+ function renderThemePicker(sections, selectedId) {
2123
+ return `<div class="prb-guide-picker">${sections.map((section, index) => `<button type="button" class="prb-guide-pick" data-goto-section="${escapeHtml$1(section.id)}" data-theme-index="${index}" data-recommended-start="${section.recommendedStartingPoint}" aria-current="${section.id === selectedId}"><span class="prb-guide-pick-order">${index + 1}</span>${escapeHtml$1(section.title)}</button>`).join("")}</div>`;
2124
+ }
2125
+ //#endregion
1901
2126
  //#region ../../packages/review-harness/src/workspace/doc_head.ts
1902
2127
  /**
1903
2128
  * The title row a document pane opens with, and the provenance disclosure at the right of it.
@@ -3396,7 +3621,7 @@ function renderWorkspace(view) {
3396
3621
  <link rel="stylesheet" href="/assets/sidebar.css">
3397
3622
  <link rel="stylesheet" href="/assets/shell.css">
3398
3623
  </head>
3399
- <body data-api-base="${escapeHtml$1(view.apiBase)}"${view.token ? ` data-token="${escapeHtml$1(view.token)}"` : ""}${view.editor ? ` data-editor-label="${escapeHtml$1(view.editor.label)}" data-editor-template="${escapeHtml$1(view.editor.template)}"` : ""}${view.repoPath ? ` data-repo-path="${escapeHtml$1(view.repoPath)}"` : ""}${view.revision === void 0 ? "" : ` data-workspace-revision="${escapeHtml$1(String(view.revision))}"`} data-session-id="${escapeHtml$1(view.session.id)}" data-explain-prompt="${escapeHtml$1(EXPLAIN_SIMPLY_PROMPT)}" data-way-back="${escapeHtml$1(wayBackSentence())}" data-default-view="${session.result ? "overview" : showsProgress(view) ? "progress" : "kiss"}">
3624
+ <body data-api-base="${escapeHtml$1(view.apiBase)}"${view.token ? ` data-token="${escapeHtml$1(view.token)}" data-notifications-since="${Date.now()}"` : ""}${view.editor ? ` data-editor-label="${escapeHtml$1(view.editor.label)}" data-editor-template="${escapeHtml$1(view.editor.template)}"` : ""}${view.repoPath ? ` data-repo-path="${escapeHtml$1(view.repoPath)}"` : ""}${view.revision === void 0 ? "" : ` data-workspace-revision="${escapeHtml$1(String(view.revision))}"`} data-session-id="${escapeHtml$1(view.session.id)}" data-explain-prompt="${escapeHtml$1(EXPLAIN_SIMPLY_PROMPT)}" data-way-back="${escapeHtml$1(wayBackSentence())}" data-default-view="${session.result ? "overview" : showsProgress(view) ? "progress" : "kiss"}">
3400
3625
  ${renderHeader(view, freshness)}
3401
3626
  ${showsProgress(view) ? "" : renderJobState(view.job ?? null)}
3402
3627
  <div class="prb-fresh-region" data-fresh-region${freshness ? ` data-report="${escapeHtml$1(JSON.stringify(freshness))}"` : ""}${view.availability ? ` data-availability="${escapeHtml$1(JSON.stringify(view.availability))}"` : ""}${view.dismissedFreshnessSha ? ` data-dismissed-sha="${escapeHtml$1(view.dismissedFreshnessSha)}"` : ""}>${renderFreshness(freshness ?? null, {
@@ -3534,10 +3759,7 @@ function renderStandardViews(view, sections) {
3534
3759
  diagrams: true,
3535
3760
  detailsInHeader: true
3536
3761
  })}</section>
3537
- <section class="prb-view" data-view="guide" hidden>${renderGuideTab(session, null, null, {
3538
- diagrams: true,
3539
- collapsedSections: true
3540
- })}</section>
3762
+ <section class="prb-view" data-view="guide" hidden>${renderWorkspaceGuide(session)}</section>
3541
3763
  <section class="prb-view" data-view="diagram" hidden data-diagram-pane${view.pendingDiagram ? ` data-diagram-running="${escapeHtml$1(view.pendingDiagram.sectionId ?? "")}"` : ""}>
3542
3764
  <h3 class="prb-doc-title">Diagrams</h3>
3543
3765
  ${renderDiagrams(view.diagrams ?? [], sections, view.reviewedSha ?? null)}
@@ -3782,24 +4004,11 @@ function renderIdMenu(id, canDelete) {
3782
4004
  */
3783
4005
  function renderRailFoot(view) {
3784
4006
  return `<div class="prb-rail-group prb-rail-foot">
3785
- ${view.token ? `<a class="prb-rail-item prb-rail-link" href="/">
3786
- ${railIcon("reviews")}
3787
- <span class="prb-rail-label"><span class="prb-rail-title">All reviews</span></span>
3788
- </a>
3789
- <a class="prb-rail-item prb-rail-link" href="/settings">
3790
- ${railIcon("settings")}
3791
- <span class="prb-rail-label"><span class="prb-rail-title">Settings</span></span>
3792
- </a>` : ""}
3793
- <a class="prb-rail-item prb-rail-link" href="${escapeHtml$1(feedbackUrl())}" target="_blank" rel="noopener noreferrer">
3794
- ${railIcon("feedback")}
3795
- <span class="prb-rail-label"><span class="prb-rail-title">Send feedback</span></span>
3796
- </a>
4007
+ ${view.token ? renderWorkspaceNav({ unread: view.unreadNotifications ?? 0 }) : ""}
4008
+ ${renderSendFeedback()}
3797
4009
  </div>
3798
4010
  <div class="prb-rail-group prb-rail-ops">
3799
- ${view.token ? `<button type="button" class="prb-rail-item prb-rail-link prb-rail-danger" data-shutdown>
3800
- ${railIcon("stop")}
3801
- <span class="prb-rail-label"><span class="prb-rail-title">Stop server</span></span>
3802
- </button>` : ""}
4011
+ ${view.token ? renderStopServer() : ""}
3803
4012
  <p class="prb-build">v${escapeHtml$1(BUILD_VERSION)}</p>
3804
4013
  </div>`;
3805
4014
  }
@@ -3834,14 +4043,14 @@ function renderRail(view, sections) {
3834
4043
  const dispositions = reviewerDispositions(view.session);
3835
4044
  const themes = [...sections].sort((a, b) => a.order - b.order).map((section, index) => {
3836
4045
  const sectionIssues = issuesOutstanding(issues.filter((issue) => issue.sectionId === section.id), dispositions);
3837
- 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>`;
4046
+ const detail = [`~${themeMinutes(section)} min`, themeFindingsNote(sectionIssues)].filter(Boolean).join(" &middot; ");
4047
+ const note = `<span class="prb-rail-meta">${section.recommendedStartingPoint ? "<span class=\"prb-rail-start\">Start here</span>" : ""}<span class="prb-rail-note">${detail}</span></span>`;
3838
4048
  return `<button type="button" class="prb-rail-item" data-goto-section="${escapeHtml$1(section.id)}" data-theme-index="${index}" data-recommended-start="${section.recommendedStartingPoint}">
3839
4049
  <span class="prb-rail-order">${index + 1}</span>
3840
4050
  <span class="prb-rail-label">
3841
4051
  <span class="prb-rail-title">${escapeHtml$1(section.title)}</span>
3842
4052
  ${note}
3843
4053
  </span>
3844
- ${renderBadge(themeBadge(sectionIssues))}
3845
4054
  <span class="prb-attention" data-attention="${escapeHtml$1(section.reviewAttention)}" title="Needs ${escapeHtml$1(section.reviewAttention)} attention"></span>
3846
4055
  </button>`;
3847
4056
  }).join("");
@@ -3854,10 +4063,10 @@ function renderRail(view, sections) {
3854
4063
  current: true,
3855
4064
  badge: null,
3856
4065
  collapses: "the review",
3857
- body: `<div class="prb-rail-row">
3858
- <p class="prb-rail-heading">Review path</p>
3859
- <button type="button" class="prb-rail-twisty" data-rail-toggle="the review path" aria-expanded="true" aria-controls="prb-rail-path" aria-label="Collapse the review path">${TWISTY}</button>
3860
- </div>
4066
+ body: `<button type="button" class="prb-rail-subhead" data-rail-toggle="the review path" aria-expanded="true" aria-controls="prb-rail-path" aria-label="Collapse the review path">
4067
+ <span class="prb-rail-heading">Review path</span>
4068
+ <span class="prb-rail-twisty" aria-hidden="true">${TWISTY}</span>
4069
+ </button>
3861
4070
  <div class="prb-rail-path" id="prb-rail-path">
3862
4071
  ${themes || "<p class=\"prb-rail-heading\">No themes</p>"}
3863
4072
  </div>
@@ -3986,24 +4195,27 @@ function totalBadge(count, noun) {
3986
4195
  * A zero total keeps its `0`: "we looked and found none" is a different fact from "you have dealt
3987
4196
  * with all of them", and a tick would claim the second on a review that earned the first.
3988
4197
  */
4198
+ /** "1 issue", "2 issues". Shared so a row's words and its badge cannot drift apart. */
4199
+ function plural(n, noun) {
4200
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
4201
+ }
3989
4202
  function remainingBadge(counts, noun, verb) {
3990
- const plural = (n) => `${n} ${noun}${n === 1 ? "" : "s"}`;
3991
4203
  if (counts.total === 0) return {
3992
4204
  text: "0",
3993
4205
  label: `No ${noun}s`
3994
4206
  };
3995
4207
  if (counts.open === 0) return {
3996
4208
  text: "✓",
3997
- label: `All ${plural(counts.total)} ${verb}`,
4209
+ label: `All ${plural(counts.total, noun)} ${verb}`,
3998
4210
  done: true
3999
4211
  };
4000
4212
  if (counts.done === 0) return {
4001
4213
  text: String(counts.open),
4002
- label: plural(counts.total)
4214
+ label: plural(counts.total, noun)
4003
4215
  };
4004
4216
  return {
4005
4217
  text: String(counts.open),
4006
- label: `${counts.open} of ${plural(counts.total)} left, ${counts.done} ${verb}`
4218
+ label: `${counts.open} of ${plural(counts.total, noun)} left, ${counts.done} ${verb}`
4007
4219
  };
4008
4220
  }
4009
4221
  /**
@@ -4016,10 +4228,20 @@ function remainingBadge(counts, noun, verb) {
4016
4228
  function findingsBadge(counts) {
4017
4229
  return remainingBadge(counts, "issue", doneVerb(counts));
4018
4230
  }
4019
- /** A theme's badge. Silent when the theme never had a finding, so most themes stay uncluttered. */
4020
- function themeBadge(counts) {
4231
+ /**
4232
+ * What a theme's row says about its findings, in words rather than as a bare numeral.
4233
+ *
4234
+ * This was a badge: the digit "1" at the end of the row, beside an equally unlabelled attention
4235
+ * dot, with the sentence that explained it reaching only a screen reader through `aria-label`. Two
4236
+ * unlabelled marks on one row is worse than one, and the reader looking at it got strictly less
4237
+ * than the reader hearing it. So the count joins the note already on the row, which already reads
4238
+ * in words. Silent when the theme never had a finding, so most rows stay bare.
4239
+ */
4240
+ function themeFindingsNote(counts) {
4021
4241
  if (counts.total === 0) return null;
4022
- return findingsBadge(counts);
4242
+ if (counts.open === 0) return `all ${plural(counts.total, "issue")} ${doneVerb(counts)}`;
4243
+ if (counts.done === 0) return plural(counts.open, "issue");
4244
+ return `${counts.open} of ${plural(counts.total, "issue")} left`;
4023
4245
  }
4024
4246
  /**
4025
4247
  * `aria-label` rather than `title`: the same sentence, without a native tooltip sitting on top of
@@ -4258,17 +4480,6 @@ function renderPromptDialog() {
4258
4480
  * could fire after the server had already stopped and reset the label to an offer that no longer
4259
4481
  * meant anything. A dialog has no timer to race, and it has room to say what is about to happen.
4260
4482
  */
4261
- function renderShutdownDialog() {
4262
- return `<dialog class="prb-dialog" data-shutdown-dialog aria-labelledby="prb-shutdown-title">
4263
- <h2 id="prb-shutdown-title">Stop the workspace server?</h2>
4264
- <p>One server serves every review on this machine, so <b>every open workspace stops</b>, not just this one.</p>
4265
- <p class="prb-dialog-reassure">Nothing is deleted. Your reviews stay on disk for thirty days, and their links keep working. Run <code>${escapeHtml$1(wayBackSentence())}</code> to start the server again.</p>
4266
- <div class="prb-dialog-actions">
4267
- <button type="button" data-shutdown-cancel>Cancel</button>
4268
- <button type="button" class="prb-dialog-danger" data-shutdown-confirm>Stop server</button>
4269
- </div>
4270
- </dialog>`;
4271
- }
4272
4483
  function renderAsk(view, sections) {
4273
4484
  const suggestions = buildSuggestions(view, sections);
4274
4485
  const transcript = view.transcript ?? [];
@@ -4438,16 +4649,23 @@ function renderReviewsPage(view) {
4438
4649
  <link rel="stylesheet" href="/assets/sidebar.css">
4439
4650
  <link rel="stylesheet" href="/assets/shell.css">
4440
4651
  </head>
4441
- <body class="prb-index-body"${view.revision === void 0 ? "" : ` data-index-revision="${escapeHtml$1(view.revision)}"`} data-live="${anythingRunning(view.analyses ?? /* @__PURE__ */ new Map())}">
4652
+ <body class="prb-index-body" data-way-back="${escapeHtml$1(wayBackSentence())}" data-notifications-since="${now}"${view.revision === void 0 ? "" : ` data-index-revision="${escapeHtml$1(view.revision)}"`} data-live="${anythingRunning(view.analyses ?? /* @__PURE__ */ new Map())}">
4442
4653
  <header class="prb-header">
4443
4654
  <span class="prb-wordmark"><b>pr</b>review<b>buddy</b></span>
4444
- <span class="prb-change-ref">Reviews</span>
4655
+ <span class="prb-page-title">Reviews</span>
4445
4656
  <div class="prb-header-stats">
4657
+ ${renderHeaderNav({
4658
+ current: "reviews",
4659
+ unread: view.unreadNotifications ?? 0
4660
+ })}
4446
4661
  <span class="prb-build">v${escapeHtml$1(BUILD_VERSION)}</span>
4447
- <a class="prb-feedback" href="/settings">Settings</a>
4448
- <a class="prb-feedback" href="${escapeHtml$1(feedbackUrl())}" target="_blank" rel="noopener noreferrer">Send feedback</a>
4449
4662
  </div>
4450
4663
  </header>
4664
+ <div class="prb-shell">
4665
+ ${renderWorkspaceRail({
4666
+ current: "reviews",
4667
+ unread: view.unreadNotifications ?? 0
4668
+ })}
4451
4669
  <main class="prb-index">
4452
4670
  ${ordered.length === 0 ? "" : renderControls(queue.length, authorOptions(ordered.map((group) => group.latest.authorHandle)))}
4453
4671
  ${front === void 0 ? "" : renderContinue(front)}
@@ -4463,6 +4681,8 @@ function renderReviewsPage(view) {
4463
4681
  ${ordered.length === 0 ? "" : renderBulkBar()}
4464
4682
  <p class="prb-index-note">Reviews are kept for thirty days, on this machine only. Saved reviews and anything in your review queue are kept for ninety days instead, counting from the last time you opened them. Every review has a fixed address, so a link to one is worth keeping and still opens after a restart. Stopping the server deletes nothing; it only stops anything being served until the next review starts it again.</p>
4465
4683
  </main>
4684
+ </div>
4685
+ ${renderShutdownDialog()}
4466
4686
  <script src="/assets/reviews.js" type="module"><\/script>
4467
4687
  </body>
4468
4688
  </html>`;
@@ -5013,15 +5233,23 @@ function renderSettingsPage(view) {
5013
5233
  <link rel="stylesheet" href="/assets/sidebar.css">
5014
5234
  <link rel="stylesheet" href="/assets/shell.css">
5015
5235
  </head>
5016
- <body class="prb-index-body">
5236
+ <body class="prb-index-body" data-way-back="${escapeHtml$1(wayBackSentence())}" data-notifications-since="${Date.now()}">
5017
5237
  <header class="prb-header">
5018
5238
  <span class="prb-wordmark"><b>pr</b>review<b>buddy</b></span>
5019
- <span class="prb-change-ref">Settings</span>
5239
+ <span class="prb-page-title">Settings</span>
5020
5240
  <div class="prb-header-stats">
5241
+ ${renderHeaderNav({
5242
+ current: "settings",
5243
+ unread: view.unreadNotifications ?? 0
5244
+ })}
5021
5245
  <span class="prb-build">v${escapeHtml$1(BUILD_VERSION)}</span>
5022
- <a class="prb-feedback" href="${escapeHtml$1(feedbackUrl())}" target="_blank" rel="noopener noreferrer">Send feedback</a>
5023
5246
  </div>
5024
5247
  </header>
5248
+ <div class="prb-shell">
5249
+ ${renderWorkspaceRail({
5250
+ current: "settings",
5251
+ unread: view.unreadNotifications ?? 0
5252
+ })}
5025
5253
  <main class="prb-index prb-settings">
5026
5254
  ${renderBack()}
5027
5255
  ${renderWorkspaceSection(settings, address)}
@@ -5037,6 +5265,8 @@ function renderSettingsPage(view) {
5037
5265
  edit by hand, and they are the same settings <code>prreviewbuddy config</code> reads and writes.
5038
5266
  Run <code>prreviewbuddy config path</code> to be told where that file is.</p>
5039
5267
  </main>
5268
+ </div>
5269
+ ${renderShutdownDialog()}
5040
5270
  <script src="/assets/settings.js" type="module"><\/script>
5041
5271
  </body>
5042
5272
  </html>`;
@@ -5121,6 +5351,77 @@ function renderPrivacySection(settings) {
5121
5351
  </section>`;
5122
5352
  }
5123
5353
  //#endregion
5354
+ //#region ../../packages/review-harness/src/workspace/notifications_page.ts
5355
+ function renderNotificationsPage(view) {
5356
+ const unread = view.notifications.filter((notification) => notification.readAt === void 0).length;
5357
+ return `<!doctype html>
5358
+ <html lang="en">
5359
+ <head>
5360
+ <meta charset="utf-8">
5361
+ <meta name="viewport" content="width=device-width, initial-scale=1">
5362
+ <title>Notifications · PR Review Buddy</title>
5363
+ <link rel="icon" type="image/png" href="/assets/icon128.png">
5364
+ <link rel="stylesheet" href="/assets/sidebar.css">
5365
+ <link rel="stylesheet" href="/assets/shell.css">
5366
+ </head>
5367
+ <body class="prb-index-body" data-way-back="${escapeHtml$1(wayBackSentence())}">
5368
+ <header class="prb-header">
5369
+ <span class="prb-wordmark"><b>pr</b>review<b>buddy</b></span>
5370
+ <span class="prb-page-title">Notifications</span>
5371
+ <div class="prb-header-stats">
5372
+ ${renderHeaderNav({
5373
+ current: "notifications",
5374
+ unread: view.unreadNotifications ?? 0
5375
+ })}
5376
+ <span class="prb-build">v${escapeHtml$1(BUILD_VERSION)}</span>
5377
+ </div>
5378
+ </header>
5379
+ <div class="prb-shell">
5380
+ ${renderWorkspaceRail({
5381
+ current: "notifications",
5382
+ unread: view.unreadNotifications ?? 0
5383
+ })}
5384
+ <main class="prb-index prb-notifications-page">
5385
+ ${view.notifications.length === 0 ? `<p class="prb-index-empty">Nothing yet. When a review finishes or fails, it appears here.</p>` : `<div class="prb-index-summary">
5386
+ <p class="prb-index-count" data-notifications-count role="status" aria-live="polite">${escapeHtml$1(unreadLabel(unread))}</p>
5387
+ <button type="button" class="prb-index-select" data-notifications-read-all${unread === 0 ? " disabled" : ""}>Mark all read</button>
5388
+ </div>
5389
+ <ol class="prb-notifications" data-notifications>
5390
+ ${view.notifications.map((notification) => renderEntry(notification, view.gone?.has(notification.id) === true)).join("")}
5391
+ </ol>`}
5392
+ <p class="prb-index-note">Kept for thirty days, on this machine only.</p>
5393
+ </main>
5394
+ </div>
5395
+ ${renderShutdownDialog()}
5396
+ <script src="/assets/notifications.js" type="module"><\/script>
5397
+ </body>
5398
+ </html>`;
5399
+ }
5400
+ /**
5401
+ * One event: what happened, then what to, then when. The whole row opens the review and so marks it
5402
+ * read, which is why a row has no Mark read of its own. An event, not a card: nothing about the
5403
+ * review's findings belongs here.
5404
+ */
5405
+ function renderEntry(notification, gone) {
5406
+ const read = notification.readAt !== void 0;
5407
+ const event = eventName(notification);
5408
+ const after = [
5409
+ notification.branch,
5410
+ notification.repo,
5411
+ relativeTime$1(notification.createdAt)
5412
+ ].filter((part) => Boolean(part));
5413
+ const note = gone ? " · this review has been deleted" : "";
5414
+ const spoken = `${read ? "" : "Unread. "}${event}, ${[notification.subject, ...after].join(", ")}${gone ? ", this review has been deleted" : ""}`;
5415
+ const body = `<span class="prb-notification-dot" aria-hidden="true"></span>
5416
+ <span class="prb-notification-text">
5417
+ <span class="prb-notification-event" data-outcome="${notification.outcome}">${escapeHtml$1(event)}</span>
5418
+ <span class="prb-notification-meta"><span class="prb-notification-subject">${escapeHtml$1(notification.subject)}</span>${after.map((part) => ` · ${escapeHtml$1(part)}`).join("")}${note}</span>
5419
+ </span>`;
5420
+ return `<li class="prb-notification" data-notification-id="${escapeHtml$1(notification.id)}" data-read="${read}">
5421
+ ${gone ? `<span class="prb-notification-open" aria-label="${escapeHtml$1(spoken)}">${body}</span>` : `<a class="prb-notification-open" href="/notifications/open?id=${encodeURIComponent(notification.id)}" aria-label="${escapeHtml$1(spoken)}">${body}</a>`}
5422
+ </li>`;
5423
+ }
5424
+ //#endregion
5124
5425
  //#region ../../packages/review-harness/src/workspace/reviews_revision.ts
5125
5426
  /**
5126
5427
  * Whether anything the reviews index draws has changed, as one token.
@@ -6072,11 +6373,15 @@ async function handle(req, res, options) {
6072
6373
  if (url.pathname === "/") return handleIndex(req, res, url.searchParams, options.indexToken);
6073
6374
  if (url.pathname === "/open") return handleOpen(req, res, url.searchParams, options.indexToken);
6074
6375
  if (url.pathname === "/settings") return void handleSettings(req, res, url.searchParams, options.indexToken);
6376
+ if (url.pathname === "/notifications") return void handleNotifications(req, res, url.searchParams, options.indexToken);
6377
+ if (url.pathname === "/notifications/open") return handleNotificationOpen(req, res, url.searchParams, options.indexToken);
6378
+ if (url.pathname === "/notifications/recent") return handleRecentNotifications(req, res, url.searchParams, options.indexToken);
6075
6379
  if (url.pathname === "/pin") return handlePin(req, res, options.indexToken);
6076
6380
  if (url.pathname === "/queue") return handleQueue(req, res, options.indexToken);
6077
6381
  if (url.pathname === "/delete") return handleDelete(req, res, options.indexToken);
6078
6382
  if (url.pathname === "/status") return handleStatus(req, res, options.indexToken);
6079
6383
  if (url.pathname === "/revision") return handleRevision(req, res, options.indexToken);
6384
+ if (url.pathname === "/shutdown") return handleIndexShutdown(req, res, options);
6080
6385
  const match = /^\/(?:r|api)\/([0-9a-f-]{36})(\/[a-z]+)?$/.exec(url.pathname);
6081
6386
  if (!match) return send(res, 404, { error: "Not found" });
6082
6387
  const [, id, action] = match;
@@ -6171,7 +6476,8 @@ function handleIndex(req, res, params, indexToken) {
6171
6476
  groups,
6172
6477
  queue: queue.filter((id) => rendered.has(id)),
6173
6478
  analyses: states,
6174
- revision: reviewsRevision(states)
6479
+ revision: reviewsRevision(states),
6480
+ unreadNotifications: unreadCount$1()
6175
6481
  }), cookie);
6176
6482
  }
6177
6483
  /**
@@ -6256,7 +6562,10 @@ async function handleSettings(req, res, params, indexToken) {
6256
6562
  error
6257
6563
  });
6258
6564
  }
6259
- sendHtml(res, 200, renderSettingsPage({ settings: readSettings() }), indexCookieHeader(indexToken));
6565
+ sendHtml(res, 200, renderSettingsPage({
6566
+ settings: readSettings(),
6567
+ unreadNotifications: unreadCount$1()
6568
+ }), indexCookieHeader(indexToken));
6260
6569
  }
6261
6570
  /**
6262
6571
  * Whether the caller is a browser navigating, rather than something reading the API.
@@ -6290,6 +6599,73 @@ function handleOpen(req, res, params, indexToken) {
6290
6599
  return redirect(res, `/r/${workspace.id}?t=${encodeURIComponent(workspace.token)}`);
6291
6600
  }
6292
6601
  /**
6602
+ * What an open workspace page should show as toasts: unread notifications recorded since it last
6603
+ * asked, and how many more there were than it has room to show.
6604
+ *
6605
+ * The cookie only, like `/revision`, because the caller is a page's script rather than a navigation.
6606
+ * `now` is read before the list, and nothing newer than it is returned, so an event recorded between
6607
+ * the two reads is handed over on the next poll rather than on both. What goes back is the line and
6608
+ * the id; the id opens nothing except through `/notifications/open`, which asks for the credential
6609
+ * again.
6610
+ */
6611
+ function handleRecentNotifications(req, res, params, indexToken) {
6612
+ if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
6613
+ const now = Date.now();
6614
+ const after = Number(params.get("after"));
6615
+ const { shown, more } = splitForToasts(Number.isFinite(after) ? listNotifications().filter((entry) => entry.readAt === void 0 && entry.createdAt > after && entry.createdAt <= now) : []);
6616
+ send(res, 200, {
6617
+ now,
6618
+ notifications: shown.map((entry) => ({
6619
+ id: entry.id,
6620
+ headline: headline(entry),
6621
+ outcome: entry.outcome
6622
+ })),
6623
+ more
6624
+ });
6625
+ }
6626
+ /**
6627
+ * The Notifications page, and marking its entries read.
6628
+ *
6629
+ * The index's authority, like Settings: the list names every review on the machine, which is the
6630
+ * machine's user's business and no single review's. `GET` renders and `POST` marks, answering with
6631
+ * the unread count as it now stands so the page paints the files rather than its own arithmetic.
6632
+ */
6633
+ async function handleNotifications(req, res, params, indexToken) {
6634
+ if (!indexAuthorised(req, params, indexToken)) {
6635
+ if (req.method === "POST") return send(res, 403, { error: "This link is missing its access token." });
6636
+ return refuseIndex(req, res);
6637
+ }
6638
+ if (req.method === "POST") {
6639
+ const body = await readJson(req);
6640
+ if (body?.action === "read-all") markAllRead();
6641
+ else if (body?.action === "read" && Array.isArray(body.ids)) markRead(body.ids.filter((id) => typeof id === "string"));
6642
+ else return send(res, 400, { error: "Say what to mark: `read` with the ids, or `read-all`." });
6643
+ return send(res, 200, { unread: unreadCount$1() });
6644
+ }
6645
+ const notifications = listNotifications();
6646
+ sendHtml(res, 200, renderNotificationsPage({
6647
+ notifications,
6648
+ gone: new Set(notifications.filter((entry) => !loadWorkspace(entry.reviewId)).map((entry) => entry.id)),
6649
+ unreadNotifications: unreadCount$1()
6650
+ }), indexCookieHeader(indexToken));
6651
+ }
6652
+ /**
6653
+ * Open one notification: mark it read, then go to its review with the review's own token.
6654
+ *
6655
+ * The one way a notification is read by being followed. Arriving at the same review from anywhere
6656
+ * else leaves it unread, because that is not the reviewer having seen this. The redirect is the one
6657
+ * `handleOpen` makes, so no second kind of link to a review exists.
6658
+ */
6659
+ function handleNotificationOpen(req, res, params, indexToken) {
6660
+ if (!indexAuthorised(req, params, indexToken)) return refuseIndex(req, res);
6661
+ const notification = readNotification(params.get("id") ?? "");
6662
+ if (!notification) return redirect(res, "/notifications");
6663
+ markRead([notification.id]);
6664
+ const workspace = loadWorkspace(notification.reviewId);
6665
+ if (!workspace) return redirect(res, "/notifications");
6666
+ return redirect(res, `/r/${workspace.id}?t=${encodeURIComponent(workspace.token)}`);
6667
+ }
6668
+ /**
6293
6669
  * Mark a selection reviewed, or put it back to in progress.
6294
6670
  *
6295
6671
  * Authorised by the index credential, like pinning and deleting, because this is the list acting on
@@ -6355,7 +6731,8 @@ function handleRevision(req, res, indexToken) {
6355
6731
  const states = analysisStates();
6356
6732
  send(res, 200, {
6357
6733
  revision: reviewsRevision(states),
6358
- live: anythingRunning(states)
6734
+ live: anythingRunning(states),
6735
+ unread: unreadCount$1()
6359
6736
  });
6360
6737
  }
6361
6738
  /**
@@ -6631,6 +7008,7 @@ async function sendWorkspacePage(res, workspace) {
6631
7008
  pendingDiagram: reportRun(workspace.id, "diagram"),
6632
7009
  prompts: readPromptBank(),
6633
7010
  complete: workspace.completedAt !== void 0,
7011
+ unreadNotifications: unreadCount$1(),
6634
7012
  prContext: workspace.prContext ? {
6635
7013
  prContext: workspace.prContext,
6636
7014
  processed: processDiscussion(workspace.prContext.discussion)
@@ -7097,6 +7475,16 @@ async function handleDiagram(req, res, workspace) {
7097
7475
  *
7098
7476
  * One server serves every review, so this ends all of them. The page says so before asking.
7099
7477
  */
7478
+ /**
7479
+ * Stop server from the workspace pages that are not a review: the reviews list, Notifications and
7480
+ * Settings. They hold no review token, so the index cookie authorises it, exactly as the list's own
7481
+ * actions are. POST only, so a link or a prefetch can never end every open review.
7482
+ */
7483
+ function handleIndexShutdown(req, res, options) {
7484
+ if (!options.indexToken || !secretMatches(options.indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
7485
+ if (req.method !== "POST") return send(res, 405, { error: "Stopping the server is a POST." });
7486
+ handleShutdown(res, options);
7487
+ }
7100
7488
  function handleShutdown(res, options) {
7101
7489
  send(res, 200, { ok: true });
7102
7490
  const stop = options.onShutdown ?? (() => process.exit(0));
@@ -7304,6 +7692,7 @@ async function sweep(now = Date.now()) {
7304
7692
  if (await retire(job, report)) report.retired += 1;
7305
7693
  }
7306
7694
  await removeOrphans(jobs, report);
7695
+ pruneNotifications(now);
7307
7696
  return report;
7308
7697
  }
7309
7698
  /**
@@ -7318,7 +7707,9 @@ function markAbandoned(job, now) {
7318
7707
  if (isClaimed(job.id, now)) return false;
7319
7708
  const current = loadJob(job.id);
7320
7709
  if (!current || current.updatedAt !== job.updatedAt) return false;
7321
- saveJob(fail(current, ABANDONED_MESSAGE));
7710
+ const abandoned = fail(current, ABANDONED_MESSAGE);
7711
+ saveJob(abandoned);
7712
+ announceJob(abandoned);
7322
7713
  return true;
7323
7714
  }
7324
7715
  /** A finished job, discarded on the shared sequence. See `job_cleanup.ts` for why it is an order. */