prreviewbuddy 0.81.0 → 0.82.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as MIN_WORKSPACE_PORT, $i as agentIdentityOf, $n as RUN_RECORD_RETENTION_MS, $r as readQueue, $t as markRead, A as batchIdentity, Ai as WITHHELD_REASON, An as escapeHtml$1, Ar as presentedKissRunOf, At as loadArchitecture, Bi as git, Bn as readMarker, Br as activityAttributes, Bt as ancestorsOf, C as RUN_KINDS, Ci as sourcePhrase, Cn as UNESTABLISHED_MOTIVATION, Cr as kissDecisionsOf, Ct as mapRuntimes, D as reportRun, Di as extractJsonText, Dn as renderMarkdown, Dt as formatDuration, E as endRun, Ei as asString, En as GITHUB_MARKDOWN, Er as loadWorkspace, Et as describeRuntimes, F as activateQueued, Fi as isCommitReview, Fn as withReviewLock, Ft as storedScopes, G as serverEntryExists, Gi as clearAgentPreference, Gn as findExistingReview, Gr as lifecyclePill, Gt as subjectOf, Hi as resolveAgent, Hn as requestJobStop, Hr as isLifecycle, Ht as writeJsonAtomically, I as compactQueue, Ii as resolveBase, In as MANAGED_ROOT, Ir as touchWorkspace, It as trackedFiles, Jn as followedBranch, Jr as sectionOf, Jt as createPrState, K as spawnServer, Ki as readAgentPreference, Kn as checkCodeFreshness, Kr as offersLifecycle, Kt as categoryOf, Li as AmbiguousRefError, Ln as createWorktree, Lr as workspaceRevision, Lt as ownersOf, M as parsePullRequestLink, Mi as resolveTarget, Mr as recentWorkspaces, Mt as readArchitecture, N as unsupportedReason, Ni as originRemote, Nn as ReviewBeingDeletedError, Nr as reviewedCommit, Nt as repositoryKey, O as stopRun, Oi as objectWithAnyOf, On as CODE_BLOCK_CLASS, Or as mutateWorkspace, Ot as ARCHITECTURE_DIR, P as runBatch, Pi as DetachedHeadError, Pn as isBusy, Pt as storeArchitecture, Q as MAX_WORKSPACE_PORT, Qi as agentFor$1, Qn as fillFileUrlTemplate, Qr as queueStamp, Qt as markAllRead, R as settleQueue, Ri as shortNameOf, Rn as holdWorktree, Rr as workspaceStamps, Rt as QUOTE_LIMITS, S as track, Si as reassessIn, Sn as chatConversationSection, Sr as kissDecisionRefusal, St as waitingHistory, T as completeRun, Ti as followUpFindingId, Tn as hasAuthorContent, Tr as lineagePosition, Tt as RUNTIME_WORDS, U as readServerRecord, Ui as resolveModel, Ur as lifecycleChip, Ut as announceJob, V as clearServerRecordOwnedBy, Vi as refExists, Vn as removeWorktree, Vr as activityLabel, Vt as isArchitectureId, Wi as agentIdOf, Wr as lifecycleItemLabel, Wt as branchOf, X as writeServerRecord, Xi as AGENT_IDS, Xn as fetchHead, Xr as queueAddMany, Xt as eventName, Yi as STORE_ROOT, Yn as reviewedShaOf, Yr as queueAdd, Yt as createPrUpdate, Z as DEFAULT_WORKSPACE_PORT, Zi as agentById, Zn as forgeResolver, Zr as queueRemove, Zt as listNotifications, _i as findingAssessment, _n as unforeseenFailure, _r as decideKissRecommendation, _t as telemetryUploadConsent, a as wasBlocked, aa as AgentUnavailableError, ai as resolvedByConversation, an as removeNotification, ar as loadJob, at as observeReview, b as startKissJob, bi as isGradeFor, bn as agentEnvOf, br as indexKeptIds, c as RefreshUnavailableError, ci as recommendationsOf, cn as unreadAround, ct as reanalyseReview, d as mergeUpdateInto, di as isExplanationAction, dn as existingPaths, dr as phasesFor, ea as detectAgents, ei as doneVerb, en as markReadForReview, er as allJobs, et as parseWorkspacePort, fi as withExplanation, fn as ASK_REREAD_CEILING_MS, fr as progressSteps, ft as readEvents, g as liveUpdateFor, gi as currentGradeIn, gn as storedConversation, gt as recordTelemetryUploadConsent, h as UpdateAlreadyRunningError, hi as assessmentLookup, hn as saveRefresh, hr as isClaimed, i as liveJobsFor, ia as AgentCancelledError, ii as questionsOutstanding, in as readStateOf, ir as isTerminal, it as failureOf$1, j as extractPullRequestLinks, ji as kissOutcome, jn as renderInlineText, jr as previousKissResult, jt as partFiles, k as relativeTime$1, ki as describeAuthorship, kn as CODE_COPY_CLASS, kt as checkArchitecture, l as cancelUpdate, li as explanationActionOf, ln as unreadCount$1, lr as isLiveJob, lt as runJob, m as askCheckout, mi as analysisRefOf, mn as prepareConversationWithin, n as deleteReview, na as parkedReason, ni as isQuestionOutstanding, nn as pruneNotifications, nr as endedAt, nt as statedWorkspacePort, o as discardJob, oi as reviewerDispositions, on as retireNotification, or as pruneJobResidue, ot as prepareReview, p as updateReview, pi as ANALYSIS_ONLY, pn as adoptLegacyBaseline, pr as purposeOf, pt as record, q as stopChild, qi as writeAgentPreference, qn as checkReview, qr as parseLifecycle, qt as createArchitectureNotification, r as lineageIds, ra as runnableAgents, ri as issuesOutstanding, rn as readNotification, rr as fail$1, rt as writePortPreference, s as FETCH_TIMEOUT_MS, si as processDiscussion, sn as toneOf, sr as saveJob, st as settleReview, t as deleteLineage, ta as isAgentId, ti as isIssueOutstanding, tn as moveIds, tr as attemptIdOf, tt as resolveWorkspacePort, u as cancellableHere, ui as explanationOf, un as withUsageRecorded, ur as isTerminalJobPhase, v as kissAgentId, vi as findingIn, vn as refreshPrContext, vr as followedRefName, w as beginRun, wi as wasReassessed, wn as authorPreview, wr as latestKissRun, wt as recordRuntimeBestEffort, x as isDriving, xi as promptGrade, xn as EXPLAIN_SIMPLY_PROMPT, xr as indexReviewGroups, xt as feedbackUrl, yi as gradeScale, yn as isUnchanged, yr as groupByLineage, yt as BUILD_VERSION, z as readIndexToken, zi as shorthandOf, zn as isWorktreeHeld, zr as LIFECYCLES, zt as REPOSITORY_SCOPE } from "./delete_review-CFVFygqb.js";
1
+ import { $ as MIN_WORKSPACE_PORT, $i as resolveModel, $n as RUN_RECORD_RETENTION_MS, $r as deleteCollection, $t as markRead, A as batchIdentity, Ai as isGradeFor, An as escapeHtml$1, At as loadArchitecture, Bi as WITHHELD_REASON, Bn as readMarker, Br as LIFECYCLES, Bt as ancestorsOf, C as RUN_KINDS, Ci as ANALYSIS_ONLY, Cn as UNESTABLISHED_MOTIVATION, Cr as kissDecisionsOf, Ct as mapRuntimes, D as reportRun, Di as findingAssessment, Dn as renderMarkdown, Dr as loadWorkspace, Dt as formatDuration, E as endRun, Ei as currentGradeIn, En as GITHUB_MARKDOWN, Er as lineagePosition, Et as describeRuntimes, F as activateQueued, Fi as followUpFindingId, Fn as withReviewLock, Ft as storedScopes, G as serverEntryExists, Gi as isCommitReview, Gn as findExistingReview, Gr as lifecycleItemLabel, Gt as subjectOf, Hi as resolveTarget, Hn as requestJobStop, Hr as activityLabel, Ht as writeJsonAtomically, I as compactQueue, Ii as asString, In as MANAGED_ROOT, Ir as summarise, It as trackedFiles, Ji as shortNameOf, Jn as followedBranch, Jr as parseLifecycle, Jt as createPrState, K as spawnServer, Ki as resolveBase, Kn as checkCodeFreshness, Kr as lifecyclePill, Kt as categoryOf, Li as extractJsonText, Ln as createWorktree, Lr as touchWorkspace, Lt as ownersOf, M as parsePullRequestLink, Mi as reassessIn, Mr as previousKissResult, Mt as readArchitecture, N as unsupportedReason, Ni as sourcePhrase, Nn as ReviewBeingDeletedError, Nr as recentWorkspaces, Nt as repositoryKey, O as stopRun, Oi as findingIn, On as CODE_BLOCK_CLASS, Ot as ARCHITECTURE_DIR, P as runBatch, Pi as wasReassessed, Pn as isBusy, Pr as reviewedCommit, Pt as storeArchitecture, Q as MAX_WORKSPACE_PORT, Qi as resolveAgent, Qn as fillFileUrlTemplate, Qr as createWithMembers, Qt as markAllRead, R as settleQueue, Ri as objectWithAnyOf, Rn as holdWorktree, Rr as workspaceRevision, Rt as QUOTE_LIMITS, S as track, Si as withExplanation, Sn as chatConversationSection, Sr as kissDecisionRefusal, St as waitingHistory, T as completeRun, Ti as assessmentLookup, Tn as hasAuthorContent, Tr as lineageKey, Tt as RUNTIME_WORDS, U as readServerRecord, Ui as originRemote, Ur as isLifecycle, Ut as announceJob, V as clearServerRecordOwnedBy, Vi as kissOutcome, Vn as removeWorktree, Vr as activityAttributes, Vt as isArchitectureId, Wi as DetachedHeadError, Wr as lifecycleChip, Wt as branchOf, X as writeServerRecord, Xi as git, Xn as fetchHead, Xr as collectionsStamp, Xt as eventName, Yi as shorthandOf, Yn as reviewedShaOf, Yr as sectionOf, Yt as createPrUpdate, Z as DEFAULT_WORKSPACE_PORT, Zi as refExists, Zn as forgeResolver, Zr as createCollection, Zt as listNotifications, _i as processDiscussion, _n as unforeseenFailure, _r as decideKissRecommendation, _t as telemetryUploadConsent, a as wasBlocked, aa as STORE_ROOT, ai as queueAdd, an as removeNotification, ar as loadJob, at as observeReview, b as startKissJob, bi as explanationOf, bn as agentEnvOf, br as indexKeptIds, c as RefreshUnavailableError, ca as agentFor$1, ci as queueStamp, cn as unreadAround, ct as reanalyseReview, d as mergeUpdateInto, da as isAgentId, di as isIssueOutstanding, dn as existingPaths, dr as phasesFor, ea as agentIdOf, ei as nameProblemText, en as markReadForReview, er as allJobs, et as parseWorkspacePort, fa as parkedReason, fi as isQuestionOutstanding, fn as ASK_REREAD_CEILING_MS, fr as progressSteps, ft as readEvents, g as liveUpdateFor, gi as reviewerDispositions, gn as storedConversation, gt as recordTelemetryUploadConsent, h as UpdateAlreadyRunningError, ha as AgentUnavailableError, hi as resolvedByConversation, hn as saveRefresh, hr as isClaimed, i as liveJobsFor, ii as updateCollections, in as readStateOf, ir as isTerminal, it as failureOf$1, j as extractPullRequestLinks, ji as promptGrade, jn as renderInlineText, jr as presentedKissRunOf, jt as partFiles, k as relativeTime$1, ki as gradeScale, kn as CODE_COPY_CLASS, kr as mutateWorkspace, kt as checkArchitecture, l as cancelUpdate, la as agentIdentityOf, li as readQueue, ln as unreadCount$1, lr as isLiveJob, lt as runJob, m as askCheckout, ma as AgentCancelledError, mi as questionsOutstanding, mn as prepareConversationWithin, n as deleteReview, na as readAgentPreference, ni as renameCollection, nn as pruneNotifications, nr as endedAt, nt as statedWorkspacePort, o as discardJob, oa as AGENT_IDS, oi as queueAddMany, on as retireNotification, or as pruneJobResidue, ot as prepareReview, p as updateReview, pa as runnableAgents, pi as issuesOutstanding, pn as adoptLegacyBaseline, pr as purposeOf, pt as record, q as stopChild, qi as AmbiguousRefError, qn as checkReview, qr as offersLifecycle, qt as createArchitectureNotification, r as lineageIds, ra as writeAgentPreference, ri as setMembership, rn as readNotification, rr as fail$1, rt as writePortPreference, s as FETCH_TIMEOUT_MS, sa as agentById, si as queueRemove, sn as toneOf, sr as saveJob, st as settleReview, t as deleteLineage, ta as clearAgentPreference, ti as readCollections, tn as moveIds, tr as attemptIdOf, tt as resolveWorkspacePort, u as cancellableHere, ua as detectAgents, ui as doneVerb, un as withUsageRecorded, ur as isTerminalJobPhase, v as kissAgentId, vi as recommendationsOf, vn as refreshPrContext, vr as followedRefName, w as beginRun, wi as analysisRefOf, wn as authorPreview, wr as latestKissRun, wt as recordRuntimeBestEffort, x as isDriving, xi as isExplanationAction, xn as EXPLAIN_SIMPLY_PROMPT, xr as indexReviewGroups, xt as feedbackUrl, yi as explanationActionOf, yn as isUnchanged, yr as groupByLineage, yt as BUILD_VERSION, z as readIndexToken, zi as describeAuthorship, zn as isWorktreeHeld, zr as workspaceStamps, zt as REPOSITORY_SCOPE } from "./delete_review-BED4mPxB.js";
2
2
  import { basename, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { execFile } from "node:child_process";
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -282,15 +282,37 @@ var INDEX_SECTION_NAMES = {
282
282
  };
283
283
  /**
284
284
  * Whether a section starts open. The reviewer's choice wins, and otherwise everything is open but
285
- * Done, which is the archive. The one place that default is written.
285
+ * the archives: Done, and each collection's completed reviews. A new collection starts open. The
286
+ * one place that default is written.
286
287
  */
287
288
  function sectionOpen(section, collapsed) {
288
- return !(collapsed?.[section] ?? section === "done");
289
+ return !(collapsed?.[section] ?? (section === "done" || section.startsWith("collection-done:")));
289
290
  }
290
291
  /** A section the list draws a heading for, and so one whose fold can be stored. */
291
292
  function isListSection(value) {
292
293
  return typeof value === "string" && LIST_SECTIONS.includes(value);
293
294
  }
295
+ /** A fold the store accepts: a list section, or a collection's, by an id shaped like one. */
296
+ function isFoldKey(value) {
297
+ if (isListSection(value)) return true;
298
+ if (typeof value !== "string") return false;
299
+ const match = /^collection(?:-done)?:(.*)$/.exec(value);
300
+ return match !== null && isCollectionId(match[1]);
301
+ }
302
+ function collectionFold(id) {
303
+ return `collection:${id}`;
304
+ }
305
+ function completedFold(id) {
306
+ return `collection-done:${id}`;
307
+ }
308
+ var COLLECTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
309
+ /**
310
+ * Whether a value is shaped like a collection's id. Here, in the module both halves share, because
311
+ * the hash and the fold store check it in the browser and on the server alike.
312
+ */
313
+ function isCollectionId(value) {
314
+ return typeof value === "string" && COLLECTION_ID.test(value);
315
+ }
294
316
  /**
295
317
  * Which section a card is drawn in: Pinned when pinned, and otherwise where its lifecycle says.
296
318
  *
@@ -302,6 +324,69 @@ function indexSectionOf(pinned, lifecycle, activity, filter) {
302
324
  return pinned && !ordersExplicitly(filter) ? "pinned" : sectionOf(lifecycle, activity);
303
325
  }
304
326
  /**
327
+ * Where a review's own card is drawn, or absent when it is drawn only inside its collections.
328
+ *
329
+ * Pinned first and exclusive, as it always was: a pinned review is drawn in Pinned and nowhere else,
330
+ * collected or not, and unpinning it puts it back where the rest of these rules say. A review in a
331
+ * collection is drawn there instead of in Active, Waiting or Done, so those three list what nobody
332
+ * has grouped. Under the Queue filter neither applies: the list is the queue in its order, each card
333
+ * in its lifecycle section, the same reason `indexSectionOf` gives for Pinned.
334
+ */
335
+ function homeSection(pinned, lifecycle, activity, collected, filter) {
336
+ if (collected && !pinned && !ordersExplicitly(filter)) return void 0;
337
+ return indexSectionOf(pinned, lifecycle, activity, filter);
338
+ }
339
+ /**
340
+ * Where a review is drawn inside each collection it belongs to: with the collection's reviews still
341
+ * needing attention, under its completed reviews, or not at all.
342
+ *
343
+ * Completed means Done, by the same derivation that places the card everywhere else, so Waiting is
344
+ * still in play and the Reviewed mark has nothing to do with it. One state for every collection the
345
+ * review is in: marking it Done moves it in all of them at once. Not drawn while pinned (Pinned is
346
+ * exclusive) or under the Queue filter (see `homeSection`).
347
+ */
348
+ function collectionPlacement(pinned, lifecycle, activity, filter) {
349
+ if (pinned || ordersExplicitly(filter)) return void 0;
350
+ return sectionOf(lifecycle, activity) === "done" ? "completed" : "active";
351
+ }
352
+ /** A collection heading's count: what still needs attention, and the members drawn in Pinned. */
353
+ function collectionCountLabel(active, pinned) {
354
+ const label = `${active} active`;
355
+ return pinned === 0 ? label : `${label} · ${pinned} pinned`;
356
+ }
357
+ /**
358
+ * The quiet line on a card naming its collections, or absent when it would say nothing new.
359
+ *
360
+ * Inside a collection the heading already names that one, so the line says only where else the
361
+ * review is ("Also in Release 0.82"), and nothing for a review in one collection. Outside every
362
+ * collection heading, which is Pinned and the Queue filter, it names them all.
363
+ */
364
+ function collectionsLine(memberOf, collections, current) {
365
+ const names = collections.filter((collection) => collection.id !== current && memberOf.includes(collection.id)).map((collection) => collection.name);
366
+ if (names.length === 0) return void 0;
367
+ return `${current === void 0 ? "In" : "Also in"} ${names.join(", ")}`;
368
+ }
369
+ /**
370
+ * What a collection says when its main list is empty, or absent when it is not. A collection is
371
+ * never hidden for having nothing active: it is still somewhere its finished reviews live, and
372
+ * somewhere to put the next one.
373
+ */
374
+ function collectionEmptyText(counts, filtered) {
375
+ if (counts.active > 0) return void 0;
376
+ if (filtered) return "Nothing in this collection matches the filters.";
377
+ if (counts.members === 0) return "Nothing here yet. Add reviews from a card’s menu, or select several and choose Manage collections.";
378
+ if (counts.pinned > 0 && counts.completed === 0) return "Its reviews are pinned, so they are listed under Pinned.";
379
+ return "No active reviews";
380
+ }
381
+ /** Said under a collection whose oldest members are not drawn, so it never looks smaller than it is. */
382
+ function omittedText(omitted) {
383
+ return omitted === 1 ? "1 older review in this collection is not listed." : `${omitted} older reviews in this collection are not listed.`;
384
+ }
385
+ /** The disclosure holding a collection's Done reviews. */
386
+ function completedLabel(completed) {
387
+ return `Show completed (${completed})`;
388
+ }
389
+ /**
305
390
  * Whether a filter replaces the activity order with one the reviewer set. Queue is the only one:
306
391
  * the list is the queue, in its order.
307
392
  * `orderIndex` and `indexSectionOf` both ask this, so the ordering and the placement cannot disagree.
@@ -9533,27 +9618,63 @@ function operationLine(operation) {
9533
9618
  }
9534
9619
  //#endregion
9535
9620
  //#region ../../packages/review-harness/src/workspace/reviews_page.ts
9536
- function renderReviewsPage(view) {
9621
+ function indexContext(view) {
9537
9622
  const now = view.now ?? Date.now();
9538
9623
  const activityOf = (group) => view.activity?.get(group.latest.id) ?? {
9539
9624
  at: group.latest.createdAt,
9540
9625
  kind: "created"
9541
9626
  };
9542
9627
  const ordered = [...view.groups].sort((a, b) => activityOf(b).at - activityOf(a).at);
9628
+ const rank = new Map(ordered.map((group, index) => [group, index]));
9629
+ const operations = view.operations ?? [];
9630
+ const inProgressOnly = new Set(ordered.filter((group) => group.history.length === 1 && !group.latest.hasStandardReview && operations.some((operation) => operation.reviewId === group.latest.id && operation.purpose !== "update")).map((group) => group.latest.id));
9631
+ const queue = view.queue ?? [];
9632
+ const positions = new Map(queue.map((id, position) => [id, position]));
9633
+ const collections = view.collections ?? [];
9634
+ const refs = collections.map(({ id, name }) => ({
9635
+ id,
9636
+ name
9637
+ }));
9638
+ const memberOf = membershipsByLineage(collections);
9639
+ const renderCards = (groups, placement, hidden = () => false) => 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), inProgressOnly.has(group.latest.id), {
9640
+ placement,
9641
+ memberOf: memberOf.get(group.key) ?? [],
9642
+ refs,
9643
+ hidden: hidden(group)
9644
+ })).join("");
9645
+ return {
9646
+ now,
9647
+ ordered,
9648
+ activityOf,
9649
+ inProgressOnly,
9650
+ positions,
9651
+ queue,
9652
+ refs,
9653
+ collections,
9654
+ memberOf,
9655
+ renderCards
9656
+ };
9657
+ }
9658
+ function renderReviewsPage(view) {
9659
+ const context = indexContext(view);
9660
+ const { ordered, activityOf, inProgressOnly, positions, queue, memberOf, renderCards } = context;
9661
+ const now = context.now;
9662
+ const operations = view.operations ?? [];
9543
9663
  const sections = {
9544
9664
  pinned: [],
9545
9665
  active: [],
9546
9666
  waiting: [],
9547
9667
  done: []
9548
9668
  };
9549
- for (const group of ordered) sections[indexSectionOf(group.latest.pinned === true, group.latest.lifecycle, activityOf(group), "all")].push(group);
9550
- const rank = new Map(ordered.map((group, index) => [group, index]));
9551
- const operations = view.operations ?? [];
9552
- const inProgressOnly = new Set(ordered.filter((group) => group.history.length === 1 && !group.latest.hasStandardReview && operations.some((operation) => operation.reviewId === group.latest.id && operation.purpose !== "update")).map((group) => group.latest.id));
9553
- 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), inProgressOnly.has(group.latest.id))).join("");
9669
+ const collectedHome = /* @__PURE__ */ new Set();
9670
+ for (const group of ordered) {
9671
+ const collected = (memberOf.get(group.key) ?? []).length > 0;
9672
+ const home = homeSection(group.latest.pinned === true, group.latest.lifecycle, activityOf(group), collected, "all");
9673
+ if (home === void 0) collectedHome.add(group);
9674
+ sections[home ?? sectionOf(group.latest.lifecycle, activityOf(group))].push(group);
9675
+ }
9554
9676
  const storedReviews = view.groups.reduce((total, group) => total + group.history.length, 0);
9555
- const queue = view.queue ?? [];
9556
- const positions = new Map(queue.map((id, position) => [id, position]));
9677
+ const renderSectionAt = (section) => renderSection(section, sections[section], (groups) => renderCards(groups, "home", (group) => collectedHome.has(group)), sectionOpen(section, view.sectionsCollapsed), sections[section].filter((group) => !inProgressOnly.has(group.latest.id) && !collectedHome.has(group)).length);
9557
9678
  return `<!doctype html>
9558
9679
  <html lang="en"${themeAttribute(view.theme)}>
9559
9680
  <head>
@@ -9564,7 +9685,7 @@ function renderReviewsPage(view) {
9564
9685
  <link rel="stylesheet" href="/assets/sidebar.css">
9565
9686
  <link rel="stylesheet" href="/assets/shell.css">
9566
9687
  </head>
9567
- <body class="prb-index-body" data-way-back="${escapeHtml$1(wayBackSentence())}" data-notifications-since="${now}"${view.revision === void 0 ? "" : ` data-index-revision="${escapeHtml$1(view.revision)}"`} data-live="${anythingRunning(view.analyses ?? /* @__PURE__ */ new Map(), operations)}">
9688
+ <body class="prb-index-body" data-way-back="${escapeHtml$1(wayBackSentence())}" data-notifications-since="${now}"${view.revision === void 0 ? "" : ` data-index-revision="${escapeHtml$1(view.revision)}"`}${view.snapshot === void 0 ? "" : ` data-cards-snapshot="${escapeHtml$1(view.snapshot)}"`} data-live="${anythingRunning(view.analyses ?? /* @__PURE__ */ new Map(), operations)}">
9568
9689
  ${renderGlobalBar({
9569
9690
  current: "reviews",
9570
9691
  unread: view.unreadNotifications ?? 0,
@@ -9574,7 +9695,7 @@ ${renderContextBar("Reviews", ordered.length === 0 ? "" : renderControls$1(queue
9574
9695
  handle: latest.authorHandle,
9575
9696
  name: latest.authorName,
9576
9697
  createdAt: latest.createdAt
9577
- }))), ordered.some((group) => group.latest.prNumber !== void 0)))}
9698
+ }))), ordered.some((group) => group.latest.prNumber !== void 0), context.refs.length > 0))}
9578
9699
  <div class="prb-shell">
9579
9700
  ${renderWorkspaceRail({
9580
9701
  current: "reviews",
@@ -9583,13 +9704,20 @@ ${renderWorkspaceRail({
9583
9704
  <main class="prb-index">
9584
9705
  ${ordered.length === 0 ? "" : `<div class="prb-index-summary">
9585
9706
  <p class="prb-index-count" data-count>${escapeHtml$1(countLabel(storedReviews, ordered.length))}</p>
9586
- <button type="button" class="prb-index-select" data-select-mode aria-pressed="false">Select</button>
9707
+ <div class="prb-index-summary-actions">
9708
+ <button type="button" class="prb-index-select" data-collection-new><span aria-hidden="true">+</span> New collection</button>
9709
+ <button type="button" class="prb-index-select" data-select-mode aria-pressed="false">Select</button>
9710
+ </div>
9587
9711
  </div>`}
9588
- ${renderInProgress$1(operations, ordered, positions, view.prStates, now, sectionOpen("in-progress", view.sectionsCollapsed))}
9589
- ${INDEX_SECTIONS.map((section) => renderSection(section, sections[section], renderCards, sectionOpen(section, view.sectionsCollapsed), sections[section].filter((group) => !inProgressOnly.has(group.latest.id)).length)).join("\n ")}
9712
+ ${renderInProgress$1(operations, ordered, positions, view.prStates, now, sectionOpen("in-progress", view.sectionsCollapsed), memberOf)}
9713
+ ${renderSectionAt("pinned")}
9714
+ ${ordered.length === 0 ? "" : collectionsArea(context, view.sectionsCollapsed)}
9715
+ ${INDEX_SECTIONS.filter((section) => section !== "pinned").map(renderSectionAt).join("\n ")}
9590
9716
  ${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>`}
9591
9717
  ${ordered.length === 0 ? "" : renderBulkBar()}
9592
- <p class="prb-index-note">Reviews are kept for thirty days, on this machine only. Pinned 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>
9718
+ ${ordered.length === 0 ? "" : renderCollectionsDialog()}
9719
+ ${ordered.length === 0 ? "" : renderNewCollectionDialog()}
9720
+ <p class="prb-index-note">Reviews are kept for thirty days, on this machine only. Pinned, Done and queued reviews, and reviews in a collection, 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>
9593
9721
  </main>
9594
9722
  </div>
9595
9723
  <script src="/assets/reviews.js" type="module"><\/script>
@@ -9605,7 +9733,7 @@ ${renderWorkspaceRail({
9605
9733
  * per risk level would be four more controls for a question ("show me only the risky ones") that
9606
9734
  * sorting by recency already answers badly and that scanning answers well.
9607
9735
  */
9608
- function renderControls$1(queued, authors, anyRequest) {
9736
+ function renderControls$1(queued, authors, anyRequest, anyCollection) {
9609
9737
  return `<div class="prb-index-controls">
9610
9738
  <input type="search" class="prb-index-search" data-search placeholder="Search repo, branch, title, summary or file…" aria-label="Search reviews" spellcheck="false">
9611
9739
  ${renderAuthorFilter(authors)}
@@ -9618,10 +9746,29 @@ function renderControls$1(queued, authors, anyRequest) {
9618
9746
  </select>
9619
9747
  </label>
9620
9748
  ${anyRequest ? renderPrFilter() : ""}
9749
+ ${renderCollectionsFilter(anyCollection)}
9621
9750
  ${renderClearFilters()}
9622
9751
  </div>`;
9623
9752
  }
9624
9753
  /**
9754
+ * Which collections to show, any of several, and Uncollected for the reviews in none.
9755
+ *
9756
+ * A menu holding checkboxes rather than a select, because a select chooses one thing and this
9757
+ * chooses several, and because the list can be long enough to want its own search. The options are
9758
+ * filled in by the browser from the page's list of collections, so one made or renamed on the page
9759
+ * appears here without a reload. Hidden while there are no collections: a filter whose only choice
9760
+ * is Uncollected is not a choice.
9761
+ */
9762
+ function renderCollectionsFilter(anyCollection) {
9763
+ return `<div class="prb-index-status prb-collections-filter" data-menu data-menu-form data-collections-filter${anyCollection ? "" : " hidden"}>
9764
+ <button type="button" class="prb-collections-filter-trigger" data-menu-trigger aria-expanded="false" aria-haspopup="true" aria-label="Filter by collection">Collections: <span data-collections-filter-label>All</span></button>
9765
+ <div class="prb-menu-panel prb-collections-filter-panel" data-menu-panel hidden>
9766
+ <input type="search" class="prb-collections-filter-search" data-collections-filter-search placeholder="Find a collection" aria-label="Find a collection" spellcheck="false">
9767
+ <div class="prb-collections-filter-options" data-collections-filter-options role="group" aria-label="Collections"></div>
9768
+ </div>
9769
+ </div>`;
9770
+ }
9771
+ /**
9625
9772
  * Puts the search and every filter back at once. Hidden until the browser finds one narrowing the
9626
9773
  * list: the hash is the browser's to read, and without script there is nothing to clear.
9627
9774
  */
@@ -9671,6 +9818,144 @@ function renderAuthorFilter(authors) {
9671
9818
  </label>`;
9672
9819
  }
9673
9820
  /**
9821
+ * The Collections area, between Pinned and Active: one disclosure per collection, each listing its
9822
+ * reviews that still need attention, with its Done reviews folded beneath.
9823
+ *
9824
+ * Exported for the routes that change collections, which answer with this markup so the browser can
9825
+ * swap the area in place: every card instance is drawn by `renderCard`, here and on a full load, and
9826
+ * never copied in the browser.
9827
+ */
9828
+ /** Collection ids by lineage key, in collection order: what a card's `data-collections` says. */
9829
+ function membershipsByLineage(collections) {
9830
+ const memberOf = /* @__PURE__ */ new Map();
9831
+ for (const collection of collections) for (const key of collection.members) memberOf.set(key, [...memberOf.get(key) ?? [], collection.id]);
9832
+ return memberOf;
9833
+ }
9834
+ function renderCollectionsArea(view) {
9835
+ return collectionsArea(indexContext(view), view.sectionsCollapsed);
9836
+ }
9837
+ function collectionsArea(context, collapsed) {
9838
+ const { ordered, refs, collections, memberOf, activityOf, inProgressOnly, renderCards } = context;
9839
+ const registry = JSON.stringify(refs).replace(/</g, "\\u003c");
9840
+ const blocks = collections.map((collection) => {
9841
+ const members = ordered.filter((group) => (memberOf.get(group.key) ?? []).includes(collection.id));
9842
+ const place = (group) => collectionPlacement(group.latest.pinned === true, group.latest.lifecycle, activityOf(group), "all");
9843
+ const drawn = members.filter((group) => !inProgressOnly.has(group.latest.id));
9844
+ const active = members.filter((group) => place(group) !== "completed");
9845
+ const completed = members.filter((group) => place(group) === "completed");
9846
+ return renderCollection(collection, {
9847
+ active: drawn.filter((group) => place(group) === "active").length,
9848
+ completed: drawn.filter((group) => place(group) === "completed").length,
9849
+ pinned: drawn.filter((group) => group.latest.pinned === true).length,
9850
+ members: members.length
9851
+ }, collection.members.length - members.length, collapsed, {
9852
+ active: renderCards(active, { collection: collection.id }, (group) => place(group) === void 0),
9853
+ completed: renderCards(completed, { collection: collection.id })
9854
+ });
9855
+ });
9856
+ return `<section class="prb-collections" data-collections-area aria-label="Collections"${collections.length === 0 ? " hidden" : ""}>
9857
+ <script type="application/json" data-collections-registry>${registry}<\/script>
9858
+ ${blocks.join("\n ")}
9859
+ </section>`;
9860
+ }
9861
+ function renderCollection(collection, counts, omitted, collapsed, cards) {
9862
+ const id = escapeHtml$1(collection.id);
9863
+ const empty = collectionEmptyText(counts, false);
9864
+ return `<div class="prb-collection" data-collection="${id}">
9865
+ <details class="prb-index-section" data-section="${escapeHtml$1(collectionFold(collection.id))}"${sectionOpen(collectionFold(collection.id), collapsed) ? " open" : ""}>
9866
+ <summary class="prb-index-section-head"><span data-collection-name>${escapeHtml$1(collection.name)}</span> · <span data-section-count>${escapeHtml$1(collectionCountLabel(counts.active, counts.pinned))}</span></summary>
9867
+ <p class="prb-collection-empty" data-collection-empty${empty === void 0 ? " hidden" : ""}>${escapeHtml$1(empty ?? "")}</p>
9868
+ <div class="prb-cards" data-cards="${escapeHtml$1(collectionFold(collection.id))}">
9869
+ ${cards.active}
9870
+ </div>
9871
+ ${renderLoadMore$1(collectionFold(collection.id))}
9872
+ ${omitted > 0 ? `<p class="prb-collection-empty" data-collection-omitted>${escapeHtml$1(omittedText(omitted))}</p>` : ""}
9873
+ <details class="prb-collection-completed" data-section="${escapeHtml$1(completedFold(collection.id))}"${sectionOpen(completedFold(collection.id), collapsed) ? " open" : ""}${counts.completed === 0 ? " hidden" : ""}>
9874
+ <summary class="prb-collection-completed-head" data-completed-label>${escapeHtml$1(completedLabel(counts.completed))}</summary>
9875
+ <div class="prb-cards" data-cards="${escapeHtml$1(completedFold(collection.id))}">
9876
+ ${cards.completed}
9877
+ </div>
9878
+ ${renderLoadMore$1(completedFold(collection.id))}
9879
+ </details>
9880
+ </details>
9881
+ ${renderCollectionMenu(collection)}
9882
+ </div>`;
9883
+ }
9884
+ /**
9885
+ * A collection's own menu: rename it in place, or delete it.
9886
+ *
9887
+ * Deleting says what it does not do, which is the thing a reader worries about: the reviews stay.
9888
+ * It says what does change, too, because collected reviews are kept for longer and these go back to
9889
+ * the ordinary thirty days unless something else keeps them.
9890
+ */
9891
+ function renderCollectionMenu(collection) {
9892
+ const id = escapeHtml$1(collection.id);
9893
+ return `<div class="prb-menu prb-collection-menu" data-menu data-menu-form>
9894
+ <button type="button" class="prb-menu-trigger" data-menu-trigger aria-expanded="false" aria-haspopup="true" aria-label="${escapeHtml$1(`Actions for collection ${collection.name}`)}">&#8943;</button>
9895
+ <div class="prb-menu-panel" data-menu-panel hidden>
9896
+ <div data-menu-items>
9897
+ <button type="button" class="prb-menu-item" data-collection-rename="${id}">Rename collection</button>
9898
+ <button type="button" class="prb-menu-item prb-menu-item-danger" data-collection-delete="${id}">Delete collection</button>
9899
+ </div>
9900
+ <form class="prb-collection-rename" data-collection-rename-form="${id}" hidden>
9901
+ <label class="prb-menu-question" for="prb-rename-${id}">New name</label>
9902
+ <input type="text" id="prb-rename-${id}" class="prb-collection-name" data-collection-name-input value="${escapeHtml$1(collection.name)}" maxlength="60" autocomplete="off" spellcheck="false">
9903
+ <p class="prb-collection-error" data-collection-error hidden role="alert"></p>
9904
+ <div class="prb-menu-buttons">
9905
+ <button type="submit" class="prb-menu-item">Save</button>
9906
+ <button type="button" class="prb-menu-item" data-collection-cancel>Cancel</button>
9907
+ </div>
9908
+ </form>
9909
+ <div class="prb-menu-confirm" data-menu-confirm hidden>
9910
+ <p class="prb-menu-question">${escapeHtml$1(`Delete the collection ${collection.name}?`)}</p>
9911
+ <p class="prb-menu-note">Its reviews stay where they are. They go back to being kept for thirty days unless they are pinned, Done or in your review queue.</p>
9912
+ <div class="prb-menu-buttons">
9913
+ <button type="button" class="prb-menu-item prb-menu-item-danger" data-collection-confirm-delete="${id}">Delete</button>
9914
+ <button type="button" class="prb-menu-item" data-cancel-delete>Cancel</button>
9915
+ </div>
9916
+ </div>
9917
+ </div>
9918
+ </div>`;
9919
+ }
9920
+ /**
9921
+ * New collection, opened from beside Select. The description lives here, where somebody is deciding
9922
+ * what to make, rather than on the list for good.
9923
+ */
9924
+ function renderNewCollectionDialog() {
9925
+ return `<dialog class="prb-dialog prb-collection-new-dialog" data-collection-new-dialog aria-labelledby="prb-collection-new-title">
9926
+ <form method="dialog" data-collection-new-form>
9927
+ <h2 id="prb-collection-new-title">New collection</h2>
9928
+ <p>Group reviews that belong together, such as one refactor or one release.</p>
9929
+ <label class="prb-field"><span>Collection name</span>
9930
+ <input type="text" data-collection-name-input placeholder="e.g. Refactor" maxlength="60" autocomplete="off" spellcheck="false" required>
9931
+ </label>
9932
+ <p class="prb-collection-error" data-collection-error hidden role="alert"></p>
9933
+ <div class="prb-dialog-actions">
9934
+ <button type="button" data-collection-cancel>Cancel</button>
9935
+ <button type="submit" class="prb-dialog-primary" data-collection-create disabled>Create</button>
9936
+ </div>
9937
+ </form>
9938
+ </dialog>`;
9939
+ }
9940
+ /**
9941
+ * Manage collections, for one card or for a selection. Built once; the browser fills the list from
9942
+ * the page's collections and the reviews it was opened for, because the boxes' states depend on
9943
+ * which reviews those are.
9944
+ */
9945
+ function renderCollectionsDialog() {
9946
+ return `<dialog class="prb-dialog prb-collections-dialog" data-collections-dialog aria-labelledby="prb-collections-dialog-title">
9947
+ <h2 id="prb-collections-dialog-title">Collections</h2>
9948
+ <p data-collections-dialog-for></p>
9949
+ <input type="search" class="prb-collections-dialog-search" data-collections-dialog-search placeholder="Find or create a collection" aria-label="Find or create a collection" spellcheck="false" autocomplete="off">
9950
+ <div class="prb-collections-dialog-list" data-collections-dialog-list role="group" aria-label="Collections"></div>
9951
+ <button type="button" class="prb-collections-dialog-create" data-collections-dialog-create hidden></button>
9952
+ <p class="prb-collection-error" data-collections-dialog-error hidden role="alert"></p>
9953
+ <div class="prb-dialog-actions">
9954
+ <button type="button" data-collections-dialog-close>Done</button>
9955
+ </div>
9956
+ </dialog>`;
9957
+ }
9958
+ /**
9674
9959
  * One section's Load more, which the browser shows once that section holds more than it is showing.
9675
9960
  *
9676
9961
  * One per section rather than one pager for the page, so revealing older Done reviews never costs
@@ -9708,10 +9993,11 @@ function renderSection(section, groups, renderCards, open, shown = groups.length
9708
9993
  * index token and arrives as a reload. Its count is of work, not reviews, so a review being updated
9709
9994
  * counts once here and once in its own section, which is the point.
9710
9995
  */
9711
- function renderInProgress$1(operations, groups, positions, prStates, now, open) {
9996
+ function renderInProgress$1(operations, groups, positions, prStates, now, open, memberOf = /* @__PURE__ */ new Map()) {
9712
9997
  if (operations.length === 0) return "";
9713
9998
  const reviews = new Map(groups.flatMap((group) => group.history.map((review) => [review.id, review])));
9714
- const entries = operations.map((operation) => renderOperation(operation, reviewOf(operation, reviews), positions, prStates, now)).join("");
9999
+ const collections = operationCollections(operations, groups, memberOf);
10000
+ const entries = operations.map((operation) => renderOperation(operation, reviewOf(operation, reviews), positions, prStates, now, collections[operation.key] ?? [])).join("");
9715
10001
  return `<details class="prb-index-section" data-section="in-progress"${open ? " open" : ""}>
9716
10002
  <summary class="prb-index-section-head">${INDEX_SECTION_NAMES["in-progress"].title} · <span data-section-count>${operations.length}</span></summary>
9717
10003
  <div class="prb-cards" data-operations>
@@ -9725,6 +10011,23 @@ function renderInProgress$1(operations, groups, positions, prStates, now, open)
9725
10011
  * one, which is what that review's title, author and state still are: the filters then treat a
9726
10012
  * re-review of a known pull request as that pull request, rather than as somebody unknown.
9727
10013
  */
10014
+ /**
10015
+ * The collections each piece of work's review is in, by the work's key: the lineage of whichever
10016
+ * review `reviewOf` finds, so a waiting row with no review yet still answers the Collections filter
10017
+ * the way its card will. Also what the collections routes send back, so a change of membership
10018
+ * reaches the In progress section without a reload.
10019
+ */
10020
+ function operationCollections(operations, groups, memberOf) {
10021
+ const reviews = new Map(groups.flatMap((group) => group.history.map((review) => [review.id, review])));
10022
+ const lineage = new Map(groups.flatMap((group) => group.history.map((review) => [review.id, group.key])));
10023
+ const answer = {};
10024
+ for (const operation of operations) {
10025
+ const review = reviewOf(operation, reviews);
10026
+ const key = review ? lineage.get(review.id) : void 0;
10027
+ answer[operation.key] = key === void 0 ? [] : [...memberOf.get(key) ?? []];
10028
+ }
10029
+ return answer;
10030
+ }
9728
10031
  function reviewOf(operation, reviews) {
9729
10032
  if (operation.reviewId !== void 0) return reviews.get(operation.reviewId);
9730
10033
  const row = operation.row;
@@ -9746,7 +10049,7 @@ function reviewOf(operation, reviews) {
9746
10049
  * A link to the review, whose own page shows the steps, the cancel and the Retry. A row still waiting
9747
10050
  * for a slot has no review to open, so it is not a link.
9748
10051
  */
9749
- function renderOperation(operation, review, positions, prStates, now) {
10052
+ function renderOperation(operation, review, positions, prStates, now, collections = []) {
9750
10053
  const row = operation.row;
9751
10054
  const own = operation.reviewId !== void 0 && review?.id === operation.reviewId ? review : void 0;
9752
10055
  const repoPath = review?.repoPath ?? row?.repoPath;
@@ -9777,7 +10080,7 @@ function renderOperation(operation, review, positions, prStates, now) {
9777
10080
  ${operation.startedAt ? `<span class="prb-card-when"><time datetime="${new Date(operation.startedAt).toISOString()}">${escapeHtml$1(`Started ${relativeTime(operation.startedAt, now)}`)}</time></span>` : ""}
9778
10081
  </p>`;
9779
10082
  const href = own ? `/r/${encodeURIComponent(own.id)}?t=${encodeURIComponent(own.token)}` : void 0;
9780
- return `<article class="prb-card" data-operation="${escapeHtml$1(operation.kind)}" data-key="${escapeHtml$1(operation.key)}" data-id="${escapeHtml$1(operation.reviewId ?? "")}" data-status="${status}"${queued === void 0 ? "" : ` data-queue="${queued}"`}${prState === void 0 ? "" : ` data-pr-state="${prState}"`}${review?.authorHandle ? ` data-author="${escapeHtml$1(review.authorHandle)}"` : ""} data-search-text="${escapeHtml$1(search)}">
10083
+ return `<article class="prb-card" data-operation="${escapeHtml$1(operation.kind)}" data-key="${escapeHtml$1(operation.key)}" data-id="${escapeHtml$1(operation.reviewId ?? "")}" data-status="${status}"${queued === void 0 ? "" : ` data-queue="${queued}"`}${prState === void 0 ? "" : ` data-pr-state="${prState}"`}${review?.authorHandle ? ` data-author="${escapeHtml$1(review.authorHandle)}"` : ""} data-search-text="${escapeHtml$1(search)}" data-collections="${escapeHtml$1(collections.join(" "))}">
9781
10084
  ${href ? `<a class="prb-card-link" href="${escapeHtml$1(href)}">${body}</a>` : `<div class="prb-card-link">${body}</div>`}
9782
10085
  </article>`;
9783
10086
  }
@@ -9802,6 +10105,7 @@ function renderBulkBar() {
9802
10105
  <button type="button" class="prb-bulk-button" data-bulk-select-all>${escapeHtml$1(selectAllLabel(0))}</button>
9803
10106
  <button type="button" class="prb-bulk-button" data-bulk-fetch hidden>${escapeHtml$1(FETCH_SELECTED_LABEL)}</button>
9804
10107
  <button type="button" class="prb-bulk-button" data-bulk-queue disabled>Add to review queue</button>
10108
+ <button type="button" class="prb-bulk-button" data-bulk-collections disabled>Manage collections</button>
9805
10109
  <button type="button" class="prb-bulk-button" data-bulk-mark="complete">${markItemLabel("complete")}</button>
9806
10110
  <button type="button" class="prb-bulk-button" data-bulk-mark="in-progress">${markItemLabel("in-progress")}</button>
9807
10111
  ${LIFECYCLES.map((lifecycle) => `<button type="button" class="prb-bulk-button" data-bulk-lifecycle="${lifecycle.id}">${escapeHtml$1(lifecycle.action)}</button>`).join("\n ")}
@@ -9838,13 +10142,21 @@ function renderBulkBar() {
9838
10142
  * card and is shown only in selection mode, so entering that mode is a stylesheet change rather
9839
10143
  * than a rebuild of the list.
9840
10144
  */
9841
- function renderCard(group, now, activity, rank, analysis, queued, moved, prState, inProgressOnly = false) {
10145
+ function renderCard(group, now, activity, rank, analysis, queued, moved, prState, inProgressOnly = false, instance = {
10146
+ placement: "home",
10147
+ memberOf: [],
10148
+ refs: [],
10149
+ hidden: false
10150
+ }) {
9842
10151
  const review = group.latest;
10152
+ const placement = instance.placement === "home" ? "home" : `collection:${instance.placement.collection}`;
10153
+ const line = collectionsLine(instance.memberOf, instance.refs, instance.placement === "home" ? void 0 : instance.placement.collection);
10154
+ const hidden = inProgressOnly || instance.hidden;
9843
10155
  const status = statusOf(review);
9844
10156
  const chip = cardLifecycleChip(review.pinned === true, review.lifecycle, activity);
9845
10157
  const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
9846
10158
  const followed = review.targetType === "commit" ? `commit ${(review.commitSha ?? review.branch).slice(0, 7)}` : followedRefName(review.branch, review.headResolvedRef) ?? review.branch;
9847
- 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}"` : ""}${activityAttributes(activity)} 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))}"${inProgressOnly ? " data-in-progress-only hidden" : ""}>
10159
+ 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}"` : ""}${activityAttributes(activity)} 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))}" data-placement="${escapeHtml$1(placement)}" data-collections="${escapeHtml$1(instance.memberOf.join(" "))}"${inProgressOnly ? " data-in-progress-only" : ""}${hidden ? " hidden" : ""}>
9848
10160
  <input type="checkbox" class="prb-card-select" data-select="${escapeHtml$1(review.id)}" aria-label="Select ${escapeHtml$1(followed)}">
9849
10161
  <button type="button" class="prb-card-pin" data-pin="${escapeHtml$1(review.id)}" aria-pressed="${review.pinned}" aria-label="${review.pinned ? "Unpin" : "Pin"} this review">${review.pinned ? "&#9733;" : "&#9734;"}</button>
9850
10162
  ${renderCardMenu(group, followed, queued !== void 0, status, activity)}
@@ -9870,6 +10182,7 @@ function renderCard(group, now, activity, rank, analysis, queued, moved, prState
9870
10182
  <span class="prb-card-diffstat"><span class="prb-add">+${review.additions}</span> <span class="prb-del">&minus;${review.deletions}</span></span>
9871
10183
  ${analysis !== void 0 && !review.hasStandardReview ? "" : `<span class="prb-card-left">${escapeHtml$1(whatIsLeft(review))}</span>`}
9872
10184
  ${renderAnalysisState(analysis)}
10185
+ <span class="prb-card-collections" data-collections-line${line === void 0 ? " hidden" : ""}>${escapeHtml$1(line ?? "")}</span>
9873
10186
  <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>
9874
10187
  </p>
9875
10188
  </a>
@@ -9910,7 +10223,8 @@ function renderCardMenu(group, followed, queued, status, activity) {
9910
10223
  queue: queued ? "remove" : "add",
9911
10224
  mark,
9912
10225
  lifecycle,
9913
- fetch: true
10226
+ fetch: true,
10227
+ collections: true
9914
10228
  });
9915
10229
  const all = `all ${count} reviews following ${followed}`;
9916
10230
  return renderMenu({
@@ -9923,6 +10237,7 @@ function renderCardMenu(group, followed, queued, status, activity) {
9923
10237
  mark,
9924
10238
  lifecycle,
9925
10239
  fetch: true,
10240
+ collections: true,
9926
10241
  copy: "Copy latest review ID"
9927
10242
  });
9928
10243
  }
@@ -9953,6 +10268,7 @@ function renderMenu(item) {
9953
10268
  ${markItem}
9954
10269
  ${lifecycleItems}
9955
10270
  ${queueItem}
10271
+ ${item.collections ? `<button type="button" class="prb-menu-item" data-collections-for="${id}">Manage collections…</button>` : ""}
9956
10272
  <button type="button" class="prb-menu-item" data-copy-id="${id}">${escapeHtml$1(item.copy)}</button>
9957
10273
  <button type="button" class="prb-menu-item prb-menu-item-danger" data-delete="${id}" data-scope="${item.scope}">${escapeHtml$1(item.action)}</button>
9958
10274
  </div>
@@ -10363,14 +10679,15 @@ function writeChangeMapRegister(register) {
10363
10679
  }, null, 2)}\n`);
10364
10680
  } catch {}
10365
10681
  }
10366
- /** Known sections with a boolean only. A hand-edited key we do not draw is dropped on the next write. */
10682
+ /**
10683
+ * Known sections and collection folds with a boolean only. A hand-edited key we do not draw is
10684
+ * dropped on the next write. A fold for a collection since deleted elsewhere is kept until
10685
+ * `forgetSections` is told, which costs one line of JSON and nothing on the page.
10686
+ */
10367
10687
  function readReviewSections(value) {
10368
10688
  if (!value || typeof value !== "object") return {};
10369
10689
  const sections = {};
10370
- for (const section of LIST_SECTIONS) {
10371
- const collapsed = value[section];
10372
- if (typeof collapsed === "boolean") sections[section] = collapsed;
10373
- }
10690
+ for (const [section, collapsed] of Object.entries(value)) if (isFoldKey(section) && typeof collapsed === "boolean") sections[section] = collapsed;
10374
10691
  return Object.keys(sections).length === 0 ? {} : { reviewSections: sections };
10375
10692
  }
10376
10693
  /**
@@ -10392,6 +10709,19 @@ function writeSectionCollapsed(section, collapsed) {
10392
10709
  }, null, 2)}\n`);
10393
10710
  } catch {}
10394
10711
  }
10712
+ /** Drop folds nothing will draw again: a deleted collection's. Writes nothing when none was stored. */
10713
+ function forgetSections(sections) {
10714
+ try {
10715
+ const current = readPreferences();
10716
+ const stored = current.reviewSections;
10717
+ if (!stored || !sections.some((section) => section in stored)) return;
10718
+ const reviewSections = Object.fromEntries(Object.entries(stored).filter(([section]) => !sections.includes(section)));
10719
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
10720
+ ...current,
10721
+ reviewSections
10722
+ }, null, 2)}\n`);
10723
+ } catch {}
10724
+ }
10395
10725
  /** As `readReviewSections`, for the Runs page. */
10396
10726
  function readRunsSections(value) {
10397
10727
  if (!value || typeof value !== "object") return {};
@@ -11164,6 +11494,25 @@ feature: E</pre>
11164
11494
  <p>Deleting a review removes its record and the isolated checkouts it was made in. It never
11165
11495
  touches your branch or its commits. Delete one from its menu, or tick reviews in the Reviews
11166
11496
  list, one at a time or a whole branch at once, and delete them together.</p>`
11497
+ },
11498
+ {
11499
+ id: "collections",
11500
+ title: "Collections",
11501
+ body: `<p>A collection groups reviews that belong together, such as the pull requests of one
11502
+ refactor or one release. It says what belongs together; whether each review still needs your
11503
+ attention is still Active, Waiting or Done, so adding or removing a review never changes its
11504
+ state.</p>
11505
+ <p>Make one with New collection on the Reviews page, or from Manage collections in a card's menu.
11506
+ To add several reviews at once, choose Select, tick them, and choose Manage collections. A review
11507
+ can be in more than one collection. Ticking a collection adds every selected review to it and
11508
+ leaves their other collections alone.</p>
11509
+ <p>Each collection lists its reviews that are not Done. Marking one Done moves it under Show
11510
+ completed, in every collection it is in, and Move to active brings it back. A review in a
11511
+ collection is listed there rather than under Active, Waiting or Done. A pinned review stays under
11512
+ Pinned. The Collections filter shows chosen collections, or Uncollected for reviews in none.</p>
11513
+ <p>Reviews in a collection are kept for ninety days, like pinned and Done reviews, counted from
11514
+ when you last opened or marked them. Adding one to a collection doesn't restart that count.
11515
+ Deleting a collection keeps its reviews.</p>`
11167
11516
  },
11168
11517
  {
11169
11518
  id: "several-pull-requests",
@@ -11217,8 +11566,8 @@ feature: E</pre>
11217
11566
  automatically:</p>
11218
11567
  <ul>
11219
11568
  <li><b>Reviews</b> are kept for thirty days after you last opened or changed them.</li>
11220
- <li><b>Reviews you kept</b> (saved, marked Done, or on your review queue) are kept for ninety
11221
- days instead.</li>
11569
+ <li><b>Reviews you kept</b> (saved, marked Done, in a collection, or on your review queue) are
11570
+ kept for ninety days instead.</li>
11222
11571
  <li><b>Isolated checkouts</b> are removed six hours after their work finishes. See
11223
11572
  <a href="#isolated-checkouts">Isolated checkouts</a>.</li>
11224
11573
  <li><b>Runs</b>, the record on the Runs page of what ran and how it ended, are kept for seven
@@ -11395,6 +11744,50 @@ function renderNotificationRow(notification, gone) {
11395
11744
  </li>`;
11396
11745
  }
11397
11746
  //#endregion
11747
+ //#region ../../packages/review-harness/src/workspace/collections_live.ts
11748
+ /**
11749
+ * Collections as they relate to reviews that actually exist.
11750
+ *
11751
+ * `collections.ts` stores lineage keys and must not learn to read the review store, for the cycle
11752
+ * `queue_live.ts` describes: the prune in `store.ts` imports it. Anything that asks both questions
11753
+ * lives here, which imports both and is imported by neither.
11754
+ */
11755
+ /**
11756
+ * Drop members whose lineage has no review any more, deleted elsewhere or pruned by age, and store
11757
+ * the result if that changed anything. Read against every group, not the windowed list, so a branch
11758
+ * the recency window happens to hide keeps its place.
11759
+ *
11760
+ * Compacted again under the lock rather than written from what was read, so a member somebody added
11761
+ * since is kept. On a failed write the compacted copy still stands in, so the page draws what exists.
11762
+ */
11763
+ function settleCollections(groups) {
11764
+ const live = new Set(groups.map((group) => group.key));
11765
+ const compact = (collections) => collections.some((collection) => collection.members.some((key) => !live.has(key))) ? collections.map((collection) => ({
11766
+ ...collection,
11767
+ members: collection.members.filter((key) => live.has(key))
11768
+ })) : null;
11769
+ const read = readCollections();
11770
+ const compacted = compact(read);
11771
+ if (compacted === null) return read;
11772
+ return updateCollections((current) => compact(current)) ?? compacted;
11773
+ }
11774
+ /**
11775
+ * The lineage each review id belongs to, for the routes: the page sends the ids on its cards, and
11776
+ * the lineage is the server's to derive. An id with no review, or one that cannot be summarised, is
11777
+ * left out, and the caller says so.
11778
+ */
11779
+ function lineageKeysFor(ids) {
11780
+ const keys = /* @__PURE__ */ new Map();
11781
+ for (const id of new Set(ids)) {
11782
+ const workspace = loadWorkspace(id);
11783
+ if (!workspace) continue;
11784
+ try {
11785
+ keys.set(id, lineageKey(summarise(workspace)));
11786
+ } catch {}
11787
+ }
11788
+ return keys;
11789
+ }
11790
+ //#endregion
11398
11791
  //#region ../../packages/review-harness/src/workspace/working_folders.ts
11399
11792
  /**
11400
11793
  * The local Git working folders PR Review Buddy can find right now.
@@ -12610,6 +13003,7 @@ function reviewsRevision(live = analysisStates(), operations = liveOperations())
12610
13003
  }
12611
13004
  for (const operation of [...operations].sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) hash.update(`op:${operation.key}:${operation.kind}:${operation.phase ?? ""}:${operation.reviewId ?? ""}\n`);
12612
13005
  hash.update(`queue:${queueStamp()}\n`);
13006
+ hash.update(`collections:${collectionsStamp()}\n`);
12613
13007
  for (const [id, kind] of [...movedReviews()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`moved:${id}:${kind}\n`);
12614
13008
  for (const [key, state] of [...prStatesByRequest()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`pr:${key}:${state}\n`);
12615
13009
  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`);
@@ -14606,6 +15000,8 @@ async function handle(req, res, options) {
14606
15000
  if (url.pathname === "/pin") return handlePin(req, res, options.indexToken);
14607
15001
  if (url.pathname === "/sections") return handleSections(req, res, options.indexToken);
14608
15002
  if (url.pathname === "/queue") return handleQueue(req, res, options.indexToken);
15003
+ if (url.pathname === "/collections") return handleCollections(req, res, options.indexToken);
15004
+ if (url.pathname === "/collections/members") return handleCollectionMembers(req, res, options.indexToken);
14609
15005
  if (url.pathname === "/delete") return handleDelete(req, res, options.indexToken);
14610
15006
  if (url.pathname === "/status") return handleStatus(req, res, options.indexToken);
14611
15007
  if (url.pathname === "/lifecycle") return handleLifecycle(req, res, options.indexToken, options.establishBaseline ?? establishBaseline);
@@ -14705,18 +15101,44 @@ function handleIndex(req, res, params, indexToken) {
14705
15101
  }
14706
15102
  const cookie = indexCookieHeader(indexToken);
14707
15103
  if (onward) return redirect(res, onward, cookie);
15104
+ sendHtml(res, 200, renderReviewsPage(indexView()), cookie);
15105
+ }
15106
+ /**
15107
+ * Which cards a page is drawn with: each branch, by its newest review. Equality only. Changes when a
15108
+ * branch is analysed again, or a card is added or deleted, which are the changes that make a
15109
+ * fragment drawn now wrong for a page drawn then. Nothing else moves it.
15110
+ */
15111
+ function cardsSnapshot(groups) {
15112
+ const hash = createHash("sha1");
15113
+ for (const line of groups.map((group) => `${group.key}\t${group.latest.id}`).sort()) hash.update(`${line}\n`);
15114
+ return hash.digest("hex").slice(0, 16);
15115
+ }
15116
+ /**
15117
+ * Everything the reviews index is drawn from, read in one pass.
15118
+ *
15119
+ * Shared by the page and by the collections routes, which answer with the Collections area drawn
15120
+ * from the same view, so a card in a collection after a change is the card a reload would draw.
15121
+ */
15122
+ function indexView() {
14708
15123
  const states = analysisStates();
14709
15124
  const operations = liveOperations();
14710
15125
  const reviews = recentWorkspaces(Number.MAX_SAFE_INTEGER);
14711
15126
  const everyGroup = groupByLineage(reviews);
14712
15127
  const queue = settleQueue(everyGroup);
15128
+ const collections = settleCollections(everyGroup);
14713
15129
  const kept = indexKeptIds(reviews, queue);
14714
15130
  const activity = reviewActivity(everyGroup);
14715
- const groups = indexReviewGroups(MAX_LISTED_BRANCHES, MAX_KEPT_BRANCHES, kept, (group) => activity.get(group.latest.id)?.at ?? group.latest.createdAt, everyGroup);
15131
+ const groups = indexReviewGroups(MAX_LISTED_BRANCHES, MAX_KEPT_BRANCHES, kept, (group) => activity.get(group.latest.id)?.at ?? group.latest.createdAt, everyGroup, {
15132
+ keys: new Set(collections.flatMap((collection) => collection.members)),
15133
+ limit: MAX_COLLECTED_BRANCHES
15134
+ });
14716
15135
  const rendered = new Set(groups.flatMap((group) => group.history.map((review) => review.id)));
14717
- sendHtml(res, 200, renderReviewsPage({
15136
+ const live = queue.filter((id) => rendered.has(id));
15137
+ return {
14718
15138
  groups,
14719
- queue: queue.filter((id) => rendered.has(id)),
15139
+ snapshot: cardsSnapshot(groups),
15140
+ queue: live,
15141
+ collections,
14720
15142
  analyses: states,
14721
15143
  operations,
14722
15144
  moved: movedReviews(),
@@ -14727,7 +15149,7 @@ function handleIndex(req, res, params, indexToken) {
14727
15149
  driving: isDriving(),
14728
15150
  theme: readPreferences().theme,
14729
15151
  sectionsCollapsed: readPreferences().reviewSections
14730
- }), cookie);
15152
+ };
14731
15153
  }
14732
15154
  /**
14733
15155
  * Where `?go=` is allowed to send someone, which is one shape and no other.
@@ -15381,7 +15803,7 @@ async function handleSections(req, res, indexToken) {
15381
15803
  if (req.method !== "POST") return send(res, 405, { error: "Use POST." });
15382
15804
  if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
15383
15805
  const body = await readJson(req);
15384
- if (!isListSection(body?.section) || typeof body?.collapsed !== "boolean") return send(res, 400, { error: "Not a section this page can fold." });
15806
+ if (!isFoldKey(body?.section) || typeof body?.collapsed !== "boolean") return send(res, 400, { error: "Not a section this page can fold." });
15385
15807
  writeSectionCollapsed(body.section, body.collapsed);
15386
15808
  send(res, 200, { ok: true });
15387
15809
  }
@@ -15450,6 +15872,102 @@ function addMany(res, ids) {
15450
15872
  });
15451
15873
  }
15452
15874
  /**
15875
+ * Making, renaming and deleting collections, from the Reviews page.
15876
+ *
15877
+ * The index cookie, like the queue: this is the list acting on itself. Every answer that changed
15878
+ * something carries the collections as stored and the Collections area redrawn from them, so the
15879
+ * page swaps in exactly what a reload would show rather than building cards of its own.
15880
+ */
15881
+ async function handleCollections(req, res, indexToken) {
15882
+ if (req.method !== "POST") return send(res, 405, { error: "Use POST." });
15883
+ if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
15884
+ const body = await readJson(req);
15885
+ const action = body?.action;
15886
+ const name = typeof body?.name === "string" ? body.name : "";
15887
+ const id = isCollectionId(body?.id) ? body.id : null;
15888
+ const snapshot = typeof body?.snapshot === "string" ? body.snapshot : "";
15889
+ if (action === "create") return answerCollections(res, createCollection(name), [], snapshot);
15890
+ if (action === "rename" && id) return answerCollections(res, renameCollection(id, name), [], snapshot);
15891
+ if (action === "delete" && id) {
15892
+ const outcome = deleteCollection(id);
15893
+ if (outcome.ok) forgetSections([collectionFold(id), completedFold(id)]);
15894
+ return answerCollections(res, outcome, [], snapshot);
15895
+ }
15896
+ send(res, 400, { error: "Unknown collections action." });
15897
+ }
15898
+ /**
15899
+ * Putting reviews into one collection, or taking them out of it: one card's menu or the bulk bar.
15900
+ *
15901
+ * The page sends review ids, the ids on its cards, and the lineage each belongs to is derived here,
15902
+ * because a lineage is what a collection holds and the server is the only side that can say which.
15903
+ * Only the named collection changes, whatever else the reviews belong to. `create` makes the
15904
+ * collection first, by name, so "Create Refactor" from the panel is one request and cannot leave an
15905
+ * empty collection behind a failed assignment without saying so.
15906
+ */
15907
+ async function handleCollectionMembers(req, res, indexToken) {
15908
+ if (req.method !== "POST") return send(res, 405, { error: "Use POST." });
15909
+ if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
15910
+ const body = await readJson(req);
15911
+ const action = body?.action;
15912
+ const ids = Array.isArray(body?.ids) ? body.ids.filter((one) => typeof one === "string") : [];
15913
+ if (action !== "add" && action !== "remove" || ids.length === 0) return send(res, 400, { error: "Say which reviews, and whether to add or remove them." });
15914
+ const keys = lineageKeysFor(ids);
15915
+ const gone = ids.filter((one) => !keys.has(one));
15916
+ const snapshot = typeof body?.snapshot === "string" ? body.snapshot : "";
15917
+ if (action === "add" && typeof body?.create === "string" && !isCollectionId(body?.collection)) {
15918
+ if (gone.length > 0) return send(res, 409, { error: "A review you selected has been deleted, so nothing was created. Reload the page and try again." });
15919
+ return answerCollections(res, createWithMembers(body.create, [...keys.values()]), [], snapshot);
15920
+ }
15921
+ const collection = isCollectionId(body?.collection) ? body.collection : null;
15922
+ if (collection === null) return send(res, 400, { error: "Say which collection." });
15923
+ answerCollections(res, setMembership([...keys.values()], collection, action), gone.map((one) => ({
15924
+ id: one,
15925
+ error: "A review you selected has been deleted."
15926
+ })), snapshot);
15927
+ }
15928
+ /**
15929
+ * The answer every collections route gives: what is stored, and the area drawn from it, or why not.
15930
+ *
15931
+ * **Only for the page it would fit.** The area's cards and the memberships are keyed by each
15932
+ * branch's newest review, and a page drawn before a branch was analysed again, or before one was
15933
+ * added or deleted, holds cards under other ids. Swapping a fragment drawn from today's reviews into
15934
+ * that page would mix the two. So the page says which cards it was drawn with (`snapshot`, from
15935
+ * `cardsSnapshot`), and when that is not what the server would draw now the answer is `stale`, with
15936
+ * no fragment: the change itself is stored either way, by lineage, and the page reloads to show it.
15937
+ */
15938
+ function answerCollections(res, outcome, refused, snapshot) {
15939
+ if (!outcome.ok) {
15940
+ if (outcome.reason === "missing") return send(res, 404, { error: "That collection has been deleted." });
15941
+ if (outcome.reason === "full") return send(res, 409, { error: "That is more than a collection can hold." });
15942
+ if (outcome.reason === "unsaved") return send(res, 500, { error: "The collections could not be saved." });
15943
+ return send(res, 409, {
15944
+ error: nameProblemText(outcome.reason),
15945
+ reason: outcome.reason
15946
+ });
15947
+ }
15948
+ const view = indexView();
15949
+ const collections = (view.collections ?? []).map(({ id, name }) => ({
15950
+ id,
15951
+ name
15952
+ }));
15953
+ if (snapshot === "" || snapshot !== view.snapshot) return send(res, 200, {
15954
+ stale: true,
15955
+ collections,
15956
+ ...outcome.id === void 0 ? {} : { id: outcome.id },
15957
+ refused
15958
+ });
15959
+ const memberOf = {};
15960
+ for (const group of view.groups) memberOf[group.latest.id] = (view.collections ?? []).filter((one) => one.members.includes(group.key)).map((one) => one.id);
15961
+ send(res, 200, {
15962
+ collections,
15963
+ ...outcome.id === void 0 ? {} : { id: outcome.id },
15964
+ memberOf,
15965
+ operations: operationCollections(view.operations ?? [], view.groups, membershipsByLineage(view.collections ?? [])),
15966
+ area: renderCollectionsArea(view),
15967
+ refused
15968
+ });
15969
+ }
15970
+ /**
15453
15971
  * Delete one review, or everything on one card.
15454
15972
  *
15455
15973
  * Authorised the same way pinning is, and for the same reason: this is the list acting on itself,
@@ -15606,6 +16124,13 @@ var MAX_LISTED_BRANCHES = 50;
15606
16124
  */
15607
16125
  var MAX_KEPT_BRANCHES = 50;
15608
16126
  /**
16127
+ * How many collected branches the list draws, in a window of their own beside the two above, so a
16128
+ * collection never competes with Pinned or the queue for a place. One collection's worth: a single
16129
+ * collection is always drawn whole, and only collections holding more than this between them leave
16130
+ * their oldest members out, which each collection then says.
16131
+ */
16132
+ var MAX_COLLECTED_BRANCHES = 500;
16133
+ /**
15609
16134
  * A finished review's workspace was served.
15610
16135
  *
15611
16136
  * **This counts page serves, not people and not sessions.** A reload, a restored tab, the CLI