prreviewbuddy 0.46.0 → 0.49.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 { $ as compactQueue, A as createPrState, An as processDiscussion, At as reviewedShaOf, B as toneOf, Bn as writeAgentPreference, Bt as progressSteps, C as readEvents, Cn as readQueue, D as announceJob, Dn as issuesOutstanding, Dt as checkCodeFreshness, E as telemetryUploadConsent, En as isQuestionOutstanding, Et as fillFileUrlTemplate, F as markAllRead, Fn as git, Ft as loadJob, G as extractJsonText, Gt as followedRefName, H as withUsageRecorded, Hn as STORE_ROOT, I as markRead, It as saveJob, J as readMarker, Jn as AgentCancelledError, Jt as indexReviewGroups, K as ReviewBeingDeletedError, Kn as agentFor, Kt as groupByLineage, L as pruneNotifications, Ln as resolveModel, M as eventName, Mt as endedAt, N as headline, Nt as fail, O as branchOf, On as questionsOutstanding, Ot as checkReview, P as listNotifications, Pn as isCommitReview, Pt as isTerminal, Q as activateQueued, Qt as loadWorkspace, R as readNotification, Rn as clearAgentPreference, Rt as isLiveJob, Sn as queueStamp, St as claimRequestUrl, T as recordTelemetryUploadConsent, Tn as isIssueOutstanding, Tt as remoteHost, U as EXPLAIN_SIMPLY_PROMPT, Un as AGENT_IDS, V as unreadCount$1, Vt as purposeOf, W as asString, Wt as isClaimed, Y as removeWorktree, Yn as AgentUnavailableError, Yt as latestKissRun, Z as relativeTime$1, Zt as lineagePosition, _ as agentEnvOf, _n as parseLifecycle, _t as MIN_WORKSPACE_PORT, a as wasBlocked, at as readServerRecord, bn as queueAddMany, bt as statedWorkspacePort, c as RefreshUnavailableError, cn as workspaceRevision, ct as spawnServer, d as refreshPrContext, dn as activityLabel, dt as writeServerRecord, en as mutateWorkspace, f as isUnchanged, fn as isLifecycle, ft as BUILD_VERSION, gn as offersLifecycle, gt as MAX_WORKSPACE_PORT, h as liveUpdateFor, hn as lifecyclePill, ht as DEFAULT_WORKSPACE_PORT, i as liveJobsFor, in as reviewedCommit, j as createPrUpdate, jn as describeAuthorship, jt as allJobs, k as subjectOf, kn as reviewerDispositions, kt as followedBranch, l as mergeUpdateInto, ln as workspaceStamps, m as UpdateAlreadyRunningError, mn as lifecycleChip, mt as feedbackUrl, n as deleteReview, nn as previousKissResult, o as discardJob, p as askCheckout, pn as lifecycleAction, q as MANAGED_ROOT, qt as indexKeptIds, r as lineageIds, rn as recentWorkspaces, rt as clearServerRecord, s as FETCH_TIMEOUT_MS, sn as touchWorkspace, st as serverEntryExists, t as deleteLineage, tt as readIndexToken, u as updateReview, un as LIFECYCLES, vn as sectionOf, vt as parseWorkspacePort, w as record, wn as doneVerb, wt as forgeFor, x as runJob, xn as queueRemove, xt as writePortPreference, y as startKissJob, yn as queueAdd, yt as resolveWorkspacePort, z as removeNotification, zn as readAgentPreference, zt as phasesFor } from "./delete_review-BBjXPfMV.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";
@@ -253,6 +253,14 @@ function saveItemLabel(saved) {
253
253
  return saved ? "Remove from saved" : "Save review";
254
254
  }
255
255
  /**
256
+ * The queue item, on a card and on the review's own page. "review queue" rather than "queue",
257
+ * because a menu is where somebody meets the idea for the first time. Here because the browser
258
+ * relabels it after a change, on both pages.
259
+ */
260
+ function queueItemLabel(queued) {
261
+ return queued ? "Remove from review queue" : "Add to review queue";
262
+ }
263
+ /**
256
264
  * What `Continue reviewing` opens, and whether it exists at all.
257
265
  *
258
266
  * One rule rather than an expression in the template, because "is there anything to continue" and
@@ -4550,6 +4558,46 @@ function renderPrState(state, besideNumber = false) {
4550
4558
  return `<span class="prb-card-pr-state" data-pr-state="${state}">${state === "merged" ? "Merged" : besideNumber ? "Closed" : "PR closed"}</span>`;
4551
4559
  }
4552
4560
  //#endregion
4561
+ //#region ../../packages/review-harness/src/workspace/theme.ts
4562
+ /**
4563
+ * Which palette the workspace's furniture is drawn in: the reviewer's device, or a fixed choice.
4564
+ *
4565
+ * One axis with a registry rather than a flag per value, so the stored type, the settings page's
4566
+ * radios and the attribute on `<html>` all derive from the same list and cannot disagree about
4567
+ * what the choices are.
4568
+ *
4569
+ * Pure on purpose: the settings page's script imports this, and the client bundle must not reach
4570
+ * anything that pulls in a node builtin.
4571
+ *
4572
+ * The attribute is the whole mechanism. shell.css sets `color-scheme` from it, and every shell
4573
+ * token is a `light-dark()` pair that the browser resolves from `color-scheme`, so System follows
4574
+ * the device live, with no listener and no reload.
4575
+ */
4576
+ var THEMES = [
4577
+ {
4578
+ id: "system",
4579
+ label: "System",
4580
+ help: "Follow your device setting"
4581
+ },
4582
+ {
4583
+ id: "light",
4584
+ label: "Light",
4585
+ help: "Always use light mode"
4586
+ },
4587
+ {
4588
+ id: "dark",
4589
+ label: "Dark",
4590
+ help: "Always use dark mode"
4591
+ }
4592
+ ];
4593
+ function isTheme(value) {
4594
+ return THEMES.some((theme) => theme.id === value);
4595
+ }
4596
+ /** The `<html>` attribute, leading space included, so a root reads `<html lang="en"${…}>`. */
4597
+ function themeAttribute(theme) {
4598
+ return ` data-theme="${theme ?? "system"}"`;
4599
+ }
4600
+ //#endregion
4553
4601
  //#region ../../packages/review-harness/src/workspace/page.ts
4554
4602
  function renderWorkspace(view) {
4555
4603
  const { session, changeSet } = view;
@@ -4569,7 +4617,7 @@ function renderWorkspace(view) {
4569
4617
  */
4570
4618
  const freshness = result ? view.freshness : void 0;
4571
4619
  return `<!doctype html>
4572
- <html lang="en">
4620
+ <html lang="en"${themeAttribute(view.theme)}>
4573
4621
  <head>
4574
4622
  <meta charset="utf-8">
4575
4623
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -4658,9 +4706,30 @@ function renderReviewBody(view, sections) {
4658
4706
  ${updating(view) ? renderUpdatingDoc(view) : renderReviewDoc(view, sections)}
4659
4707
  </main>
4660
4708
  ${result ? renderAsk(view, sections, updating(view)) : ""}
4709
+ ${result ? renderAskTab() : ""}
4661
4710
  </div>`;
4662
4711
  }
4663
4712
  /**
4713
+ * Buddy AI's own way in: a tab on the window's right edge, where its pane opens.
4714
+ *
4715
+ * Out of the header, which says where the review stands; the assistant is a tool beside the review,
4716
+ * and the edge it lives on is the edge this sits on. Hidden by the stylesheet whenever the pane is
4717
+ * showing, where the chevron in the pane's own header closes it again. The pane's left border is
4718
+ * not free for it: the resize handle runs its full height.
4719
+ *
4720
+ * Horizontal, and only as wide as its words: the product reads left to right everywhere else, and
4721
+ * a vertical label was a pattern it would have used exactly once.
4722
+ *
4723
+ * Only with a standard review, like the pane. A control that opens nothing is worse than none.
4724
+ */
4725
+ function renderAskTab() {
4726
+ return `<button type="button" class="prb-ask-tab" data-ask-toggle aria-controls="prb-ask" aria-expanded="false">${ASK_TAB_ICON}<span class="prb-ask-tab-label">Buddy AI</span></button>`;
4727
+ }
4728
+ /** Four-pointed, stroked like the rail's icons, so it reads as one of the product's own marks. */
4729
+ var ASK_TAB_ICON = "<svg class=\"prb-ask-tab-icon\" viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" focusable=\"false\" aria-hidden=\"true\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linejoin=\"round\"><path d=\"M12 3l1.8 5.6a2 2 0 0 0 1.3 1.3L21 12l-5.9 2.1a2 2 0 0 0-1.3 1.3L12 21l-1.8-5.6a2 2 0 0 0-1.3-1.3L3 12l5.9-2.1a2 2 0 0 0 1.3-1.3z\"/></svg>";
4730
+ /** Two chevrons pointing where the pane goes. */
4731
+ var ASK_HIDE_ICON = "<svg viewBox=\"0 0 24 24\" width=\"15\" height=\"15\" focusable=\"false\" aria-hidden=\"true\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m6 17 5-5-5-5M13 17l5-5-5-5\"/></svg>";
4732
+ /**
4664
4733
  * The document while an update runs: its progress and nothing else.
4665
4734
  *
4666
4735
  * Deliberately not a `.prb-view`. Views are what the rail and the hash switch between, and a page
@@ -4967,42 +5036,69 @@ function renderReviewBar(view, freshness) {
4967
5036
  <span><span class="prb-add">+${additions}</span> <span class="prb-del">&minus;${deletions}</span></span>
4968
5037
  ${freshness || view.remote ? (() => {
4969
5038
  const { label, state } = freshnessLabel(freshness ?? null, view.remote ?? null);
4970
- return `<span class="prb-fresh-status" data-fresh-status data-state="${state}">${escapeHtml$1(label)}</span>${view.token ? "<button type=\"button\" class=\"prb-fresh-check\" data-fresh-check>Check for updates</button>" : ""}`;
5039
+ return `<span class="prb-fresh-status" data-fresh-status data-state="${state}">${escapeHtml$1(label)}</span>`;
4971
5040
  })() : ""}
4972
- ${view.token ? `<button type="button" class="prb-complete" data-complete aria-pressed="${view.complete === true}">${view.complete ? "Reviewed" : "Mark reviewed"}</button>` : ""}
4973
- ${view.id ? renderIdMenu(view.id, Boolean(view.token), view.saved === true) : ""}
4974
- ${view.session.result ? "<button type=\"button\" class=\"prb-ask-toggle\" data-ask-toggle aria-expanded=\"false\">Buddy AI</button>" : ""}
5041
+ ${view.token ? renderStatePills(view) : ""}
5042
+ ${view.id ? renderIdMenu(view, freshness) : ""}
4975
5043
  </div>
4976
5044
  </div>`;
4977
5045
  }
4978
5046
  /**
4979
- * This review's ID, where somebody looking at this review would think to find it.
5047
+ * Where this review stands, as pills that say and never do.
4980
5048
  *
4981
- * In the header because the header answers "what am I looking at", and an ID is the most literal
4982
- * possible answer to that: it is the name `prreviewbuddy review --review <review> --delete` takes.
4983
- * It was on the index only, so the one page devoted to a single review was the only place that
4984
- * could not tell you which review it was, and the help text's advice amounted to reading it out of
4985
- * the address bar.
5049
+ * Two, because they answer two questions. Reviewed or In progress is what the reviewer has done;
5050
+ * Waiting or Done is what happens next with the branch. A review can be Reviewed and Waiting, so a
5051
+ * single pill would have to drop one of them. Active draws nothing, as an open pull request does
5052
+ * not: it is where every review starts, and a pill that never varies teaches nothing.
4986
5053
  *
4987
- * The same `...` and the same menu markup as the index, deliberately. One gesture, in both places
4988
- * it can be wanted, behaving identically. That includes deleting, with the same confirmation in the
4989
- * same panel and the same sentence. The list is where you land afterwards, since the page you were
4990
- * reading no longer exists. It deletes this review only, as a history row on the list does.
5054
+ * Never "New" here, because being on this page is what ends it.
5055
+ */
5056
+ function renderStatePills(view) {
5057
+ const status = view.complete ? "complete" : "in-progress";
5058
+ const pill = lifecyclePill(view.lifecycle, view.activity);
5059
+ return `<span class="prb-card-status" data-status="${status}" data-page-status>${statusLabel(status)}</span>
5060
+ <span class="prb-card-status prb-page-lifecycle" data-page-lifecycle${pill?.changed ? " data-changed" : ""}${pill ? "" : " hidden"}>${escapeHtml$1(pill?.text ?? "")}</span>`;
5061
+ }
5062
+ /**
5063
+ * Everything that can be done to this review, from the page you are reading it on.
4991
5064
  *
4992
- * No delete where there is no server, which is the offline preview: the item would be a lie there.
4993
- * Save follows the same rule, since saving is a write to the store the preview does not have. It
4994
- * is the index's star, reachable from the page you are reading, so its words are the star's
4995
- * `aria-pressed` pair spelled out: what pressing it will do, never the state it is in.
5065
+ * The header only says where the review stands; this is where that is changed. It holds what the
5066
+ * Reviews list's card menu holds, in the same words from the same registries, so nobody has to go
5067
+ * back to the list to park or queue the review they are in. Check for updates leads because it is
5068
+ * the one thing here the list does not offer, and it is here rather than in the header because the
5069
+ * page already checks by itself, every minute while visible and on coming back to the tab.
5070
+ *
5071
+ * Waiting, Done and the queue belong to the branch, so they are offered on its newest review only,
5072
+ * as on the list, where the older reviews' rows carry none. The lineage notice already points at
5073
+ * the newest one.
5074
+ *
5075
+ * Delete keeps the index's confirmation, in the same panel with the same sentence. The list is
5076
+ * where you land afterwards, since the page you were reading no longer exists.
5077
+ *
5078
+ * Nothing that writes where there is no server, which is the offline preview: each item would be a
5079
+ * lie there. Copy review ID stays, because it is the name `prreviewbuddy review --review <review>
5080
+ * --delete` takes, and the page devoted to one review should be able to say which one it is.
4996
5081
  */
4997
- function renderIdMenu(id, canWrite, saved) {
4998
- const safe = escapeHtml$1(id);
4999
- return `<div class="prb-menu" data-menu>
5082
+ function renderIdMenu(view, freshness) {
5083
+ const safe = escapeHtml$1(view.id ?? "");
5084
+ const canWrite = Boolean(view.token);
5085
+ const latest = !view.lineage || view.lineage.position >= view.lineage.total;
5086
+ const mark = view.complete ? "in-progress" : "complete";
5087
+ const branchItems = canWrite && latest;
5088
+ const items = [
5089
+ canWrite && (freshness || view.remote) ? "<button type=\"button\" class=\"prb-menu-item\" data-fresh-check>Check for updates</button>" : "",
5090
+ canWrite ? `<button type="button" class="prb-menu-item" data-mark-status="${mark}">${markItemLabel(mark)}</button>` : "",
5091
+ ...branchItems ? LIFECYCLES.map(({ id }) => `<button type="button" class="prb-menu-item" data-lifecycle-set="${id}"${offersLifecycle(id, view.lifecycle, view.activity) ? "" : " hidden"}>${escapeHtml$1(lifecycleAction(id))}</button>`) : [],
5092
+ branchItems ? `<button type="button" class="prb-menu-item" data-queue-action="${view.queued ? "remove" : "add"}">${queueItemLabel(view.queued === true)}</button>` : "",
5093
+ canWrite ? `<button type="button" class="prb-menu-item" data-save-toggle aria-pressed="${view.saved === true}">${saveItemLabel(view.saved === true)}</button>` : "",
5094
+ `<button type="button" class="prb-menu-item" data-copy-id="${safe}">Copy review ID</button>`,
5095
+ canWrite ? `<button type="button" class="prb-menu-item prb-menu-item-danger" data-delete="${safe}">Delete review</button>` : ""
5096
+ ].filter(Boolean);
5097
+ return `<div class="prb-menu" data-menu data-review-menu${[view.lifecycle ? ` data-lifecycle-state="${view.lifecycle.state}" data-lifecycle-at="${view.lifecycle.at}"` : "", view.activity ? ` data-activity-at="${view.activity.at}" data-activity-kind="${view.activity.kind}"` : ""].join("")}>
5000
5098
  <button type="button" class="prb-menu-trigger" data-menu-trigger aria-expanded="false" aria-haspopup="true" aria-label="Actions for this review">&#8943;</button>
5001
5099
  <div class="prb-menu-panel" data-menu-panel hidden>
5002
5100
  <div data-menu-items>
5003
- ${canWrite ? `<button type="button" class="prb-menu-item" data-save-toggle aria-pressed="${saved}">${saveItemLabel(saved)}</button>` : ""}
5004
- <button type="button" class="prb-menu-item" data-copy-id="${safe}">Copy review ID</button>
5005
- ${canWrite ? `<button type="button" class="prb-menu-item prb-menu-item-danger" data-delete="${safe}">Delete review</button>` : ""}
5101
+ ${items.join("\n ")}
5006
5102
  </div>
5007
5103
  ${canWrite ? `<div class="prb-menu-confirm" data-menu-confirm hidden>
5008
5104
  <p class="prb-menu-question">Delete this review?</p>
@@ -5019,7 +5115,7 @@ function renderIdMenu(id, canWrite, saved) {
5019
5115
  * Everything below the review: where else to go, and what to do to the server.
5020
5116
  *
5021
5117
  * These are in the rail rather than the header because they are not about this review. The header
5022
- * answers "what am I looking at" — branch, size, whether I am done with it, and Buddy AI — and
5118
+ * answers "what am I looking at" — branch, size, where the review stands — and
5023
5119
  * anything global sitting up there competed with that. Leaving the review is navigation, and it
5024
5120
  * belongs with the other navigation.
5025
5121
  *
@@ -5529,7 +5625,7 @@ function renderAsk(view, sections, paused = false) {
5529
5625
  const pending = view.pendingAsk ?? null;
5530
5626
  const pendingTurn = pending ? `<div class="prb-msg prb-msg-user">${escapeHtml$1(pending.question ?? "")}</div>
5531
5627
  <div class="prb-thinking" data-ask-pending><i></i><i></i><i></i><span>Reading the change…</span></div>` : "";
5532
- return `<aside class="prb-ask" aria-label="Ask Buddy AI">
5628
+ return `<aside class="prb-ask" id="prb-ask" aria-label="Ask Buddy AI">
5533
5629
  <div
5534
5630
  class="prb-ask-resize"
5535
5631
  data-ask-resize
@@ -5542,6 +5638,7 @@ function renderAsk(view, sections, paused = false) {
5542
5638
  <h2>Ask Buddy AI</h2>
5543
5639
  <span class="prb-ask-scope" data-ask-scope>whole change</span>
5544
5640
  ${renderFocusToggle("buddy", "Buddy AI")}
5641
+ <button type="button" class="prb-ask-hide" data-ask-toggle aria-controls="prb-ask" aria-label="Hide Buddy AI">${ASK_HIDE_ICON}</button>
5545
5642
  </div>
5546
5643
  ${paused ? "" : renderPromptBank(view.prompts ?? [], view.promptsCollapsed === true)}
5547
5644
  <div class="prb-ask-log" data-ask-log>
@@ -5718,7 +5815,8 @@ function normalise$1(entries) {
5718
5815
  if (typeof raw.observedAt !== "number") continue;
5719
5816
  states[key] = {
5720
5817
  state: raw.state,
5721
- observedAt: raw.observedAt
5818
+ observedAt: raw.observedAt,
5819
+ ...typeof raw.changedAt === "number" ? { changedAt: raw.changedAt } : {}
5722
5820
  };
5723
5821
  }
5724
5822
  return states;
@@ -5834,6 +5932,7 @@ function normalise(entries) {
5834
5932
  if (typeof raw.polledAt === "number") entry.polledAt = raw.polledAt;
5835
5933
  if (typeof raw.observedSha === "string") entry.observedSha = raw.observedSha;
5836
5934
  if (typeof raw.observedAt === "number") entry.observedAt = raw.observedAt;
5935
+ if (typeof raw.changedAt === "number") entry.changedAt = raw.changedAt;
5837
5936
  if (typeof raw.notifiedSha === "string") entry.notifiedSha = raw.notifiedSha;
5838
5937
  if (typeof raw.notificationId === "string") entry.notificationId = raw.notificationId;
5839
5938
  if (typeof raw.failures === "number") entry.failures = raw.failures;
@@ -6172,10 +6271,12 @@ async function watchCycle(deps = defaultDeps(), options = {}) {
6172
6271
  }
6173
6272
  if (observed.kind === "unresolved") continue;
6174
6273
  if (observed.prState !== void 0 && !isBranchCandidate(candidate)) recordPrState(candidate, key, observed.sha, observed.prState, prStates, now);
6274
+ const lastSeen = state[key]?.observedSha;
6175
6275
  state[key] = {
6176
6276
  ...state[key],
6177
6277
  observedSha: observed.sha,
6178
6278
  observedAt: seenAt,
6279
+ ...lastSeen === void 0 || observed.sha === lastSeen ? {} : { changedAt: seenAt },
6179
6280
  failures: 0
6180
6281
  };
6181
6282
  if (observed.sha === candidate.analysedSha) {
@@ -6220,7 +6321,8 @@ function prunedPrStates(prStates, deps) {
6220
6321
  */
6221
6322
  function recordPrState(candidate, key, headSha, observed, prStates, now) {
6222
6323
  if (observed === null) return;
6223
- if (prStates[key]?.state === "open" && observed !== "open") {
6324
+ const previous = prStates[key]?.state;
6325
+ if (previous === "open" && observed !== "open") {
6224
6326
  const id = `pr-${observed}-${candidate.reviewId}-${headSha}`;
6225
6327
  if (createPrState({
6226
6328
  id,
@@ -6232,9 +6334,11 @@ function recordPrState(candidate, key, headSha, observed, prStates, now) {
6232
6334
  headSha
6233
6335
  }, now) === null && readNotification(id) === null) return;
6234
6336
  }
6337
+ const changedAt = previous !== void 0 && previous !== observed ? now : prStates[key]?.changedAt;
6235
6338
  prStates[key] = {
6236
6339
  state: observed,
6237
- observedAt: now
6340
+ observedAt: now,
6341
+ ...changedAt === void 0 ? {} : { changedAt }
6238
6342
  };
6239
6343
  }
6240
6344
  async function observeRequest(candidate, deps, unreachable) {
@@ -6400,6 +6504,12 @@ async function announce(candidate, key, observed, state, deps, now) {
6400
6504
  * and a review with no `completedAt` is one they have not finished. Anything outside those is a
6401
6505
  * review they are done with, and polling it would spend a request on an answer nobody wants.
6402
6506
  *
6507
+ * **Waiting and Done are watched whatever else is true of them.** Parking a review takes it out of
6508
+ * the queue and is usually done after marking it reviewed, so without this a Waiting review would
6509
+ * fall outside all three sets at the moment it starts waiting for a push, and the push that should
6510
+ * bring it back to Active would never be seen. Done is watched for "Changed since done": the reviewer
6511
+ * asked not to be interrupted, not to be kept uninformed.
6512
+ *
6403
6513
  * The one exclusion is structural rather than editorial: a commit review is of one fixed commit
6404
6514
  * that cannot move, and `freshness.ts` calls that state `fixed` for the same reason. A review with
6405
6515
  * no pull request used to be excluded too, having nothing to ask a forge about. It is watched
@@ -6407,7 +6517,7 @@ async function announce(candidate, key, observed, state, deps, now) {
6407
6517
  */
6408
6518
  function isWatchable(workspace, queued) {
6409
6519
  if (isCommitReview(workspace.changeSet)) return false;
6410
- return queued.has(workspace.id) || workspace.pinned === true || workspace.completedAt === void 0;
6520
+ return queued.has(workspace.id) || workspace.pinned === true || workspace.completedAt === void 0 || parseLifecycle(workspace.lifecycle) !== void 0;
6411
6521
  }
6412
6522
  /** The request a review is attached to, by URL where there is one and by number otherwise. */
6413
6523
  function requestOf(workspace) {
@@ -6498,6 +6608,50 @@ function prStatesByRequest(states = readPrStates()) {
6498
6608
  return new Map(Object.entries(states).map(([key, entry]) => [key, entry.state]));
6499
6609
  }
6500
6610
  /**
6611
+ * What last happened to each branch's change, by the id of its newest review: the list's order and
6612
+ * the time on each card.
6613
+ *
6614
+ * The latest of three things, each of which is the change moving rather than anybody looking at it:
6615
+ * the newest analysis being made, its head moving, and its pull request changing state. Read from
6616
+ * the two `changedAt`s, never the `observedAt`s, which every poll rewrites.
6617
+ *
6618
+ * A watch entry follows the newest analysis of its request, so it is looked for across the whole
6619
+ * history, the same way `moved` is: an entry still filed against an older analysis is the same
6620
+ * change. A head move or state change from before the newest analysis is already in it, so only a
6621
+ * later one counts.
6622
+ */
6623
+ function reviewActivity(groups, watch = readWatch(), prStates = readPrStates()) {
6624
+ const headChanges = /* @__PURE__ */ new Map();
6625
+ for (const entry of Object.values(watch)) {
6626
+ if (entry.reviewId === void 0 || entry.changedAt === void 0) continue;
6627
+ headChanges.set(entry.reviewId, Math.max(entry.changedAt, headChanges.get(entry.reviewId) ?? 0));
6628
+ }
6629
+ const activity = /* @__PURE__ */ new Map();
6630
+ for (const group of groups) {
6631
+ const { latest } = group;
6632
+ let current = {
6633
+ at: latest.createdAt,
6634
+ kind: "created"
6635
+ };
6636
+ for (const review of group.history) {
6637
+ const at = headChanges.get(review.id);
6638
+ if (at !== void 0 && at > current.at) current = {
6639
+ at,
6640
+ kind: "updated"
6641
+ };
6642
+ }
6643
+ if (latest.prNumber !== void 0) {
6644
+ const entry = prStates[requestKey(latest.repoPath, latest.prNumber)];
6645
+ if (entry?.changedAt !== void 0 && entry.changedAt > current.at) current = {
6646
+ at: entry.changedAt,
6647
+ kind: entry.state === "open" ? "reopened" : entry.state
6648
+ };
6649
+ }
6650
+ activity.set(latest.id, current);
6651
+ }
6652
+ return activity;
6653
+ }
6654
+ /**
6501
6655
  * One review's pull request state, for its page, filed under the same key the list looks up. Null
6502
6656
  * for a review with no request or one never observed: unknown, not open. Only ever what the watcher
6503
6657
  * last saw; nothing here reaches the forge.
@@ -6561,6 +6715,23 @@ function watchNow(scope, makeDeps = defaultDeps) {
6561
6715
  });
6562
6716
  }
6563
6717
  /**
6718
+ * A first look at every one of these reviews, however many there are.
6719
+ *
6720
+ * For parking a selection as Waiting. `watchNow` keeps the request budget of an ordinary cycle, so
6721
+ * handed thirty reviews it would look at twenty and leave ten to the timer, and a push to one of
6722
+ * those before its turn would be taken as its first observation and never bring it back. So the
6723
+ * selection goes through in batches of that budget, one scoped cycle after another: every review is
6724
+ * looked at, and no single cycle asks more of the forge than an ordinary one does. The whole
6725
+ * selection is the reviewer's own act, which is what bounds how many batches there can be.
6726
+ */
6727
+ async function watchAllNow(reviewIds, makeDeps = defaultDeps) {
6728
+ const ids = [...reviewIds];
6729
+ for (let start = 0; start < ids.length; start += 20) {
6730
+ const batch = new Set(ids.slice(start, start + 20));
6731
+ await watchNow((candidate) => batch.has(candidate.reviewId), makeDeps);
6732
+ }
6733
+ }
6734
+ /**
6564
6735
  * Run the pass on a timer, and return the stop.
6565
6736
  *
6566
6737
  * The same shape as `startSweeping`, and tied into `cli/server.ts` the same way, so a restart
@@ -6600,14 +6771,26 @@ var FETCH_SELECTED_LABEL = "Fetch selected";
6600
6771
  //#region ../../packages/review-harness/src/workspace/reviews_page.ts
6601
6772
  function renderReviewsPage(view) {
6602
6773
  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));
6774
+ const activityOf = (group) => view.activity?.get(group.latest.id) ?? {
6775
+ at: group.latest.createdAt,
6776
+ kind: "created"
6777
+ };
6778
+ const ordered = [...view.groups].sort((a, b) => Number(b.latest.pinned) - Number(a.latest.pinned) || activityOf(b).at - activityOf(a).at);
6779
+ const sections = {
6780
+ active: [],
6781
+ waiting: [],
6782
+ done: []
6783
+ };
6784
+ for (const group of ordered) sections[sectionOf(group.latest.lifecycle, activityOf(group))].push(group);
6785
+ const rank = new Map(ordered.map((group, index) => [group, index]));
6786
+ 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
6787
  const storedReviews = view.groups.reduce((total, group) => total + group.history.length, 0);
6605
6788
  const queue = view.queue ?? [];
6606
6789
  const positions = new Map(queue.map((id, position) => [id, position]));
6607
6790
  const continues = continueTarget(queue);
6608
6791
  const front = continues === null ? void 0 : findReview(view.groups, continues);
6609
6792
  return `<!doctype html>
6610
- <html lang="en">
6793
+ <html lang="en"${themeAttribute(view.theme)}>
6611
6794
  <head>
6612
6795
  <meta charset="utf-8">
6613
6796
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -6634,13 +6817,15 @@ ${renderWorkspaceRail({
6634
6817
  <p class="prb-index-count" data-count>${escapeHtml$1(countLabel(storedReviews, ordered.length))}</p>
6635
6818
  <button type="button" class="prb-index-select" data-select-mode aria-pressed="false">Select</button>
6636
6819
  </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("")}
6820
+ <div class="prb-cards" data-cards="active">
6821
+ ${renderCards(sections.active)}
6639
6822
  </div>
6823
+ ${renderLoadMore("active")}
6640
6824
  ${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() : ""}
6825
+ ${renderSection("waiting", "Waiting", sections.waiting, true, renderCards)}
6826
+ ${renderSection("done", "Done", sections.done, false, renderCards)}
6642
6827
  ${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>
6828
+ <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
6829
  </main>
6645
6830
  </div>
6646
6831
  <script src="/assets/reviews.js" type="module"><\/script>
@@ -6721,22 +6906,31 @@ function findReview(groups, id) {
6721
6906
  for (const group of groups) for (const review of group.history) if (review.id === id) return review;
6722
6907
  }
6723
6908
  /**
6724
- * The pager, with its numbers left out.
6909
+ * One section's Load more, which the browser shows once that section holds more than it is showing.
6725
6910
  *
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.
6911
+ * One per section rather than one pager for the page, so revealing older Done reviews never costs
6912
+ * the reviewer their place in Active. Hidden until the client has counted, so a browser with no
6913
+ * JavaScript gets every card and no control that does nothing.
6914
+ */
6915
+ function renderLoadMore(section) {
6916
+ return `<button type="button" class="prb-index-more" data-load-more="${section}" hidden>Load more</button>`;
6917
+ }
6918
+ /**
6919
+ * Waiting or Done, as a disclosure under the working list.
6730
6920
  *
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.
6921
+ * Active has no heading: it is what the page has always been, and the count line above it already
6922
+ * says what it holds. Waiting starts open, because it is work that will come back; Done starts
6923
+ * closed, because it is the archive. Rendered empty and hidden rather than left out, so a card the
6924
+ * browser moves here after a mark has somewhere to go.
6733
6925
  */
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>`;
6926
+ function renderSection(section, title, groups, open, renderCards) {
6927
+ return `<details class="prb-index-section" data-section="${section}"${open ? " open" : ""}${groups.length === 0 ? " hidden" : ""}>
6928
+ <summary class="prb-index-section-head">${title} · <span data-section-count>${groups.length}</span></summary>
6929
+ <div class="prb-cards" data-cards="${section}">
6930
+ ${renderCards(groups)}
6931
+ </div>
6932
+ ${renderLoadMore(section)}
6933
+ </details>`;
6740
6934
  }
6741
6935
  /**
6742
6936
  * The bulk bar, with its counts left out.
@@ -6761,6 +6955,7 @@ function renderBulkBar() {
6761
6955
  <button type="button" class="prb-bulk-button" data-bulk-queue disabled>Add to review queue</button>
6762
6956
  <button type="button" class="prb-bulk-button" data-bulk-mark="complete">Mark reviewed</button>
6763
6957
  <button type="button" class="prb-bulk-button" data-bulk-mark="in-progress">Mark in progress</button>
6958
+ ${LIFECYCLES.map((lifecycle) => `<button type="button" class="prb-bulk-button" data-bulk-lifecycle="${lifecycle.id}">${escapeHtml$1(lifecycle.action)}</button>`).join("\n ")}
6764
6959
  <button type="button" class="prb-bulk-button prb-bulk-button-danger" data-bulk-delete></button>
6765
6960
  <button type="button" class="prb-bulk-button prb-bulk-cancel" data-bulk-cancel>Cancel</button>
6766
6961
  </div>
@@ -6794,20 +6989,22 @@ function renderBulkBar() {
6794
6989
  * card and is shown only in selection mode, so entering that mode is a stylesheet change rather
6795
6990
  * than a rebuild of the list.
6796
6991
  */
6797
- function renderCard(group, now, analysis, queued, moved, prState) {
6992
+ function renderCard(group, now, activity, rank, analysis, queued, moved, prState) {
6798
6993
  const review = group.latest;
6799
6994
  const status = statusOf(review);
6995
+ const chip = lifecycleChip(review.lifecycle, activity);
6800
6996
  const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
6801
6997
  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))}">
6998
+ 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
6999
  <input type="checkbox" class="prb-card-select" data-select="${escapeHtml$1(review.id)}" aria-label="Select ${escapeHtml$1(followed)}">
6804
7000
  <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)}
7001
+ ${renderCardMenu(group, followed, queued !== void 0, status, activity)}
6806
7002
  <a class="prb-card-link" href="${escapeHtml$1(href)}">
6807
7003
  <div class="prb-card-head">
6808
7004
  <h2>${escapeHtml$1(review.title)}</h2>
6809
7005
  ${renderStatus(status)}
6810
7006
  ${renderPrState(prState)}
7007
+ ${`<span class="prb-card-lifecycle" data-lifecycle-chip${chip === void 0 ? " hidden" : ""}>${escapeHtml$1(chip ?? "")}</span>`}
6811
7008
  </div>
6812
7009
  ${review.summary ? `<p class="prb-card-summary">${renderInlineText(review.summary)}</p>` : ""}
6813
7010
  <p class="prb-card-where">
@@ -6824,7 +7021,7 @@ function renderCard(group, now, analysis, queued, moved, prState) {
6824
7021
  <span class="prb-card-diffstat"><span class="prb-add">+${review.additions}</span> <span class="prb-del">&minus;${review.deletions}</span></span>
6825
7022
  ${analysis !== void 0 && !review.hasStandardReview ? "" : `<span class="prb-card-left">${escapeHtml$1(whatIsLeft(review))}</span>`}
6826
7023
  ${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>
7024
+ <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
7025
  </p>
6829
7026
  </a>
6830
7027
  ${renderHistory(group, now)}
@@ -6847,9 +7044,13 @@ function renderCard(group, now, analysis, queued, moved, prState) {
6847
7044
  *
6848
7045
  * The rows underneath carry the per-review menus, and they only exist once there is more than one.
6849
7046
  */
6850
- function renderCardMenu(group, followed, queued, status) {
7047
+ function renderCardMenu(group, followed, queued, status, activity) {
6851
7048
  const count = group.history.length;
6852
7049
  const mark = status === "complete" ? "in-progress" : "complete";
7050
+ const lifecycle = {
7051
+ stored: group.latest.lifecycle,
7052
+ activity
7053
+ };
6853
7054
  if (count < 2) return renderMenu({
6854
7055
  id: group.latest.id,
6855
7056
  scope: "review",
@@ -6858,7 +7059,8 @@ function renderCardMenu(group, followed, queued, status) {
6858
7059
  question: "Delete this review?",
6859
7060
  copy: "Copy review ID",
6860
7061
  queue: queued ? "remove" : "add",
6861
- mark
7062
+ mark,
7063
+ lifecycle
6862
7064
  });
6863
7065
  const all = `all ${count} reviews following ${followed}`;
6864
7066
  return renderMenu({
@@ -6869,6 +7071,7 @@ function renderCardMenu(group, followed, queued, status) {
6869
7071
  question: `Delete ${all}?`,
6870
7072
  queue: queued ? "remove" : "add",
6871
7073
  mark,
7074
+ lifecycle,
6872
7075
  copy: "Copy latest review ID"
6873
7076
  });
6874
7077
  }
@@ -6888,12 +7091,14 @@ function renderCardMenu(group, followed, queued, status) {
6888
7091
  function renderMenu(item) {
6889
7092
  const id = escapeHtml$1(item.id);
6890
7093
  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
- 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>`;
7094
+ const queueItem = item.queue === void 0 ? "" : `<button type="button" class="prb-menu-item" data-queue-action="${item.queue}" data-queue-id="${id}">${queueItemLabel(item.queue === "remove")}</button>`;
7095
+ 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
7096
  return `<div class="prb-menu" data-menu>
6893
7097
  <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
7098
  <div class="prb-menu-panel" data-menu-panel hidden>
6895
7099
  <div data-menu-items>
6896
7100
  ${markItem}
7101
+ ${lifecycleItems}
6897
7102
  ${queueItem}
6898
7103
  <button type="button" class="prb-menu-item" data-copy-id="${id}">${escapeHtml$1(item.copy)}</button>
6899
7104
  <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 +7245,6 @@ function renderRisk(risk) {
7040
7245
  if (risk === "unknown") return "";
7041
7246
  return `<span class="prb-card-risk" data-risk="${risk}">${risk} risk</span>`;
7042
7247
  }
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
7248
  /**
7051
7249
  * Everything the search field matches, flattened into one string on the card.
7052
7250
  *
@@ -7136,6 +7334,115 @@ function escapeHtml(value) {
7136
7334
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
7137
7335
  }
7138
7336
  //#endregion
7337
+ //#region ../../packages/review-harness/src/workspace/preferences.ts
7338
+ /**
7339
+ * Workspace preferences that belong to the reviewer rather than to a review.
7340
+ *
7341
+ * Kept server-side, in their own file. The original reason was that `localStorage` is keyed by
7342
+ * origin and this server took whatever port the OS offered on each start, so a restart would
7343
+ * silently discard a width the reviewer had deliberately chosen. That reason has gone: the port is
7344
+ * fixed, so the origin holds still and `localStorage` would now work. Moving this is a change worth
7345
+ * making on its own merits rather than in passing, and there is a second reason it stays for now:
7346
+ * `config.json` next door is hand-edited, and writing to a file someone maintains by hand reformats
7347
+ * it under them the first time they save, which is why this is not simply a key in there.
7348
+ *
7349
+ * Everything here is best-effort. A workspace that will not open because a preferences file is
7350
+ * unreadable would be a preference costing more than it is worth.
7351
+ */
7352
+ var PREFERENCES_PATH = join(STORE_ROOT, "ui.json");
7353
+ var MAX_ASK_WIDTH = 1200;
7354
+ /** A key is a short identifier, never prose. Anything else in the file is not one of ours. */
7355
+ var NOTICE_KEY = /^[a-z-]+:[^\s]{1,200}$/;
7356
+ function readPreferences() {
7357
+ try {
7358
+ const parsed = JSON.parse(readFileSync(PREFERENCES_PATH, "utf8"));
7359
+ if (!parsed || typeof parsed !== "object") return {};
7360
+ const { askWidth, dismissedNotices, promptsCollapsed, changeMapRegister, theme } = parsed;
7361
+ return {
7362
+ ...typeof askWidth === "number" && Number.isFinite(askWidth) ? { askWidth: clampAskWidth(askWidth) } : {},
7363
+ ...Array.isArray(dismissedNotices) ? { dismissedNotices: dismissedNotices.filter((key) => typeof key === "string" && NOTICE_KEY.test(key)) } : {},
7364
+ ...typeof promptsCollapsed === "boolean" ? { promptsCollapsed } : {},
7365
+ ...changeMapRegister === "simple" || changeMapRegister === "technical" ? { changeMapRegister } : {},
7366
+ ...isTheme(theme) ? { theme } : {}
7367
+ };
7368
+ } catch {
7369
+ return {};
7370
+ }
7371
+ }
7372
+ function writeAskWidth(width) {
7373
+ if (!Number.isFinite(width)) return;
7374
+ try {
7375
+ mkdirSync(STORE_ROOT, { recursive: true });
7376
+ const next = {
7377
+ ...readPreferences(),
7378
+ askWidth: clampAskWidth(width)
7379
+ };
7380
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify(next, null, 2)}\n`);
7381
+ } catch {}
7382
+ }
7383
+ /** Remember whether "Your prompts" is folded. Read-modify-write, like every other field here. */
7384
+ function writePromptsCollapsed(collapsed) {
7385
+ try {
7386
+ mkdirSync(STORE_ROOT, { recursive: true });
7387
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
7388
+ ...readPreferences(),
7389
+ promptsCollapsed: collapsed
7390
+ }, null, 2)}\n`);
7391
+ } catch {}
7392
+ }
7393
+ /** Remember which words the change map is read in. Read-modify-write, like every other field here. */
7394
+ function writeChangeMapRegister(register) {
7395
+ try {
7396
+ mkdirSync(STORE_ROOT, { recursive: true });
7397
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
7398
+ ...readPreferences(),
7399
+ changeMapRegister: register
7400
+ }, null, 2)}\n`);
7401
+ } catch {}
7402
+ }
7403
+ /** Remember which palette the workspace is drawn in. Read-modify-write, like every other field here. */
7404
+ function writeTheme(theme) {
7405
+ try {
7406
+ mkdirSync(STORE_ROOT, { recursive: true });
7407
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
7408
+ ...readPreferences(),
7409
+ theme
7410
+ }, null, 2)}\n`);
7411
+ } catch {}
7412
+ }
7413
+ /** Remember that the reviewer has read a notice. Refuses anything not shaped like a key. */
7414
+ function dismissNotice(key) {
7415
+ if (!NOTICE_KEY.test(key)) return false;
7416
+ try {
7417
+ const current = readPreferences();
7418
+ const dismissedNotices = [.../* @__PURE__ */ new Set([...current.dismissedNotices ?? [], key])];
7419
+ mkdirSync(STORE_ROOT, { recursive: true });
7420
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
7421
+ ...current,
7422
+ dismissedNotices
7423
+ }, null, 2)}\n`);
7424
+ return true;
7425
+ } catch {
7426
+ return false;
7427
+ }
7428
+ }
7429
+ /** Forget a stated width, so the stylesheet's proportional default applies again. */
7430
+ function clearAskWidth() {
7431
+ try {
7432
+ const { askWidth: _dropped, ...rest } = readPreferences();
7433
+ mkdirSync(STORE_ROOT, { recursive: true });
7434
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify(rest, null, 2)}\n`);
7435
+ } catch {}
7436
+ }
7437
+ /**
7438
+ * Absolute bounds only. The upper limit the reviewer actually feels is half the viewport, which
7439
+ * the page enforces because it is the only party that knows how wide the window is — this is the
7440
+ * far cruder job of keeping a hand-edited or stale number from producing an unusable layout.
7441
+ */
7442
+ function clampAskWidth(width) {
7443
+ return Math.round(Math.min(MAX_ASK_WIDTH, Math.max(320, width)));
7444
+ }
7445
+ //#endregion
7139
7446
  //#region ../../packages/review-harness/src/workspace/settings.ts
7140
7447
  /**
7141
7448
  * The settings the workspace can show and change, as one object rather than as three files.
@@ -7167,7 +7474,8 @@ function readSettings() {
7167
7474
  },
7168
7475
  agent: readAgentPreference(),
7169
7476
  agents: AGENT_IDS,
7170
- usageCounts: telemetryUploadConsent()
7477
+ usageCounts: telemetryUploadConsent(),
7478
+ theme: readPreferences().theme ?? "system"
7171
7479
  };
7172
7480
  }
7173
7481
  /** True when what the reader is looking at is served on a port they have since changed. */
@@ -7194,10 +7502,19 @@ function saveSettings(change, minPort, maxPort) {
7194
7502
  settings: readSettings(),
7195
7503
  error: `There is no agent called \`${change.agent}\`.`
7196
7504
  };
7505
+ if (change.theme !== void 0 && !isTheme(change.theme)) {
7506
+ const labels = THEMES.map((theme) => theme.label);
7507
+ const choices = `${labels.slice(0, -1).join(", ")} or ${labels.at(-1)}`;
7508
+ return {
7509
+ settings: readSettings(),
7510
+ error: `There is no theme called \`${change.theme}\`. Choose ${choices}.`
7511
+ };
7512
+ }
7197
7513
  if (port !== null) writePortPreference(port);
7198
7514
  if (change.agent === "") clearAgentPreference();
7199
7515
  else if (change.agent !== void 0) writeAgentPreference(change.agent);
7200
7516
  if (change.usageCounts !== void 0) recordTelemetryUploadConsent(change.usageCounts);
7517
+ if (change.theme !== void 0 && isTheme(change.theme)) writeTheme(change.theme);
7201
7518
  return {
7202
7519
  settings: readSettings(),
7203
7520
  error: null
@@ -7209,7 +7526,7 @@ function renderSettingsPage(view) {
7209
7526
  const { settings } = view;
7210
7527
  const address = `http://localhost:${settings.port.configured}`;
7211
7528
  return `<!doctype html>
7212
- <html lang="en">
7529
+ <html lang="en"${themeAttribute(settings.theme)}>
7213
7530
  <head>
7214
7531
  <meta charset="utf-8">
7215
7532
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -7231,6 +7548,7 @@ ${renderWorkspaceRail({
7231
7548
  unread: view.unreadNotifications ?? 0
7232
7549
  })}
7233
7550
  <main class="prb-index prb-settings">
7551
+ ${renderAppearanceSection(settings)}
7234
7552
  ${renderWorkspaceSection(settings, address)}
7235
7553
  ${renderReviewsSection(settings)}
7236
7554
  ${renderPrivacySection(settings)}
@@ -7242,7 +7560,8 @@ ${renderWorkspaceRail({
7242
7560
 
7243
7561
  <p class="prb-index-note">These are kept in <code>~/.prreviewbuddy/config.json</code>, which you can
7244
7562
  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>
7563
+ Run <code>prreviewbuddy config path</code> to be told where that file is. Appearance is kept with
7564
+ the workspace's other display choices in <code>~/.prreviewbuddy/ui.json</code>.</p>
7246
7565
 
7247
7566
  ${renderServerSection()}
7248
7567
  </main>
@@ -7310,6 +7629,23 @@ function renderWorkspaceSection(settings, address) {
7310
7629
  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
7630
  </section>`;
7312
7631
  }
7632
+ /**
7633
+ * The theme, as three radios over the registry in theme.ts.
7634
+ *
7635
+ * Choosing one repaints this page at once (see `client/settings.ts`), so the reader can see what
7636
+ * they are picking; Save is still what keeps it, like every other field here.
7637
+ */
7638
+ function renderAppearanceSection(settings) {
7639
+ return `<section class="prb-settings-section">
7640
+ <h2>Appearance</h2>
7641
+ <div class="prb-field prb-settings-field" role="group" aria-labelledby="prb-theme-label">
7642
+ <span id="prb-theme-label">Theme</span>
7643
+ <div class="prb-settings-choice">
7644
+ ${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 ")}
7645
+ </div>
7646
+ </div>
7647
+ </section>`;
7648
+ }
7313
7649
  function renderReviewsSection(settings) {
7314
7650
  return `<section class="prb-settings-section">
7315
7651
  <h2>Reviews</h2>
@@ -7568,7 +7904,7 @@ function defaultFetchDeps() {
7568
7904
  function renderNotificationsPage(view) {
7569
7905
  const unread = view.notifications.filter((notification) => notification.readAt === void 0).length;
7570
7906
  return `<!doctype html>
7571
- <html lang="en">
7907
+ <html lang="en"${themeAttribute(view.theme)}>
7572
7908
  <head>
7573
7909
  <meta charset="utf-8">
7574
7910
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -7765,6 +8101,8 @@ function reviewsRevision(live = analysisStates()) {
7765
8101
  hash.update(`queue:${queueStamp()}\n`);
7766
8102
  for (const [id, kind] of [...movedReviews()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`moved:${id}:${kind}\n`);
7767
8103
  for (const [key, state] of [...prStatesByRequest()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`pr:${key}:${state}\n`);
8104
+ 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`);
8105
+ 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
8106
  return hash.digest("base64url");
7769
8107
  }
7770
8108
  //#endregion
@@ -8643,104 +8981,6 @@ function readConfiguredEditor() {
8643
8981
  return null;
8644
8982
  }
8645
8983
  }
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
8984
  /** Long enough for a paragraph of standing instructions, short enough not to crowd the question. */
8745
8985
  var MAX_PROMPT_CHARS = 2e3;
8746
8986
  /**
@@ -9029,6 +9269,7 @@ async function handle(req, res, options) {
9029
9269
  if (url.pathname === "/queue") return handleQueue(req, res, options.indexToken);
9030
9270
  if (url.pathname === "/delete") return handleDelete(req, res, options.indexToken);
9031
9271
  if (url.pathname === "/status") return handleStatus(req, res, options.indexToken);
9272
+ if (url.pathname === "/lifecycle") return handleLifecycle(req, res, options.indexToken, options.establishBaseline ?? establishBaseline);
9032
9273
  if (url.pathname === "/revision") return handleRevision(req, res, options.indexToken);
9033
9274
  if (url.pathname === "/fetch-updates") return handleFetchUpdates(req, res, options.indexToken);
9034
9275
  if (url.pathname === "/shutdown") return handleIndexShutdown(req, res, options);
@@ -9055,6 +9296,8 @@ async function handle(req, res, options) {
9055
9296
  if (action === "/stop") return handleStop(req, res, workspace);
9056
9297
  if (action === "/kiss") return handleKiss(req, res, workspace);
9057
9298
  if (action === "/state") return handleState(req, res, workspace);
9299
+ if (action === "/lifecycle") return handleOwnLifecycle(req, res, workspace, options.establishBaseline ?? establishBaseline);
9300
+ if (action === "/queue") return handleOwnQueue(req, res, workspace);
9058
9301
  if (action === "/delete") return handleDeleteOwn(req, res, workspace);
9059
9302
  if (action === "/event") return handleEvent(req, res, workspace);
9060
9303
  if (action === "/prefs") return handlePrefs(req, res);
@@ -9119,9 +9362,11 @@ function handleIndex(req, res, params, indexToken) {
9119
9362
  if (onward) return redirect(res, onward, cookie);
9120
9363
  const states = analysisStates();
9121
9364
  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);
9365
+ const reviews = recentWorkspaces(Number.MAX_SAFE_INTEGER);
9366
+ const kept = indexKeptIds(reviews, queue);
9367
+ const everyGroup = groupByLineage(reviews);
9368
+ const activity = reviewActivity(everyGroup);
9369
+ const groups = indexReviewGroups(MAX_LISTED_BRANCHES, MAX_KEPT_BRANCHES, kept, (group) => activity.get(group.latest.id)?.at ?? group.latest.createdAt, everyGroup);
9125
9370
  const rendered = new Set(groups.flatMap((group) => group.history.map((review) => review.id)));
9126
9371
  sendHtml(res, 200, renderReviewsPage({
9127
9372
  groups,
@@ -9129,9 +9374,11 @@ function handleIndex(req, res, params, indexToken) {
9129
9374
  analyses: states,
9130
9375
  moved: movedReviews(),
9131
9376
  prStates: prStatesByRequest(),
9377
+ activity,
9132
9378
  revision: reviewsRevision(states),
9133
9379
  unreadNotifications: unreadCount$1(),
9134
- driving: isDriving()
9380
+ driving: isDriving(),
9381
+ theme: readPreferences().theme
9135
9382
  }), cookie);
9136
9383
  }
9137
9384
  /**
@@ -9203,7 +9450,8 @@ async function handleSettings(req, res, params, indexToken) {
9203
9450
  const { settings, error } = saveSettings({
9204
9451
  ...typeof body?.port === "string" ? { port: body.port } : {},
9205
9452
  ...typeof body?.agent === "string" ? { agent: body.agent } : {},
9206
- ...typeof body?.usageCounts === "boolean" ? { usageCounts: body.usageCounts } : {}
9453
+ ...typeof body?.usageCounts === "boolean" ? { usageCounts: body.usageCounts } : {},
9454
+ ...typeof body?.theme === "string" ? { theme: body.theme } : {}
9207
9455
  }, MIN_WORKSPACE_PORT, MAX_WORKSPACE_PORT);
9208
9456
  return send(res, 200, {
9209
9457
  port: {
@@ -9212,6 +9460,7 @@ async function handleSettings(req, res, params, indexToken) {
9212
9460
  },
9213
9461
  agent: settings.agent,
9214
9462
  usageCounts: settings.usageCounts,
9463
+ theme: settings.theme,
9215
9464
  restartRequired: restartRequired(settings),
9216
9465
  error
9217
9466
  });
@@ -9303,7 +9552,8 @@ async function handleNotifications(req, res, params, indexToken) {
9303
9552
  notifications,
9304
9553
  gone: new Set(notifications.filter((entry) => !loadWorkspace(entry.reviewId)).map((entry) => entry.id)),
9305
9554
  unreadNotifications: unreadCount$1(),
9306
- driving: isDriving()
9555
+ driving: isDriving(),
9556
+ theme: readPreferences().theme
9307
9557
  }), indexCookieHeader(indexToken));
9308
9558
  }
9309
9559
  /**
@@ -9354,7 +9604,6 @@ async function handleStatus(req, res, indexToken) {
9354
9604
  current.completedAt = complete ? now : void 0;
9355
9605
  if (!complete) current.lastViewedAt = current.lastViewedAt ?? now;
9356
9606
  })) continue;
9357
- dequeueOnComplete(id, complete);
9358
9607
  changed += 1;
9359
9608
  }
9360
9609
  send(res, 200, {
@@ -9363,6 +9612,156 @@ async function handleStatus(req, res, indexToken) {
9363
9612
  });
9364
9613
  }
9365
9614
  /**
9615
+ * Put a selection in Active, Waiting or Done.
9616
+ *
9617
+ * The same shape and the same credential as `handleStatus` beside it, for its reasons: this is the
9618
+ * list acting on itself, and a lifecycle is a note about what happens next, not a change to the
9619
+ * analysis, so nothing in flight is a reason to refuse it.
9620
+ *
9621
+ * **A state that is stated and unknown is refused whole**, naming it and what would have worked.
9622
+ * Guessing (reading `archived` as Done, say) would put cards where the reviewer did not ask for
9623
+ * them, and skipping it would answer 200 to a request that did nothing.
9624
+ *
9625
+ * Waiting and Done both leave the queue: it holds work somebody means to do next, and either mark
9626
+ * says there is none. Active does not put anything back in, as un-marking a review does not.
9627
+ *
9628
+ * Marking Waiting again writes a new `at`, which is what settles a Waiting review that changed: the
9629
+ * reviewer has seen the change and parked it again.
9630
+ */
9631
+ async function handleLifecycle(req, res, indexToken, baseline) {
9632
+ if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
9633
+ const body = await readJson(req);
9634
+ const state = body?.state;
9635
+ if (!isLifecycle(state)) {
9636
+ const choices = LIFECYCLES.map((lifecycle) => lifecycle.id).join(", ");
9637
+ return send(res, 400, { error: `There is no lifecycle state called \`${String(state)}\`. Choose one of ${choices}.` });
9638
+ }
9639
+ const ids = Array.isArray(body?.ids) ? body.ids.filter((id) => typeof id === "string") : [];
9640
+ const { changed, at } = applyLifecycle(ids, state);
9641
+ send(res, 200, {
9642
+ ok: true,
9643
+ changed,
9644
+ at,
9645
+ queue: readQueue()
9646
+ });
9647
+ baselineParked(ids, state, baseline);
9648
+ }
9649
+ /**
9650
+ * The write behind every lifecycle mark, from the list or from the review's own page. Waiting and
9651
+ * Done dequeue; see `handleLifecycle`.
9652
+ */
9653
+ function applyLifecycle(ids, state) {
9654
+ const at = Date.now();
9655
+ let changed = 0;
9656
+ for (const id of ids) {
9657
+ if (!mutateWorkspace(id, (current) => {
9658
+ current.lifecycle = state === "active" ? void 0 : {
9659
+ state,
9660
+ at
9661
+ };
9662
+ })) continue;
9663
+ if (state !== "active") queueRemove(id);
9664
+ changed += 1;
9665
+ }
9666
+ return {
9667
+ changed,
9668
+ at
9669
+ };
9670
+ }
9671
+ /** After the answer is sent, so a first look at the forge never holds up the page that asked. */
9672
+ function baselineParked(ids, state, baseline) {
9673
+ if (state !== "waiting") return;
9674
+ const unseen = unobserved(ids);
9675
+ if (unseen.size > 0) baseline(unseen);
9676
+ }
9677
+ /**
9678
+ * Lifecycle and queue, asked by the review's own page on its own token.
9679
+ *
9680
+ * Both belong to the branch, and the list only ever reads them from the newest review of it, so a
9681
+ * mark written on an older one would be a write nobody sees. The page offers neither there; this
9682
+ * says why to anything that asks anyway.
9683
+ */
9684
+ function refuseUnlessLatest(res, workspace) {
9685
+ const { position, total } = lineagePosition(workspace);
9686
+ if (position >= total) return false;
9687
+ send(res, 409, { error: "A newer review of this branch exists. Waiting, Done and the review queue are set on the newest one." });
9688
+ return true;
9689
+ }
9690
+ /**
9691
+ * What the Reviews list would say about this branch: the newest analysis's creation, a push the
9692
+ * watcher saw, or a change of pull request state. Reads every record, so it is asked only where
9693
+ * it can change what is drawn.
9694
+ */
9695
+ function branchActivity(workspace, all = recentWorkspaces(Number.MAX_SAFE_INTEGER)) {
9696
+ const group = groupByLineage(all).find((candidate) => candidate.history.some((review) => review.id === workspace.id));
9697
+ return group ? reviewActivity([group]).get(group.latest.id) : void 0;
9698
+ }
9699
+ /**
9700
+ * Where the review stands, for the page's own checks to repaint from. Another tab can mark or park
9701
+ * it, and the watcher can see the branch move while the page is open.
9702
+ *
9703
+ * Activity only for a parked review: it decides nothing for an Active one, and it costs a read of
9704
+ * every record (measured 27 ms warm, 525 ms cold, on 66 reviews), once a minute per open tab.
9705
+ */
9706
+ function pageStanding(workspace) {
9707
+ const lifecycle = parseLifecycle(workspace.lifecycle) ?? null;
9708
+ return {
9709
+ complete: workspace.completedAt !== void 0,
9710
+ lifecycle,
9711
+ queued: readQueue().includes(workspace.id),
9712
+ activity: lifecycle ? branchActivity(workspace) ?? null : null
9713
+ };
9714
+ }
9715
+ /** Where this review stands after a lifecycle or queue change, which is everything the page repaints. */
9716
+ function ownStanding(id) {
9717
+ return {
9718
+ lifecycle: parseLifecycle(loadWorkspace(id)?.lifecycle) ?? null,
9719
+ queued: readQueue().includes(id)
9720
+ };
9721
+ }
9722
+ async function handleOwnLifecycle(req, res, workspace, baseline) {
9723
+ const state = (await readJson(req))?.state;
9724
+ if (!isLifecycle(state)) {
9725
+ const choices = LIFECYCLES.map((lifecycle) => lifecycle.id).join(", ");
9726
+ return send(res, 400, { error: `There is no lifecycle state called \`${String(state)}\`. Choose one of ${choices}.` });
9727
+ }
9728
+ if (refuseUnlessLatest(res, workspace)) return;
9729
+ applyLifecycle([workspace.id], state);
9730
+ send(res, 200, {
9731
+ ok: true,
9732
+ ...ownStanding(workspace.id)
9733
+ });
9734
+ baselineParked([workspace.id], state, baseline);
9735
+ }
9736
+ async function handleOwnQueue(req, res, workspace) {
9737
+ const action = (await readJson(req))?.action;
9738
+ if (action !== "add" && action !== "remove") return send(res, 400, { error: "Unknown queue action." });
9739
+ if (refuseUnlessLatest(res, workspace)) return;
9740
+ const stored = applyQueue(workspace.id, action);
9741
+ if (stored === null) return send(res, 500, { error: "The queue could not be saved." });
9742
+ if (action === "add" && !stored.includes(workspace.id)) return send(res, 409, { error: `The review queue is full. It holds 50 reviews at most.` });
9743
+ send(res, 200, ownStanding(workspace.id));
9744
+ }
9745
+ /**
9746
+ * Which of these reviews the watcher has never seen the head of.
9747
+ *
9748
+ * Those are the ones Waiting cannot yet work for: a first observation is not a change (it says
9749
+ * nothing about when the head moved), so a push before it would be absorbed as the baseline. A
9750
+ * review already observed has its baseline, and polling it again now would only spend a request.
9751
+ */
9752
+ function unobserved(ids) {
9753
+ const seen = new Set(Object.values(readWatch()).filter((entry) => entry.observedSha !== void 0).map((entry) => entry.reviewId));
9754
+ return new Set(ids.filter((id) => !seen.has(id)));
9755
+ }
9756
+ /**
9757
+ * The first look itself, by the watcher's own rules. A review that cannot be observed (the forge is
9758
+ * unreachable, or it is a commit review with nothing to watch) stays Waiting with no baseline, and
9759
+ * no change is invented for it: the next successful poll sets one.
9760
+ */
9761
+ function establishBaseline(reviewIds) {
9762
+ watchAllNow(reviewIds).catch(() => {});
9763
+ }
9764
+ /**
9366
9765
  * Has anything the reviews index draws changed, and is anything still being made.
9367
9766
  *
9368
9767
  * The whole of the index's liveness, in one request that costs the same whether the machine holds
@@ -9454,10 +9853,17 @@ async function handleQueue(req, res, indexToken) {
9454
9853
  return addMany(res, body.ids.filter((one) => typeof one === "string"));
9455
9854
  }
9456
9855
  if (!id || !loadWorkspace(id)) return send(res, 404, { error: "No such review" });
9856
+ const stored = applyQueue(id, action);
9857
+ if (stored === null) return send(res, 500, { error: "The queue could not be saved." });
9858
+ send(res, 200, { queue: stored });
9859
+ }
9860
+ /** One review in or out of the queue, from a card or from its own page. The queue as stored, or null. */
9861
+ function applyQueue(id, action) {
9457
9862
  if (action === "add") compactQueue(readQueue());
9458
9863
  const stored = action === "add" ? queueAdd(id) : queueRemove(id);
9459
- if (stored === null) return send(res, 500, { error: "The queue could not be saved." });
9460
- send(res, 200, { queue: compactQueue(stored) });
9864
+ if (stored === null) return null;
9865
+ if (action === "add") activateQueued([id], stored);
9866
+ return compactQueue(stored);
9461
9867
  }
9462
9868
  /**
9463
9869
  * Adding a selection, and saying which of it did not go in.
@@ -9473,6 +9879,7 @@ function addMany(res, ids) {
9473
9879
  compactQueue(readQueue());
9474
9880
  const outcome = queueAddMany(known);
9475
9881
  if (outcome === null) return send(res, 500, { error: "The queue could not be saved." });
9882
+ activateQueued(known, outcome.queue);
9476
9883
  const refused = [...outcome.full.map((one) => ({
9477
9884
  id: one,
9478
9885
  error: `The review queue is full. It holds 50 reviews at most.`
@@ -9698,6 +10105,7 @@ async function sendWorkspacePage(res, workspace) {
9698
10105
  if (notice) mutateWorkspace(workspace.id, (current) => {
9699
10106
  if (current.lastUpdate?.at === notice.at) current.lastUpdate.acknowledged = true;
9700
10107
  });
10108
+ const everyReview = recentWorkspaces(Number.MAX_SAFE_INTEGER);
9701
10109
  const html = renderWorkspace({
9702
10110
  session: workspace.session,
9703
10111
  changeSet: workspace.changeSet,
@@ -9709,6 +10117,7 @@ async function sendWorkspacePage(res, workspace) {
9709
10117
  editor: editorChoice(),
9710
10118
  repoPath: workspace.repoPath,
9711
10119
  askWidth: readPreferences().askWidth,
10120
+ theme: readPreferences().theme,
9712
10121
  dismissedNotices: readPreferences().dismissedNotices,
9713
10122
  promptsCollapsed: readPreferences().promptsCollapsed === true,
9714
10123
  changeMapRegister: readPreferences().changeMapRegister ?? "simple",
@@ -9735,7 +10144,10 @@ async function sendWorkspacePage(res, workspace) {
9735
10144
  changeMap: workspace.changeMap,
9736
10145
  reviewedSha: reviewedCommit(workspace),
9737
10146
  revision: workspaceRevision(workspace),
9738
- lineage: lineagePosition(workspace),
10147
+ lineage: lineagePosition(workspace, everyReview),
10148
+ lifecycle: parseLifecycle(workspace.lifecycle),
10149
+ activity: branchActivity(workspace, everyReview),
10150
+ queued: readQueue().includes(workspace.id),
9739
10151
  job: workspace.jobId ? loadJob(workspace.jobId) : void 0,
9740
10152
  updateJob: liveUpdateFor(workspace.id),
9741
10153
  kissRun,
@@ -9855,7 +10267,8 @@ async function handleFreshness(res, workspace, quiet) {
9855
10267
  prState,
9856
10268
  kiss,
9857
10269
  kissDismissedSha: workspace.dismissedKissFreshnessSha ?? null,
9858
- revision
10270
+ revision,
10271
+ standing: pageStanding(workspace)
9859
10272
  });
9860
10273
  if (!quiet) record({
9861
10274
  event: "freshness_checked",
@@ -9872,7 +10285,8 @@ async function handleFreshness(res, workspace, quiet) {
9872
10285
  dismissedSha: workspace.dismissedFreshnessSha ?? null,
9873
10286
  kiss,
9874
10287
  kissDismissedSha: workspace.dismissedKissFreshnessSha ?? null,
9875
- revision
10288
+ revision,
10289
+ standing: pageStanding(workspace)
9876
10290
  });
9877
10291
  }
9878
10292
  /**
@@ -10330,7 +10744,8 @@ async function handleState(req, res, workspace) {
10330
10744
  send(res, 200, {
10331
10745
  ok: true,
10332
10746
  completedAt: saved.completedAt ?? null,
10333
- pinned: saved.pinned === true
10747
+ pinned: saved.pinned === true,
10748
+ queued: readQueue().includes(saved.id)
10334
10749
  });
10335
10750
  }
10336
10751
  /** What `/state` can change. A body naming none of these writes nothing. */
@@ -10348,10 +10763,7 @@ function applyState(workspace, body) {
10348
10763
  if (Array.isArray(body?.dismissedIssues)) reviewer.dismissedIssues = body.dismissedIssues.filter((entry) => typeof entry?.id === "string" && typeof entry?.sessionId === "string");
10349
10764
  if (typeof body?.dismissFreshnessSha === "string" || body?.dismissFreshnessSha === null) workspace.dismissedFreshnessSha = body.dismissFreshnessSha ?? void 0;
10350
10765
  if (typeof body?.dismissKissFreshnessSha === "string" || body?.dismissKissFreshnessSha === null) workspace.dismissedKissFreshnessSha = body.dismissKissFreshnessSha ?? void 0;
10351
- if (typeof body?.complete === "boolean") {
10352
- workspace.completedAt = body.complete ? Date.now() : void 0;
10353
- dequeueOnComplete(workspace.id, body.complete);
10354
- }
10766
+ if (typeof body?.complete === "boolean") workspace.completedAt = body.complete ? Date.now() : void 0;
10355
10767
  if (typeof body?.pinned === "boolean") workspace.pinned = body.pinned;
10356
10768
  }
10357
10769
  /** 1 MB is far more than any question or id list, and far less than a memory problem. */