prreviewbuddy 0.46.0 → 0.48.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 { A as createPrState, An as writeAgentPreference, At as allJobs, B as toneOf, Bt as purposeOf, C as readEvents, Ct as forgeFor, D as announceJob, Dn as resolveModel, Dt as checkReview, E as telemetryUploadConsent, Et as checkCodeFreshness, F as markAllRead, Ft as saveJob, G as extractJsonText, H as withUsageRecorded, I as markRead, In as agentFor, J as readMarker, K as ReviewBeingDeletedError, Kt as indexReviewGroups, L as pruneNotifications, Lt as isLiveJob, M as eventName, Mn as STORE_ROOT, Mt as fail, N as headline, Nn as AGENT_IDS, Nt as isTerminal, O as branchOf, On as clearAgentPreference, Ot as followedBranch, P as listNotifications, Pt as loadJob, Q as compactQueue, Qt as mutateWorkspace, R as readNotification, Rn as AgentCancelledError, Rt as phasesFor, T as recordTelemetryUploadConsent, Tn as git, Tt as fillFileUrlTemplate, U as EXPLAIN_SIMPLY_PROMPT, Ut as isClaimed, V as unreadCount$1, W as asString, Wt as followedRefName, Xt as loadWorkspace, Y as removeWorktree, Yt as lineagePosition, Z as relativeTime$1, _ as agentEnvOf, _n as issuesOutstanding, _t as parseWorkspacePort, a as wasBlocked, an as touchWorkspace, bn as processDiscussion, bt as writePortPreference, c as RefreshUnavailableError, cn as dequeueOnComplete, d as refreshPrContext, dn as queueRemove, dt as BUILD_VERSION, en as previousKissResult, et as readIndexToken, f as isUnchanged, fn as queueStamp, gn as isQuestionOutstanding, gt as MIN_WORKSPACE_PORT, h as liveUpdateFor, hn as isIssueOutstanding, ht as MAX_WORKSPACE_PORT, i as liveJobsFor, it as readServerRecord, j as createPrUpdate, jt as endedAt, k as subjectOf, kn as readAgentPreference, kt as reviewedShaOf, l as mergeUpdateInto, ln as queueAdd, m as UpdateAlreadyRunningError, mn as doneVerb, mt as DEFAULT_WORKSPACE_PORT, n as deleteReview, nn as reviewedCommit, nt as clearServerRecord, o as discardJob, on as workspaceRevision, ot as serverEntryExists, p as askCheckout, pn as readQueue, pt as feedbackUrl, q as MANAGED_ROOT, qt as latestKissRun, r as lineageIds, s as FETCH_TIMEOUT_MS, sn as workspaceStamps, st as spawnServer, t as deleteLineage, tn as recentWorkspaces, u as updateReview, un as queueAddMany, ut as writeServerRecord, vn as questionsOutstanding, vt as resolveWorkspacePort, w as record, wn as isCommitReview, wt as remoteHost, x as runJob, xn as describeAuthorship, xt as claimRequestUrl, y as startKissJob, yn as reviewerDispositions, yt as statedWorkspacePort, z as removeNotification, zn as AgentUnavailableError, zt as progressSteps } from "./delete_review-lmVdoybL.js";
1
+ import { $t as mutateWorkspace, A as createPrState, An as describeAuthorship, At as allJobs, B as toneOf, Bt as purposeOf, C as readEvents, Cn as doneVerb, Ct as forgeFor, D as announceJob, Dn as questionsOutstanding, Dt as checkReview, E as telemetryUploadConsent, En as issuesOutstanding, Et as checkCodeFreshness, F as markAllRead, Ft as saveJob, G as extractJsonText, Gn as agentFor, Gt as groupByLineage, H as withUsageRecorded, Hn as AGENT_IDS, I as markRead, In as resolveModel, J as readMarker, Jn as AgentUnavailableError, Jt as latestKissRun, K as ReviewBeingDeletedError, Kt as indexKeptIds, L as pruneNotifications, Ln as clearAgentPreference, Lt as isLiveJob, M as eventName, Mt as fail, N as headline, Nn as isCommitReview, Nt as isTerminal, O as branchOf, On as reviewerDispositions, Ot as followedBranch, P as listNotifications, Pn as git, Pt as loadJob, Q as compactQueue, R as readNotification, Rn as readAgentPreference, Rt as phasesFor, Sn as readQueue, T as recordTelemetryUploadConsent, Tn as isQuestionOutstanding, Tt as fillFileUrlTemplate, U as EXPLAIN_SIMPLY_PROMPT, Ut as isClaimed, V as unreadCount$1, Vn as STORE_ROOT, W as asString, Wt as followedRefName, Xt as lineagePosition, Y as removeWorktree, Z as relativeTime$1, Zt as loadWorkspace, _ as agentEnvOf, _n as dequeueOnComplete, _t as parseWorkspacePort, a as wasBlocked, bn as queueRemove, bt as writePortPreference, c as RefreshUnavailableError, cn as workspaceStamps, d as refreshPrContext, dn as isLifecycle, dt as BUILD_VERSION, et as readIndexToken, f as isUnchanged, fn as lifecycleAction, gn as sectionOf, gt as MIN_WORKSPACE_PORT, h as liveUpdateFor, hn as parseLifecycle, ht as MAX_WORKSPACE_PORT, i as liveJobsFor, it as readServerRecord, j as createPrUpdate, jt as endedAt, k as subjectOf, kn as processDiscussion, kt as reviewedShaOf, l as mergeUpdateInto, ln as LIFECYCLES, m as UpdateAlreadyRunningError, mn as offersLifecycle, mt as DEFAULT_WORKSPACE_PORT, n as deleteReview, nn as recentWorkspaces, nt as clearServerRecord, o as discardJob, on as touchWorkspace, ot as serverEntryExists, p as askCheckout, pn as lifecycleChip, pt as feedbackUrl, q as MANAGED_ROOT, qn as AgentCancelledError, qt as indexReviewGroups, r as lineageIds, rn as reviewedCommit, s as FETCH_TIMEOUT_MS, sn as workspaceRevision, st as spawnServer, t as deleteLineage, tn as previousKissResult, u as updateReview, un as activityLabel, ut as writeServerRecord, vn as queueAdd, vt as resolveWorkspacePort, w as record, wn as isIssueOutstanding, wt as remoteHost, x as runJob, xn as queueStamp, xt as claimRequestUrl, y as startKissJob, yn as queueAddMany, yt as statedWorkspacePort, z as removeNotification, zn as writeAgentPreference, zt as progressSteps } from "./delete_review-D52wkt66.js";
2
2
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { createHash, timingSafeEqual } from "node:crypto";
@@ -4550,6 +4550,46 @@ function renderPrState(state, besideNumber = false) {
4550
4550
  return `<span class="prb-card-pr-state" data-pr-state="${state}">${state === "merged" ? "Merged" : besideNumber ? "Closed" : "PR closed"}</span>`;
4551
4551
  }
4552
4552
  //#endregion
4553
+ //#region ../../packages/review-harness/src/workspace/theme.ts
4554
+ /**
4555
+ * Which palette the workspace's furniture is drawn in: the reviewer's device, or a fixed choice.
4556
+ *
4557
+ * One axis with a registry rather than a flag per value, so the stored type, the settings page's
4558
+ * radios and the attribute on `<html>` all derive from the same list and cannot disagree about
4559
+ * what the choices are.
4560
+ *
4561
+ * Pure on purpose: the settings page's script imports this, and the client bundle must not reach
4562
+ * anything that pulls in a node builtin.
4563
+ *
4564
+ * The attribute is the whole mechanism. shell.css sets `color-scheme` from it, and every shell
4565
+ * token is a `light-dark()` pair that the browser resolves from `color-scheme`, so System follows
4566
+ * the device live, with no listener and no reload.
4567
+ */
4568
+ var THEMES = [
4569
+ {
4570
+ id: "system",
4571
+ label: "System",
4572
+ help: "Follow your device setting"
4573
+ },
4574
+ {
4575
+ id: "light",
4576
+ label: "Light",
4577
+ help: "Always use light mode"
4578
+ },
4579
+ {
4580
+ id: "dark",
4581
+ label: "Dark",
4582
+ help: "Always use dark mode"
4583
+ }
4584
+ ];
4585
+ function isTheme(value) {
4586
+ return THEMES.some((theme) => theme.id === value);
4587
+ }
4588
+ /** The `<html>` attribute, leading space included, so a root reads `<html lang="en"${…}>`. */
4589
+ function themeAttribute(theme) {
4590
+ return ` data-theme="${theme ?? "system"}"`;
4591
+ }
4592
+ //#endregion
4553
4593
  //#region ../../packages/review-harness/src/workspace/page.ts
4554
4594
  function renderWorkspace(view) {
4555
4595
  const { session, changeSet } = view;
@@ -4569,7 +4609,7 @@ function renderWorkspace(view) {
4569
4609
  */
4570
4610
  const freshness = result ? view.freshness : void 0;
4571
4611
  return `<!doctype html>
4572
- <html lang="en">
4612
+ <html lang="en"${themeAttribute(view.theme)}>
4573
4613
  <head>
4574
4614
  <meta charset="utf-8">
4575
4615
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -5718,7 +5758,8 @@ function normalise$1(entries) {
5718
5758
  if (typeof raw.observedAt !== "number") continue;
5719
5759
  states[key] = {
5720
5760
  state: raw.state,
5721
- observedAt: raw.observedAt
5761
+ observedAt: raw.observedAt,
5762
+ ...typeof raw.changedAt === "number" ? { changedAt: raw.changedAt } : {}
5722
5763
  };
5723
5764
  }
5724
5765
  return states;
@@ -5834,6 +5875,7 @@ function normalise(entries) {
5834
5875
  if (typeof raw.polledAt === "number") entry.polledAt = raw.polledAt;
5835
5876
  if (typeof raw.observedSha === "string") entry.observedSha = raw.observedSha;
5836
5877
  if (typeof raw.observedAt === "number") entry.observedAt = raw.observedAt;
5878
+ if (typeof raw.changedAt === "number") entry.changedAt = raw.changedAt;
5837
5879
  if (typeof raw.notifiedSha === "string") entry.notifiedSha = raw.notifiedSha;
5838
5880
  if (typeof raw.notificationId === "string") entry.notificationId = raw.notificationId;
5839
5881
  if (typeof raw.failures === "number") entry.failures = raw.failures;
@@ -6172,10 +6214,12 @@ async function watchCycle(deps = defaultDeps(), options = {}) {
6172
6214
  }
6173
6215
  if (observed.kind === "unresolved") continue;
6174
6216
  if (observed.prState !== void 0 && !isBranchCandidate(candidate)) recordPrState(candidate, key, observed.sha, observed.prState, prStates, now);
6217
+ const lastSeen = state[key]?.observedSha;
6175
6218
  state[key] = {
6176
6219
  ...state[key],
6177
6220
  observedSha: observed.sha,
6178
6221
  observedAt: seenAt,
6222
+ ...lastSeen === void 0 || observed.sha === lastSeen ? {} : { changedAt: seenAt },
6179
6223
  failures: 0
6180
6224
  };
6181
6225
  if (observed.sha === candidate.analysedSha) {
@@ -6220,7 +6264,8 @@ function prunedPrStates(prStates, deps) {
6220
6264
  */
6221
6265
  function recordPrState(candidate, key, headSha, observed, prStates, now) {
6222
6266
  if (observed === null) return;
6223
- if (prStates[key]?.state === "open" && observed !== "open") {
6267
+ const previous = prStates[key]?.state;
6268
+ if (previous === "open" && observed !== "open") {
6224
6269
  const id = `pr-${observed}-${candidate.reviewId}-${headSha}`;
6225
6270
  if (createPrState({
6226
6271
  id,
@@ -6232,9 +6277,11 @@ function recordPrState(candidate, key, headSha, observed, prStates, now) {
6232
6277
  headSha
6233
6278
  }, now) === null && readNotification(id) === null) return;
6234
6279
  }
6280
+ const changedAt = previous !== void 0 && previous !== observed ? now : prStates[key]?.changedAt;
6235
6281
  prStates[key] = {
6236
6282
  state: observed,
6237
- observedAt: now
6283
+ observedAt: now,
6284
+ ...changedAt === void 0 ? {} : { changedAt }
6238
6285
  };
6239
6286
  }
6240
6287
  async function observeRequest(candidate, deps, unreachable) {
@@ -6400,6 +6447,12 @@ async function announce(candidate, key, observed, state, deps, now) {
6400
6447
  * and a review with no `completedAt` is one they have not finished. Anything outside those is a
6401
6448
  * review they are done with, and polling it would spend a request on an answer nobody wants.
6402
6449
  *
6450
+ * **Waiting and Done are watched whatever else is true of them.** Parking a review takes it out of
6451
+ * the queue and is usually done after marking it reviewed, so without this a Waiting review would
6452
+ * fall outside all three sets at the moment it starts waiting for a push, and the push that should
6453
+ * bring it back to Active would never be seen. Done is watched for "Changed since done": the reviewer
6454
+ * asked not to be interrupted, not to be kept uninformed.
6455
+ *
6403
6456
  * The one exclusion is structural rather than editorial: a commit review is of one fixed commit
6404
6457
  * that cannot move, and `freshness.ts` calls that state `fixed` for the same reason. A review with
6405
6458
  * no pull request used to be excluded too, having nothing to ask a forge about. It is watched
@@ -6407,7 +6460,7 @@ async function announce(candidate, key, observed, state, deps, now) {
6407
6460
  */
6408
6461
  function isWatchable(workspace, queued) {
6409
6462
  if (isCommitReview(workspace.changeSet)) return false;
6410
- return queued.has(workspace.id) || workspace.pinned === true || workspace.completedAt === void 0;
6463
+ return queued.has(workspace.id) || workspace.pinned === true || workspace.completedAt === void 0 || parseLifecycle(workspace.lifecycle) !== void 0;
6411
6464
  }
6412
6465
  /** The request a review is attached to, by URL where there is one and by number otherwise. */
6413
6466
  function requestOf(workspace) {
@@ -6498,6 +6551,50 @@ function prStatesByRequest(states = readPrStates()) {
6498
6551
  return new Map(Object.entries(states).map(([key, entry]) => [key, entry.state]));
6499
6552
  }
6500
6553
  /**
6554
+ * What last happened to each branch's change, by the id of its newest review: the list's order and
6555
+ * the time on each card.
6556
+ *
6557
+ * The latest of three things, each of which is the change moving rather than anybody looking at it:
6558
+ * the newest analysis being made, its head moving, and its pull request changing state. Read from
6559
+ * the two `changedAt`s, never the `observedAt`s, which every poll rewrites.
6560
+ *
6561
+ * A watch entry follows the newest analysis of its request, so it is looked for across the whole
6562
+ * history, the same way `moved` is: an entry still filed against an older analysis is the same
6563
+ * change. A head move or state change from before the newest analysis is already in it, so only a
6564
+ * later one counts.
6565
+ */
6566
+ function reviewActivity(groups, watch = readWatch(), prStates = readPrStates()) {
6567
+ const headChanges = /* @__PURE__ */ new Map();
6568
+ for (const entry of Object.values(watch)) {
6569
+ if (entry.reviewId === void 0 || entry.changedAt === void 0) continue;
6570
+ headChanges.set(entry.reviewId, Math.max(entry.changedAt, headChanges.get(entry.reviewId) ?? 0));
6571
+ }
6572
+ const activity = /* @__PURE__ */ new Map();
6573
+ for (const group of groups) {
6574
+ const { latest } = group;
6575
+ let current = {
6576
+ at: latest.createdAt,
6577
+ kind: "created"
6578
+ };
6579
+ for (const review of group.history) {
6580
+ const at = headChanges.get(review.id);
6581
+ if (at !== void 0 && at > current.at) current = {
6582
+ at,
6583
+ kind: "updated"
6584
+ };
6585
+ }
6586
+ if (latest.prNumber !== void 0) {
6587
+ const entry = prStates[requestKey(latest.repoPath, latest.prNumber)];
6588
+ if (entry?.changedAt !== void 0 && entry.changedAt > current.at) current = {
6589
+ at: entry.changedAt,
6590
+ kind: entry.state === "open" ? "reopened" : entry.state
6591
+ };
6592
+ }
6593
+ activity.set(latest.id, current);
6594
+ }
6595
+ return activity;
6596
+ }
6597
+ /**
6501
6598
  * One review's pull request state, for its page, filed under the same key the list looks up. Null
6502
6599
  * for a review with no request or one never observed: unknown, not open. Only ever what the watcher
6503
6600
  * last saw; nothing here reaches the forge.
@@ -6561,6 +6658,23 @@ function watchNow(scope, makeDeps = defaultDeps) {
6561
6658
  });
6562
6659
  }
6563
6660
  /**
6661
+ * A first look at every one of these reviews, however many there are.
6662
+ *
6663
+ * For parking a selection as Waiting. `watchNow` keeps the request budget of an ordinary cycle, so
6664
+ * handed thirty reviews it would look at twenty and leave ten to the timer, and a push to one of
6665
+ * those before its turn would be taken as its first observation and never bring it back. So the
6666
+ * selection goes through in batches of that budget, one scoped cycle after another: every review is
6667
+ * looked at, and no single cycle asks more of the forge than an ordinary one does. The whole
6668
+ * selection is the reviewer's own act, which is what bounds how many batches there can be.
6669
+ */
6670
+ async function watchAllNow(reviewIds, makeDeps = defaultDeps) {
6671
+ const ids = [...reviewIds];
6672
+ for (let start = 0; start < ids.length; start += 20) {
6673
+ const batch = new Set(ids.slice(start, start + 20));
6674
+ await watchNow((candidate) => batch.has(candidate.reviewId), makeDeps);
6675
+ }
6676
+ }
6677
+ /**
6564
6678
  * Run the pass on a timer, and return the stop.
6565
6679
  *
6566
6680
  * The same shape as `startSweeping`, and tied into `cli/server.ts` the same way, so a restart
@@ -6600,14 +6714,26 @@ var FETCH_SELECTED_LABEL = "Fetch selected";
6600
6714
  //#region ../../packages/review-harness/src/workspace/reviews_page.ts
6601
6715
  function renderReviewsPage(view) {
6602
6716
  const now = view.now ?? Date.now();
6603
- const ordered = [...view.groups].sort((a, b) => Number(b.latest.pinned) - Number(a.latest.pinned) || touchedAt(b.latest) - touchedAt(a.latest));
6717
+ const activityOf = (group) => view.activity?.get(group.latest.id) ?? {
6718
+ at: group.latest.createdAt,
6719
+ kind: "created"
6720
+ };
6721
+ const ordered = [...view.groups].sort((a, b) => Number(b.latest.pinned) - Number(a.latest.pinned) || activityOf(b).at - activityOf(a).at);
6722
+ const sections = {
6723
+ active: [],
6724
+ waiting: [],
6725
+ done: []
6726
+ };
6727
+ for (const group of ordered) sections[sectionOf(group.latest.lifecycle, activityOf(group))].push(group);
6728
+ const rank = new Map(ordered.map((group, index) => [group, index]));
6729
+ const renderCards = (groups) => groups.map((group) => renderCard(group, now, activityOf(group), rank.get(group), view.analyses?.get(group.latest.id), positions.get(group.latest.id), group.history.map((review) => view.moved?.get(review.id)).find((kind) => kind !== void 0), prStateOf(group.latest, view.prStates))).join("");
6604
6730
  const storedReviews = view.groups.reduce((total, group) => total + group.history.length, 0);
6605
6731
  const queue = view.queue ?? [];
6606
6732
  const positions = new Map(queue.map((id, position) => [id, position]));
6607
6733
  const continues = continueTarget(queue);
6608
6734
  const front = continues === null ? void 0 : findReview(view.groups, continues);
6609
6735
  return `<!doctype html>
6610
- <html lang="en">
6736
+ <html lang="en"${themeAttribute(view.theme)}>
6611
6737
  <head>
6612
6738
  <meta charset="utf-8">
6613
6739
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -6634,13 +6760,15 @@ ${renderWorkspaceRail({
6634
6760
  <p class="prb-index-count" data-count>${escapeHtml$1(countLabel(storedReviews, ordered.length))}</p>
6635
6761
  <button type="button" class="prb-index-select" data-select-mode aria-pressed="false">Select</button>
6636
6762
  </div>`}
6637
- <div class="prb-cards" data-cards>
6638
- ${ordered.map((group) => renderCard(group, now, view.analyses?.get(group.latest.id), positions.get(group.latest.id), group.history.map((review) => view.moved?.get(review.id)).find((kind) => kind !== void 0), prStateOf(group.latest, view.prStates))).join("")}
6763
+ <div class="prb-cards" data-cards="active">
6764
+ ${renderCards(sections.active)}
6639
6765
  </div>
6766
+ ${renderLoadMore("active")}
6640
6767
  ${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>`}
6641
- ${ordered.length > 15 ? renderPager() : ""}
6768
+ ${renderSection("waiting", "Waiting", sections.waiting, true, renderCards)}
6769
+ ${renderSection("done", "Done", sections.done, false, renderCards)}
6642
6770
  ${ordered.length === 0 ? "" : renderBulkBar()}
6643
- <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>
6771
+ <p class="prb-index-note">Reviews are kept for thirty days, on this machine only. Saved reviews, Done reviews and anything in your review queue are kept for ninety days instead, counting from the last time you opened or marked 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>
6644
6772
  </main>
6645
6773
  </div>
6646
6774
  <script src="/assets/reviews.js" type="module"><\/script>
@@ -6721,22 +6849,31 @@ function findReview(groups, id) {
6721
6849
  for (const group of groups) for (const review of group.history) if (review.id === id) return review;
6722
6850
  }
6723
6851
  /**
6724
- * The pager, with its numbers left out.
6852
+ * One section's Load more, which the browser shows once that section holds more than it is showing.
6725
6853
  *
6726
- * How many pages there are depends on what the search matched, and matching happens in the browser
6727
- * against the whole list — so the numbers are the one part of this page the server cannot know.
6728
- * Every word of it is still written here, where the house style tests read it; only the digits are
6729
- * filled in later.
6854
+ * One per section rather than one pager for the page, so revealing older Done reviews never costs
6855
+ * the reviewer their place in Active. Hidden until the client has counted, so a browser with no
6856
+ * JavaScript gets every card and no control that does nothing.
6857
+ */
6858
+ function renderLoadMore(section) {
6859
+ return `<button type="button" class="prb-index-more" data-load-more="${section}" hidden>Load more</button>`;
6860
+ }
6861
+ /**
6862
+ * Waiting or Done, as a disclosure under the working list.
6730
6863
  *
6731
- * Hidden until the client has counted, so a browser with no JavaScript gets the whole list and no
6732
- * controls that do nothing, rather than an empty pager under it.
6864
+ * Active has no heading: it is what the page has always been, and the count line above it already
6865
+ * says what it holds. Waiting starts open, because it is work that will come back; Done starts
6866
+ * closed, because it is the archive. Rendered empty and hidden rather than left out, so a card the
6867
+ * browser moves here after a mark has somewhere to go.
6733
6868
  */
6734
- function renderPager() {
6735
- return `<nav class="prb-index-pager" data-pager aria-label="Review pages" hidden>
6736
- <button type="button" class="prb-index-page" data-page-step="-1">&lsaquo; Previous</button>
6737
- <span class="prb-index-pages" data-pages></span>
6738
- <button type="button" class="prb-index-page" data-page-step="1">Next &rsaquo;</button>
6739
- </nav>`;
6869
+ function renderSection(section, title, groups, open, renderCards) {
6870
+ return `<details class="prb-index-section" data-section="${section}"${open ? " open" : ""}${groups.length === 0 ? " hidden" : ""}>
6871
+ <summary class="prb-index-section-head">${title} · <span data-section-count>${groups.length}</span></summary>
6872
+ <div class="prb-cards" data-cards="${section}">
6873
+ ${renderCards(groups)}
6874
+ </div>
6875
+ ${renderLoadMore(section)}
6876
+ </details>`;
6740
6877
  }
6741
6878
  /**
6742
6879
  * The bulk bar, with its counts left out.
@@ -6761,6 +6898,7 @@ function renderBulkBar() {
6761
6898
  <button type="button" class="prb-bulk-button" data-bulk-queue disabled>Add to review queue</button>
6762
6899
  <button type="button" class="prb-bulk-button" data-bulk-mark="complete">Mark reviewed</button>
6763
6900
  <button type="button" class="prb-bulk-button" data-bulk-mark="in-progress">Mark in progress</button>
6901
+ ${LIFECYCLES.map((lifecycle) => `<button type="button" class="prb-bulk-button" data-bulk-lifecycle="${lifecycle.id}">${escapeHtml$1(lifecycle.action)}</button>`).join("\n ")}
6764
6902
  <button type="button" class="prb-bulk-button prb-bulk-button-danger" data-bulk-delete></button>
6765
6903
  <button type="button" class="prb-bulk-button prb-bulk-cancel" data-bulk-cancel>Cancel</button>
6766
6904
  </div>
@@ -6794,20 +6932,22 @@ function renderBulkBar() {
6794
6932
  * card and is shown only in selection mode, so entering that mode is a stylesheet change rather
6795
6933
  * than a rebuild of the list.
6796
6934
  */
6797
- function renderCard(group, now, analysis, queued, moved, prState) {
6935
+ function renderCard(group, now, activity, rank, analysis, queued, moved, prState) {
6798
6936
  const review = group.latest;
6799
6937
  const status = statusOf(review);
6938
+ const chip = lifecycleChip(review.lifecycle, activity);
6800
6939
  const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
6801
6940
  const followed = review.targetType === "commit" ? `commit ${(review.commitSha ?? review.branch).slice(0, 7)}` : followedRefName(review.branch, review.headResolvedRef) ?? review.branch;
6802
- return `<article class="prb-card" data-card data-id="${escapeHtml$1(review.id)}" data-status="${status}" data-pinned="${review.pinned}"${queued === void 0 ? "" : ` data-queue="${queued}"`}${prState === void 0 ? "" : ` data-pr-state="${prState}"`}${review.authorHandle ? ` data-author="${escapeHtml$1(review.authorHandle)}"` : ""} data-reviews="${group.history.length}" data-lineage="${escapeHtml$1(group.history.map((entry) => entry.id).join(" "))}" data-search-text="${escapeHtml$1(groupSearchText(group))}">
6941
+ return `<article class="prb-card" data-card data-id="${escapeHtml$1(review.id)}" data-status="${status}" data-pinned="${review.pinned}"${queued === void 0 ? "" : ` data-queue="${queued}"`}${prState === void 0 ? "" : ` data-pr-state="${prState}"`}${review.authorHandle ? ` data-author="${escapeHtml$1(review.authorHandle)}"` : ""} data-lifecycle="${review.lifecycle?.state ?? "active"}"${review.lifecycle ? ` data-lifecycle-at="${review.lifecycle.at}"` : ""} data-activity-at="${activity.at}" data-activity-kind="${activity.kind}" data-rank="${rank}" data-reviews="${group.history.length}" data-lineage="${escapeHtml$1(group.history.map((entry) => entry.id).join(" "))}" data-search-text="${escapeHtml$1(groupSearchText(group))}">
6803
6942
  <input type="checkbox" class="prb-card-select" data-select="${escapeHtml$1(review.id)}" aria-label="Select ${escapeHtml$1(followed)}">
6804
6943
  <button type="button" class="prb-card-pin" data-pin="${escapeHtml$1(review.id)}" aria-pressed="${review.pinned}" aria-label="${review.pinned ? "Unsave" : "Save"} this review">${review.pinned ? "&#9733;" : "&#9734;"}</button>
6805
- ${renderCardMenu(group, followed, queued !== void 0, status)}
6944
+ ${renderCardMenu(group, followed, queued !== void 0, status, activity)}
6806
6945
  <a class="prb-card-link" href="${escapeHtml$1(href)}">
6807
6946
  <div class="prb-card-head">
6808
6947
  <h2>${escapeHtml$1(review.title)}</h2>
6809
6948
  ${renderStatus(status)}
6810
6949
  ${renderPrState(prState)}
6950
+ ${`<span class="prb-card-lifecycle" data-lifecycle-chip${chip === void 0 ? " hidden" : ""}>${escapeHtml$1(chip ?? "")}</span>`}
6811
6951
  </div>
6812
6952
  ${review.summary ? `<p class="prb-card-summary">${renderInlineText(review.summary)}</p>` : ""}
6813
6953
  <p class="prb-card-where">
@@ -6824,7 +6964,7 @@ function renderCard(group, now, analysis, queued, moved, prState) {
6824
6964
  <span class="prb-card-diffstat"><span class="prb-add">+${review.additions}</span> <span class="prb-del">&minus;${review.deletions}</span></span>
6825
6965
  ${analysis !== void 0 && !review.hasStandardReview ? "" : `<span class="prb-card-left">${escapeHtml$1(whatIsLeft(review))}</span>`}
6826
6966
  ${renderAnalysisState(analysis)}
6827
- <span class="prb-card-when"><time datetime="${new Date(touchedAt(review)).toISOString()}" title="${escapeHtml$1(new Date(touchedAt(review)).toLocaleString())}">${escapeHtml$1(whenLabel(review, now))}</time></span>
6967
+ <span class="prb-card-when"><time datetime="${new Date(activity.at).toISOString()}" title="${escapeHtml$1(new Date(activity.at).toLocaleString())}">${escapeHtml$1(`${activityLabel(activity.kind)} ${relativeTime(activity.at, now)}`)}</time></span>
6828
6968
  </p>
6829
6969
  </a>
6830
6970
  ${renderHistory(group, now)}
@@ -6847,9 +6987,13 @@ function renderCard(group, now, analysis, queued, moved, prState) {
6847
6987
  *
6848
6988
  * The rows underneath carry the per-review menus, and they only exist once there is more than one.
6849
6989
  */
6850
- function renderCardMenu(group, followed, queued, status) {
6990
+ function renderCardMenu(group, followed, queued, status, activity) {
6851
6991
  const count = group.history.length;
6852
6992
  const mark = status === "complete" ? "in-progress" : "complete";
6993
+ const lifecycle = {
6994
+ stored: group.latest.lifecycle,
6995
+ activity
6996
+ };
6853
6997
  if (count < 2) return renderMenu({
6854
6998
  id: group.latest.id,
6855
6999
  scope: "review",
@@ -6858,7 +7002,8 @@ function renderCardMenu(group, followed, queued, status) {
6858
7002
  question: "Delete this review?",
6859
7003
  copy: "Copy review ID",
6860
7004
  queue: queued ? "remove" : "add",
6861
- mark
7005
+ mark,
7006
+ lifecycle
6862
7007
  });
6863
7008
  const all = `all ${count} reviews following ${followed}`;
6864
7009
  return renderMenu({
@@ -6869,6 +7014,7 @@ function renderCardMenu(group, followed, queued, status) {
6869
7014
  question: `Delete ${all}?`,
6870
7015
  queue: queued ? "remove" : "add",
6871
7016
  mark,
7017
+ lifecycle,
6872
7018
  copy: "Copy latest review ID"
6873
7019
  });
6874
7020
  }
@@ -6889,11 +7035,13 @@ function renderMenu(item) {
6889
7035
  const id = escapeHtml$1(item.id);
6890
7036
  const markItem = item.mark === void 0 ? "" : `<button type="button" class="prb-menu-item" data-mark-status="${item.mark}" data-mark-id="${id}">${markItemLabel(item.mark)}</button>`;
6891
7037
  const queueItem = item.queue === void 0 ? "" : `<button type="button" class="prb-menu-item" data-queue-action="${item.queue}" data-queue-id="${id}">${item.queue === "remove" ? "Remove from review queue" : "Add to review queue"}</button>`;
7038
+ const lifecycleItems = item.lifecycle === void 0 ? "" : LIFECYCLES.map(({ id: target }) => `<button type="button" class="prb-menu-item" data-lifecycle-set="${target}" data-lifecycle-id="${id}"${offersLifecycle(target, item.lifecycle.stored, item.lifecycle.activity) ? "" : " hidden"}>${escapeHtml$1(lifecycleAction(target))}</button>`).join("\n ");
6892
7039
  return `<div class="prb-menu" data-menu>
6893
7040
  <button type="button" class="prb-menu-trigger" data-menu-trigger aria-expanded="false" aria-haspopup="true" aria-label="${escapeHtml$1(item.label)}">&#8943;</button>
6894
7041
  <div class="prb-menu-panel" data-menu-panel hidden>
6895
7042
  <div data-menu-items>
6896
7043
  ${markItem}
7044
+ ${lifecycleItems}
6897
7045
  ${queueItem}
6898
7046
  <button type="button" class="prb-menu-item" data-copy-id="${id}">${escapeHtml$1(item.copy)}</button>
6899
7047
  <button type="button" class="prb-menu-item prb-menu-item-danger" data-delete="${id}" data-scope="${item.scope}">${escapeHtml$1(item.action)}</button>
@@ -7040,13 +7188,6 @@ function renderRisk(risk) {
7040
7188
  if (risk === "unknown") return "";
7041
7189
  return `<span class="prb-card-risk" data-risk="${risk}">${risk} risk</span>`;
7042
7190
  }
7043
- /** Last opened where that is known, created otherwise. */
7044
- function touchedAt(review) {
7045
- return review.lastViewedAt ?? review.createdAt;
7046
- }
7047
- function whenLabel(review, now) {
7048
- return review.lastViewedAt ? `Opened ${relativeTime(review.lastViewedAt, now)}` : `Created ${relativeTime(review.createdAt, now)}`;
7049
- }
7050
7191
  /**
7051
7192
  * Everything the search field matches, flattened into one string on the card.
7052
7193
  *
@@ -7136,6 +7277,115 @@ function escapeHtml(value) {
7136
7277
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
7137
7278
  }
7138
7279
  //#endregion
7280
+ //#region ../../packages/review-harness/src/workspace/preferences.ts
7281
+ /**
7282
+ * Workspace preferences that belong to the reviewer rather than to a review.
7283
+ *
7284
+ * Kept server-side, in their own file. The original reason was that `localStorage` is keyed by
7285
+ * origin and this server took whatever port the OS offered on each start, so a restart would
7286
+ * silently discard a width the reviewer had deliberately chosen. That reason has gone: the port is
7287
+ * fixed, so the origin holds still and `localStorage` would now work. Moving this is a change worth
7288
+ * making on its own merits rather than in passing, and there is a second reason it stays for now:
7289
+ * `config.json` next door is hand-edited, and writing to a file someone maintains by hand reformats
7290
+ * it under them the first time they save, which is why this is not simply a key in there.
7291
+ *
7292
+ * Everything here is best-effort. A workspace that will not open because a preferences file is
7293
+ * unreadable would be a preference costing more than it is worth.
7294
+ */
7295
+ var PREFERENCES_PATH = join(STORE_ROOT, "ui.json");
7296
+ var MAX_ASK_WIDTH = 1200;
7297
+ /** A key is a short identifier, never prose. Anything else in the file is not one of ours. */
7298
+ var NOTICE_KEY = /^[a-z-]+:[^\s]{1,200}$/;
7299
+ function readPreferences() {
7300
+ try {
7301
+ const parsed = JSON.parse(readFileSync(PREFERENCES_PATH, "utf8"));
7302
+ if (!parsed || typeof parsed !== "object") return {};
7303
+ const { askWidth, dismissedNotices, promptsCollapsed, changeMapRegister, theme } = parsed;
7304
+ return {
7305
+ ...typeof askWidth === "number" && Number.isFinite(askWidth) ? { askWidth: clampAskWidth(askWidth) } : {},
7306
+ ...Array.isArray(dismissedNotices) ? { dismissedNotices: dismissedNotices.filter((key) => typeof key === "string" && NOTICE_KEY.test(key)) } : {},
7307
+ ...typeof promptsCollapsed === "boolean" ? { promptsCollapsed } : {},
7308
+ ...changeMapRegister === "simple" || changeMapRegister === "technical" ? { changeMapRegister } : {},
7309
+ ...isTheme(theme) ? { theme } : {}
7310
+ };
7311
+ } catch {
7312
+ return {};
7313
+ }
7314
+ }
7315
+ function writeAskWidth(width) {
7316
+ if (!Number.isFinite(width)) return;
7317
+ try {
7318
+ mkdirSync(STORE_ROOT, { recursive: true });
7319
+ const next = {
7320
+ ...readPreferences(),
7321
+ askWidth: clampAskWidth(width)
7322
+ };
7323
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify(next, null, 2)}\n`);
7324
+ } catch {}
7325
+ }
7326
+ /** Remember whether "Your prompts" is folded. Read-modify-write, like every other field here. */
7327
+ function writePromptsCollapsed(collapsed) {
7328
+ try {
7329
+ mkdirSync(STORE_ROOT, { recursive: true });
7330
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
7331
+ ...readPreferences(),
7332
+ promptsCollapsed: collapsed
7333
+ }, null, 2)}\n`);
7334
+ } catch {}
7335
+ }
7336
+ /** Remember which words the change map is read in. Read-modify-write, like every other field here. */
7337
+ function writeChangeMapRegister(register) {
7338
+ try {
7339
+ mkdirSync(STORE_ROOT, { recursive: true });
7340
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
7341
+ ...readPreferences(),
7342
+ changeMapRegister: register
7343
+ }, null, 2)}\n`);
7344
+ } catch {}
7345
+ }
7346
+ /** Remember which palette the workspace is drawn in. Read-modify-write, like every other field here. */
7347
+ function writeTheme(theme) {
7348
+ try {
7349
+ mkdirSync(STORE_ROOT, { recursive: true });
7350
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
7351
+ ...readPreferences(),
7352
+ theme
7353
+ }, null, 2)}\n`);
7354
+ } catch {}
7355
+ }
7356
+ /** Remember that the reviewer has read a notice. Refuses anything not shaped like a key. */
7357
+ function dismissNotice(key) {
7358
+ if (!NOTICE_KEY.test(key)) return false;
7359
+ try {
7360
+ const current = readPreferences();
7361
+ const dismissedNotices = [.../* @__PURE__ */ new Set([...current.dismissedNotices ?? [], key])];
7362
+ mkdirSync(STORE_ROOT, { recursive: true });
7363
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
7364
+ ...current,
7365
+ dismissedNotices
7366
+ }, null, 2)}\n`);
7367
+ return true;
7368
+ } catch {
7369
+ return false;
7370
+ }
7371
+ }
7372
+ /** Forget a stated width, so the stylesheet's proportional default applies again. */
7373
+ function clearAskWidth() {
7374
+ try {
7375
+ const { askWidth: _dropped, ...rest } = readPreferences();
7376
+ mkdirSync(STORE_ROOT, { recursive: true });
7377
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify(rest, null, 2)}\n`);
7378
+ } catch {}
7379
+ }
7380
+ /**
7381
+ * Absolute bounds only. The upper limit the reviewer actually feels is half the viewport, which
7382
+ * the page enforces because it is the only party that knows how wide the window is — this is the
7383
+ * far cruder job of keeping a hand-edited or stale number from producing an unusable layout.
7384
+ */
7385
+ function clampAskWidth(width) {
7386
+ return Math.round(Math.min(MAX_ASK_WIDTH, Math.max(320, width)));
7387
+ }
7388
+ //#endregion
7139
7389
  //#region ../../packages/review-harness/src/workspace/settings.ts
7140
7390
  /**
7141
7391
  * The settings the workspace can show and change, as one object rather than as three files.
@@ -7167,7 +7417,8 @@ function readSettings() {
7167
7417
  },
7168
7418
  agent: readAgentPreference(),
7169
7419
  agents: AGENT_IDS,
7170
- usageCounts: telemetryUploadConsent()
7420
+ usageCounts: telemetryUploadConsent(),
7421
+ theme: readPreferences().theme ?? "system"
7171
7422
  };
7172
7423
  }
7173
7424
  /** True when what the reader is looking at is served on a port they have since changed. */
@@ -7194,10 +7445,19 @@ function saveSettings(change, minPort, maxPort) {
7194
7445
  settings: readSettings(),
7195
7446
  error: `There is no agent called \`${change.agent}\`.`
7196
7447
  };
7448
+ if (change.theme !== void 0 && !isTheme(change.theme)) {
7449
+ const labels = THEMES.map((theme) => theme.label);
7450
+ const choices = `${labels.slice(0, -1).join(", ")} or ${labels.at(-1)}`;
7451
+ return {
7452
+ settings: readSettings(),
7453
+ error: `There is no theme called \`${change.theme}\`. Choose ${choices}.`
7454
+ };
7455
+ }
7197
7456
  if (port !== null) writePortPreference(port);
7198
7457
  if (change.agent === "") clearAgentPreference();
7199
7458
  else if (change.agent !== void 0) writeAgentPreference(change.agent);
7200
7459
  if (change.usageCounts !== void 0) recordTelemetryUploadConsent(change.usageCounts);
7460
+ if (change.theme !== void 0 && isTheme(change.theme)) writeTheme(change.theme);
7201
7461
  return {
7202
7462
  settings: readSettings(),
7203
7463
  error: null
@@ -7209,7 +7469,7 @@ function renderSettingsPage(view) {
7209
7469
  const { settings } = view;
7210
7470
  const address = `http://localhost:${settings.port.configured}`;
7211
7471
  return `<!doctype html>
7212
- <html lang="en">
7472
+ <html lang="en"${themeAttribute(settings.theme)}>
7213
7473
  <head>
7214
7474
  <meta charset="utf-8">
7215
7475
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -7231,6 +7491,7 @@ ${renderWorkspaceRail({
7231
7491
  unread: view.unreadNotifications ?? 0
7232
7492
  })}
7233
7493
  <main class="prb-index prb-settings">
7494
+ ${renderAppearanceSection(settings)}
7234
7495
  ${renderWorkspaceSection(settings, address)}
7235
7496
  ${renderReviewsSection(settings)}
7236
7497
  ${renderPrivacySection(settings)}
@@ -7242,7 +7503,8 @@ ${renderWorkspaceRail({
7242
7503
 
7243
7504
  <p class="prb-index-note">These are kept in <code>~/.prreviewbuddy/config.json</code>, which you can
7244
7505
  edit by hand, and they are the same settings <code>prreviewbuddy config</code> reads and writes.
7245
- Run <code>prreviewbuddy config path</code> to be told where that file is.</p>
7506
+ Run <code>prreviewbuddy config path</code> to be told where that file is. Appearance is kept with
7507
+ the workspace's other display choices in <code>~/.prreviewbuddy/ui.json</code>.</p>
7246
7508
 
7247
7509
  ${renderServerSection()}
7248
7510
  </main>
@@ -7310,6 +7572,23 @@ function renderWorkspaceSection(settings, address) {
7310
7572
  working tomorrow.${moved ? ` <strong>You are reading this on port ${settings.port.current}.</strong> The port above is used the next time the workspace starts.` : ""}</p>
7311
7573
  </section>`;
7312
7574
  }
7575
+ /**
7576
+ * The theme, as three radios over the registry in theme.ts.
7577
+ *
7578
+ * Choosing one repaints this page at once (see `client/settings.ts`), so the reader can see what
7579
+ * they are picking; Save is still what keeps it, like every other field here.
7580
+ */
7581
+ function renderAppearanceSection(settings) {
7582
+ return `<section class="prb-settings-section">
7583
+ <h2>Appearance</h2>
7584
+ <div class="prb-field prb-settings-field" role="group" aria-labelledby="prb-theme-label">
7585
+ <span id="prb-theme-label">Theme</span>
7586
+ <div class="prb-settings-choice">
7587
+ ${THEMES.map((theme) => `<label><input type="radio" name="prb-theme" value="${theme.id}" data-settings-theme${settings.theme === theme.id ? " checked" : ""}> ${theme.label} <small class="prb-settings-choice-help">${theme.help}</small></label>`).join("\n ")}
7588
+ </div>
7589
+ </div>
7590
+ </section>`;
7591
+ }
7313
7592
  function renderReviewsSection(settings) {
7314
7593
  return `<section class="prb-settings-section">
7315
7594
  <h2>Reviews</h2>
@@ -7568,7 +7847,7 @@ function defaultFetchDeps() {
7568
7847
  function renderNotificationsPage(view) {
7569
7848
  const unread = view.notifications.filter((notification) => notification.readAt === void 0).length;
7570
7849
  return `<!doctype html>
7571
- <html lang="en">
7850
+ <html lang="en"${themeAttribute(view.theme)}>
7572
7851
  <head>
7573
7852
  <meta charset="utf-8">
7574
7853
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -7765,6 +8044,8 @@ function reviewsRevision(live = analysisStates()) {
7765
8044
  hash.update(`queue:${queueStamp()}\n`);
7766
8045
  for (const [id, kind] of [...movedReviews()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`moved:${id}:${kind}\n`);
7767
8046
  for (const [key, state] of [...prStatesByRequest()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`pr:${key}:${state}\n`);
8047
+ for (const [key, entry] of Object.entries(readWatch()).sort(([a], [b]) => a.localeCompare(b))) if (entry.changedAt !== void 0) hash.update(`head:${key}:${entry.changedAt}\n`);
8048
+ for (const [key, entry] of Object.entries(readPrStates()).sort(([a], [b]) => a.localeCompare(b))) if (entry.changedAt !== void 0) hash.update(`pr-changed:${key}:${entry.changedAt}\n`);
7768
8049
  return hash.digest("base64url");
7769
8050
  }
7770
8051
  //#endregion
@@ -8643,104 +8924,6 @@ function readConfiguredEditor() {
8643
8924
  return null;
8644
8925
  }
8645
8926
  }
8646
- //#endregion
8647
- //#region ../../packages/review-harness/src/workspace/preferences.ts
8648
- /**
8649
- * Workspace preferences that belong to the reviewer rather than to a review.
8650
- *
8651
- * Kept server-side, in their own file. The original reason was that `localStorage` is keyed by
8652
- * origin and this server took whatever port the OS offered on each start, so a restart would
8653
- * silently discard a width the reviewer had deliberately chosen. That reason has gone: the port is
8654
- * fixed, so the origin holds still and `localStorage` would now work. Moving this is a change worth
8655
- * making on its own merits rather than in passing, and there is a second reason it stays for now:
8656
- * `config.json` next door is hand-edited, and writing to a file someone maintains by hand reformats
8657
- * it under them the first time they save, which is why this is not simply a key in there.
8658
- *
8659
- * Everything here is best-effort. A workspace that will not open because a preferences file is
8660
- * unreadable would be a preference costing more than it is worth.
8661
- */
8662
- var PREFERENCES_PATH = join(STORE_ROOT, "ui.json");
8663
- var MAX_ASK_WIDTH = 1200;
8664
- /** A key is a short identifier, never prose. Anything else in the file is not one of ours. */
8665
- var NOTICE_KEY = /^[a-z-]+:[^\s]{1,200}$/;
8666
- function readPreferences() {
8667
- try {
8668
- const parsed = JSON.parse(readFileSync(PREFERENCES_PATH, "utf8"));
8669
- if (!parsed || typeof parsed !== "object") return {};
8670
- const { askWidth, dismissedNotices, promptsCollapsed, changeMapRegister } = parsed;
8671
- return {
8672
- ...typeof askWidth === "number" && Number.isFinite(askWidth) ? { askWidth: clampAskWidth(askWidth) } : {},
8673
- ...Array.isArray(dismissedNotices) ? { dismissedNotices: dismissedNotices.filter((key) => typeof key === "string" && NOTICE_KEY.test(key)) } : {},
8674
- ...typeof promptsCollapsed === "boolean" ? { promptsCollapsed } : {},
8675
- ...changeMapRegister === "simple" || changeMapRegister === "technical" ? { changeMapRegister } : {}
8676
- };
8677
- } catch {
8678
- return {};
8679
- }
8680
- }
8681
- function writeAskWidth(width) {
8682
- if (!Number.isFinite(width)) return;
8683
- try {
8684
- mkdirSync(STORE_ROOT, { recursive: true });
8685
- const next = {
8686
- ...readPreferences(),
8687
- askWidth: clampAskWidth(width)
8688
- };
8689
- writeFileSync(PREFERENCES_PATH, `${JSON.stringify(next, null, 2)}\n`);
8690
- } catch {}
8691
- }
8692
- /** Remember whether "Your prompts" is folded. Read-modify-write, like every other field here. */
8693
- function writePromptsCollapsed(collapsed) {
8694
- try {
8695
- mkdirSync(STORE_ROOT, { recursive: true });
8696
- writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
8697
- ...readPreferences(),
8698
- promptsCollapsed: collapsed
8699
- }, null, 2)}\n`);
8700
- } catch {}
8701
- }
8702
- /** Remember which words the change map is read in. Read-modify-write, like every other field here. */
8703
- function writeChangeMapRegister(register) {
8704
- try {
8705
- mkdirSync(STORE_ROOT, { recursive: true });
8706
- writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
8707
- ...readPreferences(),
8708
- changeMapRegister: register
8709
- }, null, 2)}\n`);
8710
- } catch {}
8711
- }
8712
- /** Remember that the reviewer has read a notice. Refuses anything not shaped like a key. */
8713
- function dismissNotice(key) {
8714
- if (!NOTICE_KEY.test(key)) return false;
8715
- try {
8716
- const current = readPreferences();
8717
- const dismissedNotices = [.../* @__PURE__ */ new Set([...current.dismissedNotices ?? [], key])];
8718
- mkdirSync(STORE_ROOT, { recursive: true });
8719
- writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
8720
- ...current,
8721
- dismissedNotices
8722
- }, null, 2)}\n`);
8723
- return true;
8724
- } catch {
8725
- return false;
8726
- }
8727
- }
8728
- /** Forget a stated width, so the stylesheet's proportional default applies again. */
8729
- function clearAskWidth() {
8730
- try {
8731
- const { askWidth: _dropped, ...rest } = readPreferences();
8732
- mkdirSync(STORE_ROOT, { recursive: true });
8733
- writeFileSync(PREFERENCES_PATH, `${JSON.stringify(rest, null, 2)}\n`);
8734
- } catch {}
8735
- }
8736
- /**
8737
- * Absolute bounds only. The upper limit the reviewer actually feels is half the viewport, which
8738
- * the page enforces because it is the only party that knows how wide the window is — this is the
8739
- * far cruder job of keeping a hand-edited or stale number from producing an unusable layout.
8740
- */
8741
- function clampAskWidth(width) {
8742
- return Math.round(Math.min(MAX_ASK_WIDTH, Math.max(320, width)));
8743
- }
8744
8927
  /** Long enough for a paragraph of standing instructions, short enough not to crowd the question. */
8745
8928
  var MAX_PROMPT_CHARS = 2e3;
8746
8929
  /**
@@ -9029,6 +9212,7 @@ async function handle(req, res, options) {
9029
9212
  if (url.pathname === "/queue") return handleQueue(req, res, options.indexToken);
9030
9213
  if (url.pathname === "/delete") return handleDelete(req, res, options.indexToken);
9031
9214
  if (url.pathname === "/status") return handleStatus(req, res, options.indexToken);
9215
+ if (url.pathname === "/lifecycle") return handleLifecycle(req, res, options.indexToken, options.establishBaseline ?? establishBaseline);
9032
9216
  if (url.pathname === "/revision") return handleRevision(req, res, options.indexToken);
9033
9217
  if (url.pathname === "/fetch-updates") return handleFetchUpdates(req, res, options.indexToken);
9034
9218
  if (url.pathname === "/shutdown") return handleIndexShutdown(req, res, options);
@@ -9119,9 +9303,11 @@ function handleIndex(req, res, params, indexToken) {
9119
9303
  if (onward) return redirect(res, onward, cookie);
9120
9304
  const states = analysisStates();
9121
9305
  const queue = readQueue();
9122
- const kept = new Set(queue);
9123
- for (const review of recentWorkspaces(Number.MAX_SAFE_INTEGER)) if (review.pinned) kept.add(review.id);
9124
- const groups = indexReviewGroups(MAX_LISTED_BRANCHES, MAX_KEPT_BRANCHES, kept);
9306
+ const reviews = recentWorkspaces(Number.MAX_SAFE_INTEGER);
9307
+ const kept = indexKeptIds(reviews, queue);
9308
+ const everyGroup = groupByLineage(reviews);
9309
+ const activity = reviewActivity(everyGroup);
9310
+ const groups = indexReviewGroups(MAX_LISTED_BRANCHES, MAX_KEPT_BRANCHES, kept, (group) => activity.get(group.latest.id)?.at ?? group.latest.createdAt, everyGroup);
9125
9311
  const rendered = new Set(groups.flatMap((group) => group.history.map((review) => review.id)));
9126
9312
  sendHtml(res, 200, renderReviewsPage({
9127
9313
  groups,
@@ -9129,9 +9315,11 @@ function handleIndex(req, res, params, indexToken) {
9129
9315
  analyses: states,
9130
9316
  moved: movedReviews(),
9131
9317
  prStates: prStatesByRequest(),
9318
+ activity,
9132
9319
  revision: reviewsRevision(states),
9133
9320
  unreadNotifications: unreadCount$1(),
9134
- driving: isDriving()
9321
+ driving: isDriving(),
9322
+ theme: readPreferences().theme
9135
9323
  }), cookie);
9136
9324
  }
9137
9325
  /**
@@ -9203,7 +9391,8 @@ async function handleSettings(req, res, params, indexToken) {
9203
9391
  const { settings, error } = saveSettings({
9204
9392
  ...typeof body?.port === "string" ? { port: body.port } : {},
9205
9393
  ...typeof body?.agent === "string" ? { agent: body.agent } : {},
9206
- ...typeof body?.usageCounts === "boolean" ? { usageCounts: body.usageCounts } : {}
9394
+ ...typeof body?.usageCounts === "boolean" ? { usageCounts: body.usageCounts } : {},
9395
+ ...typeof body?.theme === "string" ? { theme: body.theme } : {}
9207
9396
  }, MIN_WORKSPACE_PORT, MAX_WORKSPACE_PORT);
9208
9397
  return send(res, 200, {
9209
9398
  port: {
@@ -9212,6 +9401,7 @@ async function handleSettings(req, res, params, indexToken) {
9212
9401
  },
9213
9402
  agent: settings.agent,
9214
9403
  usageCounts: settings.usageCounts,
9404
+ theme: settings.theme,
9215
9405
  restartRequired: restartRequired(settings),
9216
9406
  error
9217
9407
  });
@@ -9303,7 +9493,8 @@ async function handleNotifications(req, res, params, indexToken) {
9303
9493
  notifications,
9304
9494
  gone: new Set(notifications.filter((entry) => !loadWorkspace(entry.reviewId)).map((entry) => entry.id)),
9305
9495
  unreadNotifications: unreadCount$1(),
9306
- driving: isDriving()
9496
+ driving: isDriving(),
9497
+ theme: readPreferences().theme
9307
9498
  }), indexCookieHeader(indexToken));
9308
9499
  }
9309
9500
  /**
@@ -9363,6 +9554,74 @@ async function handleStatus(req, res, indexToken) {
9363
9554
  });
9364
9555
  }
9365
9556
  /**
9557
+ * Put a selection in Active, Waiting or Done.
9558
+ *
9559
+ * The same shape and the same credential as `handleStatus` beside it, for its reasons: this is the
9560
+ * list acting on itself, and a lifecycle is a note about what happens next, not a change to the
9561
+ * analysis, so nothing in flight is a reason to refuse it.
9562
+ *
9563
+ * **A state that is stated and unknown is refused whole**, naming it and what would have worked.
9564
+ * Guessing (reading `archived` as Done, say) would put cards where the reviewer did not ask for
9565
+ * them, and skipping it would answer 200 to a request that did nothing.
9566
+ *
9567
+ * Waiting and Done both leave the queue: it holds work somebody means to do next, and either mark
9568
+ * says there is none. Active does not put anything back in, as un-marking a review does not.
9569
+ *
9570
+ * Marking Waiting again writes a new `at`, which is what settles a Waiting review that changed: the
9571
+ * reviewer has seen the change and parked it again.
9572
+ */
9573
+ async function handleLifecycle(req, res, indexToken, baseline) {
9574
+ if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
9575
+ const body = await readJson(req);
9576
+ const state = body?.state;
9577
+ if (!isLifecycle(state)) {
9578
+ const choices = LIFECYCLES.map((lifecycle) => lifecycle.id).join(", ");
9579
+ return send(res, 400, { error: `There is no lifecycle state called \`${String(state)}\`. Choose one of ${choices}.` });
9580
+ }
9581
+ const ids = Array.isArray(body?.ids) ? body.ids.filter((id) => typeof id === "string") : [];
9582
+ const now = Date.now();
9583
+ let changed = 0;
9584
+ for (const id of ids) {
9585
+ if (!mutateWorkspace(id, (current) => {
9586
+ current.lifecycle = state === "active" ? void 0 : {
9587
+ state,
9588
+ at: now
9589
+ };
9590
+ })) continue;
9591
+ if (state !== "active") queueRemove(id);
9592
+ changed += 1;
9593
+ }
9594
+ send(res, 200, {
9595
+ ok: true,
9596
+ changed,
9597
+ at: now,
9598
+ queue: readQueue()
9599
+ });
9600
+ if (state === "waiting") {
9601
+ const unseen = unobserved(ids);
9602
+ if (unseen.size > 0) baseline(unseen);
9603
+ }
9604
+ }
9605
+ /**
9606
+ * Which of these reviews the watcher has never seen the head of.
9607
+ *
9608
+ * Those are the ones Waiting cannot yet work for: a first observation is not a change (it says
9609
+ * nothing about when the head moved), so a push before it would be absorbed as the baseline. A
9610
+ * review already observed has its baseline, and polling it again now would only spend a request.
9611
+ */
9612
+ function unobserved(ids) {
9613
+ const seen = new Set(Object.values(readWatch()).filter((entry) => entry.observedSha !== void 0).map((entry) => entry.reviewId));
9614
+ return new Set(ids.filter((id) => !seen.has(id)));
9615
+ }
9616
+ /**
9617
+ * The first look itself, by the watcher's own rules. A review that cannot be observed (the forge is
9618
+ * unreachable, or it is a commit review with nothing to watch) stays Waiting with no baseline, and
9619
+ * no change is invented for it: the next successful poll sets one.
9620
+ */
9621
+ function establishBaseline(reviewIds) {
9622
+ watchAllNow(reviewIds).catch(() => {});
9623
+ }
9624
+ /**
9366
9625
  * Has anything the reviews index draws changed, and is anything still being made.
9367
9626
  *
9368
9627
  * The whole of the index's liveness, in one request that costs the same whether the machine holds
@@ -9457,9 +9716,27 @@ async function handleQueue(req, res, indexToken) {
9457
9716
  if (action === "add") compactQueue(readQueue());
9458
9717
  const stored = action === "add" ? queueAdd(id) : queueRemove(id);
9459
9718
  if (stored === null) return send(res, 500, { error: "The queue could not be saved." });
9719
+ if (action === "add") activateQueued([id], stored);
9460
9720
  send(res, 200, { queue: compactQueue(stored) });
9461
9721
  }
9462
9722
  /**
9723
+ * Queueing a review puts it back in Active.
9724
+ *
9725
+ * The queue is work the reviewer wants surfaced next, and Waiting and Done both say the opposite; a
9726
+ * queued review folded away in Done would be a contradiction the page then has to draw. Parking
9727
+ * takes a review out of the queue, and this is the same rule read the other way. Only what actually
9728
+ * went in: a review refused because the queue was full keeps the lifecycle it had.
9729
+ */
9730
+ function activateQueued(ids, queue) {
9731
+ const queued = new Set(queue);
9732
+ for (const one of ids) {
9733
+ if (!queued.has(one) || loadWorkspace(one)?.lifecycle === void 0) continue;
9734
+ mutateWorkspace(one, (current) => {
9735
+ current.lifecycle = void 0;
9736
+ });
9737
+ }
9738
+ }
9739
+ /**
9463
9740
  * Adding a selection, and saying which of it did not go in.
9464
9741
  *
9465
9742
  * `refused` has the shape bulk deletion's `blocked` has, a sentence per id, so the bar treats both
@@ -9473,6 +9750,7 @@ function addMany(res, ids) {
9473
9750
  compactQueue(readQueue());
9474
9751
  const outcome = queueAddMany(known);
9475
9752
  if (outcome === null) return send(res, 500, { error: "The queue could not be saved." });
9753
+ activateQueued(known, outcome.queue);
9476
9754
  const refused = [...outcome.full.map((one) => ({
9477
9755
  id: one,
9478
9756
  error: `The review queue is full. It holds 50 reviews at most.`
@@ -9709,6 +9987,7 @@ async function sendWorkspacePage(res, workspace) {
9709
9987
  editor: editorChoice(),
9710
9988
  repoPath: workspace.repoPath,
9711
9989
  askWidth: readPreferences().askWidth,
9990
+ theme: readPreferences().theme,
9712
9991
  dismissedNotices: readPreferences().dismissedNotices,
9713
9992
  promptsCollapsed: readPreferences().promptsCollapsed === true,
9714
9993
  changeMapRegister: readPreferences().changeMapRegister ?? "simple",