prreviewbuddy 0.62.0 → 0.64.2

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 REPOSITORY_SCOPE, $n as presentedKissRunOf, A as reanalyseReview, An as saveJob, Ar as recommendationsOf, At as renderInlineText, B as RUNTIME_WORDS, Bn as followedRefName, Br as clearAgentPreference, Bt as removeWorktree, C as track, Cn as followedBranch, Cr as doneVerb, Ct as authorPreview, D as endRun, Dn as fail, Dr as questionsOutstanding, Dt as CODE_BLOCK_CLASS, E as completeRun, En as endedAt, Er as issuesOutstanding, Et as renderMarkdown, F as recordTelemetryUploadConsent, Fn as purposeOf, Ft as MANAGED_ROOT, G as partFiles, Gn as kissDecisionsOf, Gr as AGENT_IDS, Gt as compactQueue, H as ARCHITECTURE_DIR, Hn as indexKeptIds, Hr as writeAgentPreference, I as telemetryUploadConsent, Ir as isCommitReview, It as createWorktree, J as storeArchitecture, Jn as lineagePosition, Jr as agentFor, Jt as readIndexToken, K as readArchitecture, Kn as latestKissRun, L as waitingHistory, Lr as git, Lt as holdWorktree, Mn as isLiveJob, Mr as WITHHELD_REASON, Mt as extractJsonText, N as readEvents, Nn as phasesFor, Nr as kissOutcome, Nt as objectWithAnyOf, O as reportRun, On as isTerminal, Or as reviewerDispositions, Ot as CODE_COPY_CLASS, P as record, Pn as progressSteps, Pt as ReviewBeingDeletedError, Q as QUOTE_LIMITS, Qt as readServerRecord, R as mapRuntimes, Rn as isClaimed, Rt as isWorktreeHeld, S as isDriving, Sn as checkReview, Sr as readQueue, St as UNESTABLISHED_MOTIVATION, T as beginRun, Tn as allJobs, Tr as isQuestionOutstanding, Tt as GITHUB_MARKDOWN, U as checkArchitecture, Un as indexReviewGroups, Ut as relativeTime$1, V as describeRuntimes, Vn as groupByLineage, Vr as readAgentPreference, Vt as requestJobStop, W as loadArchitecture, Wn as kissDecisionRefusal, Wr as STORE_ROOT, Wt as activateQueued, X as trackedFiles, Xr as AgentCancelledError, Xt as clearServerRecordOwnedBy, Y as storedScopes, Yn as loadWorkspace, Z as ownersOf, Zn as mutateWorkspace, Zr as AgentUnavailableError, _ as liveUpdateFor, _r as sectionOf, _t as removeNotification, a as wasBlocked, an as writeServerRecord, ar as touchWorkspace, at as subjectOf, bn as fillFileUrlTemplate, br as queueRemove, bt as withUsageRecorded, c as RefreshUnavailableError, cn as feedbackUrl, cr as LIFECYCLES, ct as createPrUpdate, d as mergeUpdateInto, dn as MIN_WORKSPACE_PORT, dr as isLifecycle, dt as listNotifications, en as serverEntryExists, er as previousKissResult, et as ancestorsOf, f as updateReview, fn as parseWorkspacePort, fr as lifecycleChip, ft as markAllRead, g as UpdateAlreadyRunningError, gn as claimRequestUrl, gr as parseLifecycle, gt as readNotification, h as askCheckout, hn as writePortPreference, hr as offersLifecycle, ht as pruneNotifications, i as liveJobsFor, it as branchOf, j as runJob, jr as describeAuthorship, jt as asString, k as stopRun, kn as loadJob, kr as processDiscussion, kt as escapeHtml$1, l as cancelUpdate, ln as DEFAULT_WORKSPACE_PORT, lr as activityAttributes, lt as eventName, m as isUnchanged, mn as statedWorkspacePort, mr as lifecyclePill, mt as markReadForReview, n as deleteReview, nn as stopChild, nr as reviewedCommit, nt as writeJsonAtomically, o as discardJob, on as BUILD_VERSION, or as workspaceRevision, ot as createArchitectureNotification, p as refreshPrContext, pn as resolveWorkspacePort, pr as lifecycleItemLabel, pt as markRead, q as repositoryKey, qt as settleQueue, r as lineageIds, rt as announceJob, s as FETCH_TIMEOUT_MS, sr as workspaceStamps, st as createPrState, t as deleteLineage, tn as spawnServer, tr as recentWorkspaces, tt as isArchitectureId, u as cancellableHere, un as MAX_WORKSPACE_PORT, ur as activityLabel, ut as headline, vn as forgeFor, vr as queueAdd, vt as toneOf, w as RUN_KINDS, wn as reviewedShaOf, wr as isIssueOutstanding, wt as hasAuthorContent, x as startKissJob, xn as checkCodeFreshness, xr as queueStamp, xt as EXPLAIN_SIMPLY_PROMPT, y as agentEnvOf, yn as remoteHost, yr as queueAddMany, yt as unreadCount$1, z as recordRuntimeBestEffort, zn as decideKissRecommendation, zr as resolveModel, zt as readMarker } from "./delete_review-LzUgEUzN.js";
1
+ import { $ as storedScopes, $n as loadWorkspace, A as reanalyseReview, An as endedAt, Ar as issuesOutstanding, At as renderMarkdown, Br as isCommitReview, Bt as createWorktree, C as track, Cn as remoteHost, Cr as queueAddMany, Ct as unreadCount$1, D as endRun, Dn as followedBranch, Dr as doneVerb, Dt as authorPreview, E as completeRun, En as checkReview, Er as readQueue, Et as UNESTABLISHED_MOTIVATION, F as record, Fr as describeAuthorship, Ft as asString, G as describeRuntimes, Gn as groupByLineage, Gr as readAgentPreference, Gt as requestJobStop, H as mapRuntimes, Hn as isClaimed, Ht as isWorktreeHeld, In as isLiveJob, Ir as WITHHELD_REASON, It as extractJsonText, J as loadArchitecture, Jn as kissDecisionRefusal, Jr as STORE_ROOT, Jt as activateQueued, K as ARCHITECTURE_DIR, Kn as indexKeptIds, Kr as writeAgentPreference, Ln as phasesFor, Lr as kissOutcome, Lt as objectWithAnyOf, Mn as isTerminal, Mr as reviewerDispositions, Mt as CODE_COPY_CLASS, Nn as loadJob, Nr as processDiscussion, Nt as escapeHtml$1, O as reportRun, On as reviewedShaOf, Or as isIssueOutstanding, Ot as hasAuthorContent, P as readEvents, Pn as saveJob, Pr as recommendationsOf, Pt as renderInlineText, Q as storeArchitecture, Qn as lineagePosition, Qr as agentFor, Qt as readIndexToken, R as recordTelemetryUploadConsent, Rn as progressSteps, Rt as ReviewBeingDeletedError, S as isDriving, Sn as forgeFor, Sr as queueAdd, St as toneOf, T as beginRun, Tn as checkCodeFreshness, Tr as queueStamp, Tt as EXPLAIN_SIMPLY_PROMPT, U as recordRuntimeBestEffort, Un as decideKissRecommendation, Ur as resolveModel, Ut as readMarker, V as waitingHistory, Vr as git, Vt as holdWorktree, W as RUNTIME_WORDS, Wn as followedRefName, Wr as clearAgentPreference, Wt as removeWorktree, X as readArchitecture, Xn as latestKissRun, Y as partFiles, Yn as kissDecisionsOf, Yr as AGENT_IDS, Yt as compactQueue, Z as repositoryKey, Zt as settleQueue, _ as liveUpdateFor, _n as resolveWorkspacePort, _r as lifecycleItemLabel, _t as markRead, a as wasBlocked, an as spawnServer, ar as recentWorkspaces, at as isArchitectureId, bn as claimRequestUrl, br as parseLifecycle, bt as readNotification, c as RefreshUnavailableError, ct as branchOf, d as mergeUpdateInto, dr as workspaceStamps, dt as createPrState, ei as AgentCancelledError, en as clearServerRecordOwnedBy, et as trackedFiles, f as updateReview, fn as feedbackUrl, fr as LIFECYCLES, ft as createPrUpdate, g as UpdateAlreadyRunningError, gn as parseWorkspacePort, gr as lifecycleChip, gt as markAllRead, h as askCheckout, hn as MIN_WORKSPACE_PORT, hr as isLifecycle, ht as listNotifications, i as liveJobsFor, in as serverEntryExists, ir as previousKissResult, it as ancestorsOf, j as runJob, jn as fail, jr as questionsOutstanding, jt as CODE_BLOCK_CLASS, k as stopRun, kn as allJobs, kr as isQuestionOutstanding, kt as GITHUB_MARKDOWN, l as cancelUpdate, ln as writeServerRecord, lr as touchWorkspace, lt as subjectOf, m as isUnchanged, mn as MAX_WORKSPACE_PORT, mr as activityLabel, mt as headline, n as deleteReview, nn as readServerRecord, nt as QUOTE_LIMITS, o as discardJob, on as stopChild, or as reviewedCommit, ot as writeJsonAtomically, p as refreshPrContext, pn as DEFAULT_WORKSPACE_PORT, pr as activityAttributes, pt as eventName, q as checkArchitecture, qn as indexReviewGroups, qt as relativeTime$1, r as lineageIds, rr as presentedKissRunOf, rt as REPOSITORY_SCOPE, s as FETCH_TIMEOUT_MS, st as announceJob, t as deleteLineage, ti as AgentUnavailableError, tr as mutateWorkspace, tt as ownersOf, u as cancellableHere, un as BUILD_VERSION, ur as workspaceRevision, ut as createArchitectureNotification, vn as statedWorkspacePort, vr as lifecyclePill, vt as markReadForReview, w as RUN_KINDS, wn as fillFileUrlTemplate, wr as queueRemove, wt as withUsageRecorded, x as startKissJob, xr as sectionOf, xt as removeNotification, y as agentEnvOf, yn as writePortPreference, yr as offersLifecycle, yt as pruneNotifications, z as telemetryUploadConsent, zn as purposeOf, zt as MANAGED_ROOT } from "./delete_review-l4p_p6oh.js";
2
2
  import { basename, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
@@ -322,11 +322,35 @@ function countLabel(reviews, branches) {
322
322
  return `${label} · ${branches} branch${branches === 1 ? "" : "es"}`;
323
323
  }
324
324
  /**
325
- * Every login the author dropdown offers, once each, in alphabetical order regardless of case.
326
- * Read from the cards the page is showing, so it never lists somebody with nothing to show.
325
+ * Every login the author dropdown offers, once each, in alphabetical order of what the reader sees:
326
+ * the name where there is one, the login otherwise, regardless of case. Read from the cards the page
327
+ * is showing, so it never lists somebody with nothing to show.
328
+ *
329
+ * A login's name is the one on its newest review that has a name, chosen here by `createdAt` rather
330
+ * than by the order the caller happens to pass, so an account renamed upstream reads as it does now.
327
331
  */
328
- function authorOptions(handles) {
329
- return [...new Set(handles.filter((handle) => Boolean(handle)))].sort((a, b) => a.localeCompare(b, void 0, { sensitivity: "base" }));
332
+ function authorOptions(cards) {
333
+ const newest = /* @__PURE__ */ new Map();
334
+ for (const card of cards) {
335
+ if (!card.handle) continue;
336
+ const seen = newest.get(card.handle);
337
+ if (!seen) newest.set(card.handle, {
338
+ name: card.name || void 0,
339
+ createdAt: card.createdAt
340
+ });
341
+ else if (card.name && (!seen.name || card.createdAt > seen.createdAt)) newest.set(card.handle, {
342
+ name: card.name,
343
+ createdAt: card.createdAt
344
+ });
345
+ }
346
+ return [...newest].map(([handle, { name }]) => name && name !== handle ? {
347
+ handle,
348
+ name
349
+ } : { handle }).sort((a, b) => (a.name ?? a.handle).localeCompare(b.name ?? b.handle, void 0, { sensitivity: "base" }));
350
+ }
351
+ /** An author option as the dropdown spells it: the name and the login, or the login alone. */
352
+ function authorOptionLabel(option) {
353
+ return option.name ? `${option.name} · @${option.handle}` : `@${option.handle}`;
330
354
  }
331
355
  /**
332
356
  * A card menu's status item, which sets the state it names. The bulk bar's words for the same two
@@ -334,7 +358,7 @@ function authorOptions(handles) {
334
358
  * the browser relabels the item after a change, and that renderer reaches the store.
335
359
  */
336
360
  function markItemLabel(mark) {
337
- return mark === "complete" ? "Mark reviewed" : "Mark in progress";
361
+ return mark === "complete" ? "Mark reviewed" : "Mark not reviewed";
338
362
  }
339
363
  /**
340
364
  * The review page's Pin item, which says what pressing it will do. The words of the index's star,
@@ -371,7 +395,7 @@ function queueOptionLabel(queued) {
371
395
  */
372
396
  function statusLabel(status) {
373
397
  if (status === "complete") return "Reviewed";
374
- return status === "in-progress" ? "In progress" : "New";
398
+ return status === "in-progress" ? "Not reviewed" : "New";
375
399
  }
376
400
  /**
377
401
  * The bulk bar's way to tick everything the filters show, across pages: "Select all 7".
@@ -422,6 +446,206 @@ function anythingRunning(states) {
422
446
  var FETCH_REVIEW_LABEL = "Fetch remote updates";
423
447
  var FETCH_SELECTED_LABEL = "Fetch selected";
424
448
  //#endregion
449
+ //#region ../../packages/review-harness/src/domain/ui/components/message_copy.ts
450
+ var MESSAGE_COPY_CLASS = "pr-helper-msg-copy";
451
+ var MESSAGE_ACTIONS_CLASS = "pr-helper-chat-actions";
452
+ /** The Markdown itself, for surfaces that hold no message state to resolve an id against. */
453
+ var MESSAGE_SOURCE_ATTR = "data-copy-source";
454
+ var STATES$1 = {
455
+ idle: {
456
+ icon: "<path d=\"M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z\"></path><path d=\"M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z\"></path>",
457
+ label: "",
458
+ aria: "Copy message"
459
+ },
460
+ copied: {
461
+ icon: "<path d=\"M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z\"></path>",
462
+ label: "Copied",
463
+ aria: "Copied"
464
+ },
465
+ failed: {
466
+ icon: "<path d=\"M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z\"></path>",
467
+ label: "Copy failed",
468
+ aria: "Copy failed"
469
+ }
470
+ };
471
+ /**
472
+ * The same row, carrying the Markdown itself.
473
+ *
474
+ * For a surface with nothing to resolve an id against — the local workspace renders its stored
475
+ * transcript to HTML server-side and appends live answers straight to the DOM, so there is no
476
+ * conversation object on the client at all. Duplicating the source into the attribute is the
477
+ * honest trade there, and it is the same shape the prompt bank already uses to ship its data.
478
+ */
479
+ function renderMessageCopySource(source) {
480
+ return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"`);
481
+ }
482
+ /** The class that places a finding's copy control in its card's heading, and gives it its tooltip. */
483
+ var FINDING_COPY_CLASS = "pr-helper-finding-copy";
484
+ /**
485
+ * The same control on an issue or question card, carrying that finding as Markdown.
486
+ *
487
+ * It sits in the card's heading, not its footer. It copies the whole finding, so the heading is
488
+ * the thing it acts on. And it is a different kind of act from Investigate, Dismiss and Mark
489
+ * answered, which change the review: those keep the footer to themselves, so the footer holds
490
+ * only actions that change something.
491
+ *
492
+ * Icon only at rest, like a chat reply's. It used to show a word because it sat in a row of worded
493
+ * buttons, where an icon alone read as decoration. In the heading it is on its own, and a word
494
+ * would give a utility the weight of the title beside it. What it does is said by a hover
495
+ * tooltip (`data-tip`, the same CSS tooltip the pills use, not a native `title`) and by its
496
+ * accessible name. After a click it shows a green check and says "Copied" (or "Copy failed") in
497
+ * the bubble below it, the words used everywhere else, but not inline: beside it is Ask Buddy. The
498
+ * resting name and tip are kept on the button so the reset returns to "Copy finding" rather than to
499
+ * a chat reply's "Copy message".
500
+ */
501
+ function renderFindingCopySource(source, kind) {
502
+ const name = kind === "issue" ? "Copy finding" : kind === "question" ? "Copy question" : "Copy recommendation";
503
+ return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"`, {
504
+ label: "",
505
+ aria: name,
506
+ tip: name
507
+ });
508
+ }
509
+ /**
510
+ * The same control with its word showing, for a footer of worded actions.
511
+ *
512
+ * A KISS card's Copy draft comment sits among Plan to simplify and Leave it, where an icon alone
513
+ * would read as decoration. `extra` is for attributes the caller's own client needs, such as the
514
+ * handle Copy recommendations is found by; the source may be updated after render, since the
515
+ * listener reads it at the moment of the click.
516
+ */
517
+ function renderLabelledCopySource(source, label, extra = "") {
518
+ return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"${extra ? ` ${extra}` : ""}`, {
519
+ label,
520
+ aria: label,
521
+ tip: label
522
+ });
523
+ }
524
+ /** Where a finding's button keeps its resting word and name. Absent on a chat reply's. */
525
+ var IDLE_LABEL_ATTR = "data-copy-idle-label";
526
+ var IDLE_ARIA_ATTR = "data-copy-idle-aria";
527
+ var IDLE_TIP_ATTR = "data-copy-idle-tip";
528
+ function button(handle, idle) {
529
+ const { icon } = STATES$1.idle;
530
+ const label = idle?.label ?? STATES$1.idle.label;
531
+ const aria = idle?.aria ?? STATES$1.idle.aria;
532
+ const remembered = idle ? ` ${IDLE_LABEL_ATTR}="${escapeHtml$1(label)}" ${IDLE_ARIA_ATTR}="${escapeHtml$1(aria)}" ${IDLE_TIP_ATTR}="${escapeHtml$1(idle.tip)}"` : "";
533
+ const control = `<button type="button" class="${idle ? `${MESSAGE_COPY_CLASS} ${FINDING_COPY_CLASS}` : MESSAGE_COPY_CLASS}" ${handle}${remembered}${idle ? ` data-tip="${escapeHtml$1(idle.tip)}"` : ""} aria-label="${escapeHtml$1(aria)}"><span class="${MESSAGE_COPY_CLASS}-icon" aria-hidden="true">${svg(icon)}</span><span class="${MESSAGE_COPY_CLASS}-label"${label ? "" : " hidden"}>${escapeHtml$1(label)}</span></button>`;
534
+ return idle ? control : `<div class="${MESSAGE_ACTIONS_CLASS}">${control}</div>`;
535
+ }
536
+ function svg(paths) {
537
+ return `<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">${paths}</svg>`;
538
+ }
539
+ //#endregion
540
+ //#region ../../packages/review-harness/src/workspace/feedback_view.ts
541
+ /**
542
+ * Feedback selection: the reviewer picks the Issues and Questions they mean to raise with the
543
+ * author, across both panes, and takes them away as one Markdown draft.
544
+ *
545
+ * The words and the composition, in one module the server renders from and the client imports.
546
+ * The same split the inbox's bulk bar settled (`reviews_index.ts`): every sentence here carries a
547
+ * number only the browser knows, so the whole sentence lives where both sides can reach it, and
548
+ * where the copy style and vocabulary scans already look. Nothing here may import a node builtin.
549
+ *
550
+ * Each item is the text the card's own Copy button carries (`finding_copy.ts`), read from the card.
551
+ * There is one rendering of a finding as text, so what this exports, what Copy gives and what Ask
552
+ * Buddy is asked about can never disagree. That is also why no severity, theme or status appears:
553
+ * those sort the findings for the reviewer and are not part of what the author is told.
554
+ */
555
+ function count(n, noun) {
556
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
557
+ }
558
+ /**
559
+ * The tray's count line, in its three parts: "5 chosen", "2 issues", "3 questions".
560
+ *
561
+ * Chosen rather than selected: the reviewer is choosing what to tell the author, not operating a
562
+ * list.
563
+ * "Nothing chosen" rather than "0 chosen", the inbox bar's reason: the mode opens in this
564
+ * state, and a zero reads as a count of something that happened. A side with nothing picked is
565
+ * empty, and left out, rather than said as a zero beside the side that has some. The tray draws
566
+ * the two sides as buttons back to their panes; everywhere else reads the joined sentence.
567
+ */
568
+ function feedbackSelectionParts(issues, questions) {
569
+ const total = issues + questions;
570
+ return {
571
+ total: total === 0 ? "Nothing chosen" : `${total} chosen`,
572
+ issues: issues ? count(issues, "issue") : "",
573
+ questions: questions ? count(questions, "question") : ""
574
+ };
575
+ }
576
+ /** "5 chosen · 2 issues · 3 questions", "1 chosen · 1 question", "Nothing chosen". */
577
+ function feedbackSelectionLabel(issues, questions) {
578
+ const parts = feedbackSelectionParts(issues, questions);
579
+ return [
580
+ parts.total,
581
+ parts.issues,
582
+ parts.questions
583
+ ].filter(Boolean).join(" · ");
584
+ }
585
+ /**
586
+ * The pane's way into the mode, and out of it. Named for what it is for rather than for the
587
+ * mechanism ("Select"). It shares its name with the tray's last step on purpose, and the two are
588
+ * never on screen together: pressed, this one reads Done with a tick, the same act as the tray's
589
+ * Done, and that is when the tray appears. A checklist, not a sparkle, because nothing here is AI.
590
+ * Both faces are in the markup and the stylesheet shows the one `aria-pressed` calls for, so the
591
+ * hidden one is not part of the name a screen reader hears.
592
+ */
593
+ var PREPARE_LABEL = "Prepare feedback";
594
+ var DONE_LABEL = "Done";
595
+ var ICON = (paths) => `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
596
+ var LIST_CHECK = ICON("<path d=\"m3 17 2 2 4-4M3 7l2 2 4-4M13 6h8M13 12h8M13 18h8\"/>");
597
+ var CHECK = ICON("<path d=\"M20 6 9 17l-5-5\"/>");
598
+ function renderFeedbackSelectButton() {
599
+ return `<button type="button" class="prb-selection-toggle" data-feedback-select aria-pressed="false"><span class="prb-selection-toggle-idle">${LIST_CHECK}${PREPARE_LABEL}</span><span class="prb-selection-toggle-active">${CHECK}${DONE_LABEL}</span></button>`;
600
+ }
601
+ /**
602
+ * The tray, at the foot of the document pane on every view while the mode is on. The mode belongs
603
+ * to the review rather than to a pane, so leaving Issues does not take the tray with it, and its
604
+ * counts are the way back to each pane.
605
+ */
606
+ function renderFeedbackTray() {
607
+ return `<div class="prb-selection-tray" data-feedback-tray role="region" aria-label="Chosen for feedback" hidden>
608
+ <p class="prb-selection-count" aria-live="polite">
609
+ <span data-feedback-total>${escapeHtml$1(feedbackSelectionLabel(0, 0))}</span>${renderGoto("issues")}${renderGoto("questions")}
610
+ </p>
611
+ <span class="prb-selection-spacer"></span>
612
+ <button type="button" class="prb-selection-button" data-feedback-clear>Clear</button>
613
+ <button type="button" class="prb-selection-button" data-feedback-done>${DONE_LABEL}</button>
614
+ <button type="button" class="prb-selection-button prb-selection-primary" data-feedback-prepare disabled>${PREPARE_LABEL}</button>
615
+ </div>`;
616
+ }
617
+ /** A side's count, which is also the way back to its pane. The dot is text, so the line reads whole. */
618
+ function renderGoto(pane) {
619
+ return `<button type="button" class="prb-selection-goto" data-feedback-goto="${pane}" hidden><span class="prb-selection-sep" aria-hidden="true"> · </span><span data-feedback-goto-label></span></button>`;
620
+ }
621
+ /**
622
+ * Where the draft is read, edited and copied.
623
+ *
624
+ * Two views of one draft, as tabs, because that is what they are: Preview, the default, for reading
625
+ * what the author will see, and Markdown for changing it. Both are drawn from the same text, the
626
+ * text area's, so an edit is in the preview the moment the tab changes, and Copy takes exactly what
627
+ * is in the box: the client rewrites the button's source on every keystroke.
628
+ */
629
+ function renderFeedbackDialog() {
630
+ const tab = (name, label, selected) => `<button type="button" role="tab" class="prb-draft-tab" id="prb-draft-tab-${name}" data-draft-tab="${name}" aria-controls="prb-draft-${name}" aria-selected="${selected}" tabindex="${selected ? 0 : -1}">${label}</button>`;
631
+ return `<dialog class="prb-dialog prb-draft-dialog" data-feedback-dialog aria-labelledby="prb-feedback-title">
632
+ <div class="prb-draft-head">
633
+ <h2 id="prb-feedback-title">Prepare feedback</h2>
634
+ <p class="prb-draft-count" data-feedback-dialog-count></p>
635
+ </div>
636
+ <div class="prb-draft-tabs" role="tablist" aria-label="Draft">${tab("preview", "Preview", true)}${tab("markdown", "Markdown", false)}</div>
637
+ <div class="prb-draft-panel prb-draft-preview prb-md" id="prb-draft-preview" role="tabpanel" aria-labelledby="prb-draft-tab-preview" tabindex="0" data-feedback-preview></div>
638
+ <div class="prb-draft-panel" id="prb-draft-markdown" role="tabpanel" aria-labelledby="prb-draft-tab-markdown" hidden>
639
+ <textarea class="prb-draft-text" data-feedback-text aria-label="Markdown for the author" spellcheck="true" autocomplete="off"></textarea>
640
+ </div>
641
+ <div class="prb-dialog-actions">
642
+ <span class="prb-dialog-spacer"></span>
643
+ <button type="button" data-feedback-close>Close</button>
644
+ ${renderLabelledCopySource("", "Copy Markdown", "data-feedback-copy")}
645
+ </div>
646
+ </dialog>`;
647
+ }
648
+ //#endregion
425
649
  //#region ../../packages/review-harness/src/workspace/way_back.ts
426
650
  var CLI = {
427
651
  command: "prreviewbuddy open",
@@ -842,97 +1066,6 @@ function deriveReviewMetrics(result) {
842
1066
  };
843
1067
  }
844
1068
  //#endregion
845
- //#region ../../packages/review-harness/src/domain/ui/components/message_copy.ts
846
- var MESSAGE_COPY_CLASS = "pr-helper-msg-copy";
847
- var MESSAGE_ACTIONS_CLASS = "pr-helper-chat-actions";
848
- /** The Markdown itself, for surfaces that hold no message state to resolve an id against. */
849
- var MESSAGE_SOURCE_ATTR = "data-copy-source";
850
- var STATES$1 = {
851
- idle: {
852
- icon: "<path d=\"M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z\"></path><path d=\"M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z\"></path>",
853
- label: "",
854
- aria: "Copy message"
855
- },
856
- copied: {
857
- icon: "<path d=\"M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z\"></path>",
858
- label: "Copied",
859
- aria: "Copied"
860
- },
861
- failed: {
862
- icon: "<path d=\"M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z\"></path>",
863
- label: "Copy failed",
864
- aria: "Copy failed"
865
- }
866
- };
867
- /**
868
- * The same row, carrying the Markdown itself.
869
- *
870
- * For a surface with nothing to resolve an id against — the local workspace renders its stored
871
- * transcript to HTML server-side and appends live answers straight to the DOM, so there is no
872
- * conversation object on the client at all. Duplicating the source into the attribute is the
873
- * honest trade there, and it is the same shape the prompt bank already uses to ship its data.
874
- */
875
- function renderMessageCopySource(source) {
876
- return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"`);
877
- }
878
- /** The class that places a finding's copy control in its card's heading, and gives it its tooltip. */
879
- var FINDING_COPY_CLASS = "pr-helper-finding-copy";
880
- /**
881
- * The same control on an issue or question card, carrying that finding as Markdown.
882
- *
883
- * It sits in the card's heading, not its footer. It copies the whole finding, so the heading is
884
- * the thing it acts on. And it is a different kind of act from Investigate, Dismiss and Mark
885
- * answered, which change the review: those keep the footer to themselves, so the footer holds
886
- * only actions that change something.
887
- *
888
- * Icon only at rest, like a chat reply's. It used to show a word because it sat in a row of worded
889
- * buttons, where an icon alone read as decoration. In the heading it is on its own, and a word
890
- * would give a utility the weight of the title beside it. What it does is said by a hover
891
- * tooltip (`data-tip`, the same CSS tooltip the pills use, not a native `title`) and by its
892
- * accessible name. After a click it shows a green check and says "Copied" (or "Copy failed") in
893
- * the bubble below it, the words used everywhere else, but not inline: beside it is Ask Buddy. The
894
- * resting name and tip are kept on the button so the reset returns to "Copy finding" rather than to
895
- * a chat reply's "Copy message".
896
- */
897
- function renderFindingCopySource(source, kind) {
898
- const name = kind === "issue" ? "Copy finding" : kind === "question" ? "Copy question" : "Copy recommendation";
899
- return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"`, {
900
- label: "",
901
- aria: name,
902
- tip: name
903
- });
904
- }
905
- /**
906
- * The same control with its word showing, for a footer of worded actions.
907
- *
908
- * A KISS card's Copy draft comment sits among Plan to simplify and Leave it, where an icon alone
909
- * would read as decoration. `extra` is for attributes the caller's own client needs, such as the
910
- * handle Copy recommendations is found by; the source may be updated after render, since the
911
- * listener reads it at the moment of the click.
912
- */
913
- function renderLabelledCopySource(source, label, extra = "") {
914
- return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"${extra ? ` ${extra}` : ""}`, {
915
- label,
916
- aria: label,
917
- tip: label
918
- });
919
- }
920
- /** Where a finding's button keeps its resting word and name. Absent on a chat reply's. */
921
- var IDLE_LABEL_ATTR = "data-copy-idle-label";
922
- var IDLE_ARIA_ATTR = "data-copy-idle-aria";
923
- var IDLE_TIP_ATTR = "data-copy-idle-tip";
924
- function button(handle, idle) {
925
- const { icon } = STATES$1.idle;
926
- const label = idle?.label ?? STATES$1.idle.label;
927
- const aria = idle?.aria ?? STATES$1.idle.aria;
928
- const remembered = idle ? ` ${IDLE_LABEL_ATTR}="${escapeHtml$1(label)}" ${IDLE_ARIA_ATTR}="${escapeHtml$1(aria)}" ${IDLE_TIP_ATTR}="${escapeHtml$1(idle.tip)}"` : "";
929
- const control = `<button type="button" class="${idle ? `${MESSAGE_COPY_CLASS} ${FINDING_COPY_CLASS}` : MESSAGE_COPY_CLASS}" ${handle}${remembered}${idle ? ` data-tip="${escapeHtml$1(idle.tip)}"` : ""} aria-label="${escapeHtml$1(aria)}"><span class="${MESSAGE_COPY_CLASS}-icon" aria-hidden="true">${svg(icon)}</span><span class="${MESSAGE_COPY_CLASS}-label"${label ? "" : " hidden"}>${escapeHtml$1(label)}</span></button>`;
930
- return idle ? control : `<div class="${MESSAGE_ACTIONS_CLASS}">${control}</div>`;
931
- }
932
- function svg(paths) {
933
- return `<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">${paths}</svg>`;
934
- }
935
- //#endregion
936
1069
  //#region ../../packages/review-harness/src/domain/ui/components/ask_buddy_menu.ts
937
1070
  /**
938
1071
  * Ask Buddy on a finding card: a sparkle beside Copy that opens the assistant's actions for the
@@ -2397,6 +2530,7 @@ function renderIssuesTab(session, investigatingId, options) {
2397
2530
  <div class="pr-helper-issue-card pr-helper-severity-${issue.severity}${issue.reconciliationStatus ? ` pr-helper-issue-${issue.reconciliationStatus}` : ""}${issue.investigationImpact ? ` pr-helper-issue-investigation-${issue.investigationImpact}` : ""}${isDismissed && !stale ? " pr-helper-issue-card-dismissed" : ""}" data-finding-card="${escapeHtml$1(issue.id)}">
2398
2531
  <div class="pr-helper-issue-header">
2399
2532
  <div class="pr-helper-issue-title-row">
2533
+ ${options?.feedbackSelect ? renderFeedbackPick("issue", issue.title) : ""}
2400
2534
  <p class="pr-helper-issue-title">${renderInlineText(issue.title)}</p>
2401
2535
  ${renderFindingCopySource(issueMarkdown(issue), "issue")}
2402
2536
  ${options?.askBuddy ? renderAskBuddyMenu("issue") : ""}
@@ -2499,6 +2633,7 @@ function renderQuestionCard(q, sections, answered, records, now = Date.now(), in
2499
2633
  <div class="pr-helper-question-card${answered ? " pr-helper-question-card-answered" : ""}${q.investigationImpact ? ` pr-helper-issue-investigation-${q.investigationImpact}` : ""}" data-finding-card="${escapeHtml$1(q.id)}"${questionHome(q) ? ` data-section-id="${escapeHtml$1(questionHome(q))}"` : ""}>
2500
2634
  <div class="pr-helper-issue-header">
2501
2635
  <div class="pr-helper-issue-title-row">
2636
+ ${options?.feedbackSelect ? renderFeedbackPick("question", q.question) : ""}
2502
2637
  <p class="pr-helper-issue-title">${renderInlineText(q.question)}</p>
2503
2638
  ${renderFindingCopySource(questionMarkdown(q), "question")}
2504
2639
  ${options?.askBuddy ? renderAskBuddyMenu("question") : ""}
@@ -2550,6 +2685,17 @@ function renderQuestionsProgress(questions, answered) {
2550
2685
  if (counts.total < 2) return "";
2551
2686
  return `<div class="pr-helper-questions-progress" role="status">${questionsProgressBody(counts.total, counts.open)}</div>`;
2552
2687
  }
2688
+ /**
2689
+ * The card's feedback checkbox. Named by the finding, so a list of them is not one word announced
2690
+ * fifteen times. The id is the card's own `data-finding-card`.
2691
+ *
2692
+ * `autocomplete="off"` because the browser otherwise restores a box's tick when the page is loaded
2693
+ * again from history, and the selection it belonged to went with the page that made it: Back to a
2694
+ * review showed a ticked box that nothing had selected.
2695
+ */
2696
+ function renderFeedbackPick(kind, title) {
2697
+ return `<input type="checkbox" class="pr-helper-feedback-pick" data-feedback-pick="${kind}" autocomplete="off" aria-label="${escapeHtml$1(`Include in feedback: ${title.trim()}`)}">`;
2698
+ }
2553
2699
  function renderQuestionJump(file, options) {
2554
2700
  if (!file || options?.suppressFileJump) return "";
2555
2701
  return `<button type="button" class="pr-helper-question-jump" data-file="${escapeHtml$1(file)}">Jump to file →</button>`;
@@ -5441,6 +5587,7 @@ ${updating(view) ? "" : renderUpdateOutcome(view.lastUpdate)}
5441
5587
  ${updating(view) ? "" : renderLineageNotice(view)}
5442
5588
  ${renderReviewBody(view, sections)}
5443
5589
  ${view.token ? renderPromptDialog() : ""}
5590
+ ${offersFeedback(view) ? renderFeedbackDialog() : ""}
5444
5591
  <script type="module" src="/assets/workspace.js"><\/script>
5445
5592
  </body>
5446
5593
  </html>`;
@@ -5498,12 +5645,22 @@ function renderReviewBody(view, sections) {
5498
5645
  ${renderRail(view, sections, updating(view))}
5499
5646
  <main class="prb-doc">
5500
5647
  ${updating(view) ? renderUpdatingDoc(view) : renderReviewDoc(view, sections)}
5648
+ ${offersFeedback(view) ? renderFeedbackTray() : ""}
5501
5649
  </main>
5502
5650
  ${result ? renderAsk(view, sections, updating(view)) : ""}
5503
5651
  ${result ? renderAskTab() : ""}
5504
5652
  </div>`;
5505
5653
  }
5506
5654
  /**
5655
+ * Whether the reviewer can choose findings to raise with the author: a standard result with at least
5656
+ * one issue or question, and no update running over it. Select, the tray and the dialog are drawn
5657
+ * together or not at all.
5658
+ */
5659
+ function offersFeedback(view) {
5660
+ const result = view.session.result;
5661
+ return Boolean(result && (result.issues.length > 0 || result.questions.length > 0)) && !updating(view);
5662
+ }
5663
+ /**
5507
5664
  * Buddy AI's own way in: a tab on the window's right edge, where its pane opens.
5508
5665
  *
5509
5666
  * Out of the header, which says where the review stands; the assistant is a tool beside the review,
@@ -5658,15 +5815,19 @@ function renderStandardViews(view, sections) {
5658
5815
  ${renderDiagrams(view.diagrams ?? [], sections, view.reviewedSha ?? null)}
5659
5816
  </section>
5660
5817
  <section class="prb-view" data-view="issues" hidden>
5661
- ${renderDocHead("Issues", renderFocusToggle("issues", "Issues"))}
5818
+ ${renderDocHead("Issues", `${(result?.issues.length ?? 0) > 0 ? renderFeedbackSelectButton() : ""}${renderFocusToggle("issues", "Issues")}`)}
5662
5819
  ${renderAttributionAge(view)}
5663
- ${renderIssuesTab(session, null, { askBuddy: true })}
5820
+ ${renderIssuesTab(session, null, {
5821
+ askBuddy: true,
5822
+ feedbackSelect: true
5823
+ })}
5664
5824
  </section>
5665
5825
  <section class="prb-view" data-view="questions" hidden>
5666
- ${renderDocHead("Questions", renderFocusToggle("questions", "Questions"))}
5826
+ ${renderDocHead("Questions", `${(result?.questions.length ?? 0) > 0 ? renderFeedbackSelectButton() : ""}${renderFocusToggle("questions", "Questions")}`)}
5667
5827
  ${renderQuestionsTab(session, null, {
5668
5828
  suppressFileJump: true,
5669
- askBuddy: true
5829
+ askBuddy: true,
5830
+ feedbackSelect: true
5670
5831
  })}
5671
5832
  </section>
5672
5833
  <section class="prb-view" data-view="prcontext" hidden>
@@ -5763,7 +5924,7 @@ function renderLineageNotice(view) {
5763
5924
  * Two facts per row and no more. The commit is the one that does the real work, because two
5764
5925
  * analyses an hour apart are told apart by what they were of rather than by when they happened;
5765
5926
  * the risk says whether the newer one found something worse, which is the reason to go and read
5766
- * it. There is deliberately no `New` / `In progress` / `Reviewed` here: those describe where
5927
+ * it. There is deliberately no `New` / `Not reviewed` / `Reviewed` here: those describe where
5767
5928
  * somebody is in a piece of work, and on a superseded review that is a claim about work nobody
5768
5929
  * should be continuing. The index's history rows withhold it for the same reason.
5769
5930
  */
@@ -5894,7 +6055,7 @@ function renderReviewBar(view, freshness) {
5894
6055
  /**
5895
6056
  * Where this review stands, as pills that say and never do.
5896
6057
  *
5897
- * Two, because they answer two questions. Reviewed or In progress is what the reviewer has done;
6058
+ * Two, because they answer two questions. Reviewed or Not reviewed is what the reviewer has done;
5898
6059
  * Waiting or Done is what happens next with the branch. A review can be Reviewed and Waiting, so a
5899
6060
  * single pill would have to drop one of them. Active draws nothing, as an open pull request does
5900
6061
  * not: it is where every review starts, and a pill that never varies teaches nothing.
@@ -6047,8 +6208,8 @@ function renderRail(view, sections, stale = false) {
6047
6208
  ${themes || "<p class=\"prb-rail-heading\">No themes</p>"}
6048
6209
  </div>
6049
6210
  ${railLink("map", "diagrams", "Diagrams", diagramsBadge(diagramCount(view)), void 0, "diagram architecture")}
6050
- ${railLink("issues", "issues", "Issues", findingsBadge(issuesOutstanding(issues, dispositions)))}
6051
- ${railLink("questions", "questions", "Questions", remainingBadge(questionsOutstanding(questions, answered), "question", "answered"))}
6211
+ ${railLink("issues", "issues", "Issues", findingsBadge(issuesOutstanding(issues, dispositions)), void 0, void 0, true)}
6212
+ ${railLink("questions", "questions", "Questions", remainingBadge(questionsOutstanding(questions, answered), "question", "answered"), void 0, void 0, true)}
6052
6213
  ${railLink("files", "files", "Files", totalBadge(view.changeSet.data.files.length, "files"))}
6053
6214
  ${view.prContext ? railLink("prcontext", "prcontext", "PR context", totalBadge(view.prContext.processed.stats.totalThreads, "PR comment threads")) : railLink("prcontext", "prcontext", "PR context", null, "No pull request was connected to this review")}`
6054
6215
  }) : "";
@@ -6255,10 +6416,15 @@ function renderBadge(badge) {
6255
6416
  return `<span class="prb-rail-count${badge.done ? " prb-rail-count-done" : ""}" aria-label="${escapeHtml$1(badge.label)}">${escapeHtml$1(badge.text)}</span>`;
6256
6417
  }
6257
6418
  /** A null badge renders nothing, for the case where zero would be a claim rather than a total. */
6258
- function railLink(view, icon, label, badge, note, alsoCurrentOn) {
6419
+ /**
6420
+ * `selectable` leaves room for the feedback selection's quiet "2 selected" under the title, empty and
6421
+ * hidden until the client has something to say there.
6422
+ */
6423
+ function railLink(view, icon, label, badge, note, alsoCurrentOn, selectable = false) {
6424
+ const selected = selectable ? `<span class="prb-rail-selected" data-rail-selected="${view}" hidden></span>` : "";
6259
6425
  return `<a class="prb-rail-item prb-rail-link" href="${escapeHtml$1(hashForView(view))}" data-view-nav="${view}"${alsoCurrentOn ? ` data-view-also="${alsoCurrentOn}"` : ""}${note ? ` title="${escapeHtml$1(note)}"` : ""}>
6260
6426
  ${railIcon(icon)}
6261
- <span class="prb-rail-label"><span class="prb-rail-title">${escapeHtml$1(label)}</span></span>
6427
+ <span class="prb-rail-label"><span class="prb-rail-title">${escapeHtml$1(label)}</span>${selected}</span>
6262
6428
  ${renderBadge(badge)}
6263
6429
  </a>`;
6264
6430
  }
@@ -7427,7 +7593,7 @@ async function fetchHead(cwd, request, env = {}) {
7427
7593
  * What each pull request's forge last said about whether it is still open.
7428
7594
  *
7429
7595
  * **A fact about the request, never about the review.** A review can be `Reviewed` while its PR is
7430
- * open, and `In progress` while its PR has merged; the reviewer's decision lives on the workspace
7596
+ * open, and `Not reviewed` while its PR has merged; the reviewer's decision lives on the workspace
7431
7597
  * record as `completedAt`, and nothing here reads or writes it.
7432
7598
  *
7433
7599
  * **Its own file, not a field of `watch.json`**, because the two have different lives. `watch.json`
@@ -8775,7 +8941,11 @@ ${renderGlobalBar({
8775
8941
  unread: view.unreadNotifications ?? 0,
8776
8942
  driving: view.driving
8777
8943
  })}
8778
- ${renderContextBar("Reviews", ordered.length === 0 ? "" : renderControls(queue.length, authorOptions(ordered.map((group) => group.latest.authorHandle)), ordered.some((group) => group.latest.prNumber !== void 0)))}
8944
+ ${renderContextBar("Reviews", ordered.length === 0 ? "" : renderControls(queue.length, authorOptions(ordered.map(({ latest }) => ({
8945
+ handle: latest.authorHandle,
8946
+ name: latest.authorName,
8947
+ createdAt: latest.createdAt
8948
+ }))), ordered.some((group) => group.latest.prNumber !== void 0)))}
8779
8949
  <div class="prb-shell">
8780
8950
  ${renderWorkspaceRail({
8781
8951
  current: "reviews",
@@ -8801,19 +8971,19 @@ ${renderWorkspaceRail({
8801
8971
  *
8802
8972
  * Search covers everything a person might remember about a review — the repository, the branch,
8803
8973
  * the title, the model's summary, and the paths it touched — because which of those they recall
8804
- * is not predictable. The filters are the three states and nothing else; a filter per risk level
8805
- * would be four more controls for a question ("show me only the risky ones") that sorting by
8806
- * recency already answers badly and that scanning answers well.
8974
+ * is not predictable. The review state filter is the reviewer's own say and nothing else; a filter
8975
+ * per risk level would be four more controls for a question ("show me only the risky ones") that
8976
+ * sorting by recency already answers badly and that scanning answers well.
8807
8977
  */
8808
8978
  function renderControls(queued, authors, anyRequest) {
8809
8979
  return `<div class="prb-index-controls">
8810
8980
  <input type="search" class="prb-index-search" data-search placeholder="Search repo, branch, title, summary or file…" aria-label="Search reviews" spellcheck="false">
8811
8981
  ${renderAuthorFilter(authors)}
8812
- <label class="prb-index-status">Status:
8813
- <select data-status-filter aria-label="Filter by status">
8982
+ <label class="prb-index-status">Review state:
8983
+ <select data-status-filter aria-label="Filter by review state">
8814
8984
  <option value="all">All</option>
8815
8985
  <option value="queue" data-queue-option>${escapeHtml$1(queueOptionLabel(queued))}</option>
8816
- <option value="in-progress">In progress</option>
8986
+ <option value="in-progress">Not reviewed</option>
8817
8987
  <option value="complete">Reviewed</option>
8818
8988
  </select>
8819
8989
  </label>
@@ -8821,13 +8991,13 @@ function renderControls(queued, authors, anyRequest) {
8821
8991
  </div>`;
8822
8992
  }
8823
8993
  /**
8824
- * The pull request's state upstream, beside Status and not inside it, so the two combine.
8994
+ * The pull request's state upstream, beside Review state and not inside it, so the two combine.
8825
8995
  *
8826
8996
  * Absent when no card is of a pull request, for the author filter's reason: a select whose every
8827
8997
  * choice but Any shows nothing is not a choice.
8828
8998
  */
8829
8999
  function renderPrFilter() {
8830
- return `<label class="prb-index-status">PR:
9000
+ return `<label class="prb-index-status prb-index-forge">${GITHUB_MARK}PR:
8831
9001
  <select data-pr-filter aria-label="Filter by pull request state">${PR_FILTER_OPTIONS.map((option) => `<option value="${option.value}">${escapeHtml$1(option.label)}</option>`).join("")}</select>
8832
9002
  </label>`;
8833
9003
  }
@@ -8837,16 +9007,25 @@ function prStateOf(review, states) {
8837
9007
  return states?.get(requestKey(review.repoPath, review.prNumber));
8838
9008
  }
8839
9009
  /**
8840
- * Whose pull requests to show.
9010
+ * Marks the filters whose answers come from GitHub, Author and PR, so the mark means one thing.
9011
+ * Review state is the reviewer's own and goes without. Hidden from assistive technology: each
9012
+ * select's label already names it.
9013
+ */
9014
+ var GITHUB_MARK = "<svg class=\"prb-index-forge-mark\" viewBox=\"0 0 16 16\" width=\"14\" height=\"14\" focusable=\"false\" aria-hidden=\"true\" fill=\"currentColor\"><path d=\"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z\"/></svg>";
9015
+ /**
9016
+ * Whose pull requests to show, by name where GitHub gave one. The login is still what it filters
9017
+ * by, and is kept beside the name because two people can share a name and nobody shares a login.
8841
9018
  *
8842
9019
  * Absent when no card carries a login, which is every page of local-branch reviews: a dropdown
8843
9020
  * whose only choice is Everyone is not a choice.
8844
9021
  */
8845
9022
  function renderAuthorFilter(authors) {
8846
9023
  if (authors.length === 0) return "";
8847
- return `<select class="prb-index-author" name="author" data-author-filter aria-label="Filter by pull request author">
8848
- <option value="">Everyone</option>${authors.map((handle) => `<option value="${escapeHtml$1(handle)}">@${escapeHtml$1(handle)}</option>`).join("")}
8849
- </select>`;
9024
+ return `<label class="prb-index-status prb-index-forge">${GITHUB_MARK}Author:
9025
+ <select name="author" data-author-filter aria-label="Filter by pull request author">
9026
+ <option value="">Everyone</option>${authors.map((author) => `<option value="${escapeHtml$1(author.handle)}">${escapeHtml$1(authorOptionLabel(author))}</option>`).join("")}
9027
+ </select>
9028
+ </label>`;
8850
9029
  }
8851
9030
  /**
8852
9031
  * One section's Load more, which the browser shows once that section holds more than it is showing.
@@ -8899,8 +9078,8 @@ function renderBulkBar() {
8899
9078
  <button type="button" class="prb-bulk-button" data-bulk-select-all>${escapeHtml$1(selectAllLabel(0))}</button>
8900
9079
  <button type="button" class="prb-bulk-button" data-bulk-fetch hidden>${escapeHtml$1(FETCH_SELECTED_LABEL)}</button>
8901
9080
  <button type="button" class="prb-bulk-button" data-bulk-queue disabled>Add to review queue</button>
8902
- <button type="button" class="prb-bulk-button" data-bulk-mark="complete">Mark reviewed</button>
8903
- <button type="button" class="prb-bulk-button" data-bulk-mark="in-progress">Mark in progress</button>
9081
+ <button type="button" class="prb-bulk-button" data-bulk-mark="complete">${markItemLabel("complete")}</button>
9082
+ <button type="button" class="prb-bulk-button" data-bulk-mark="in-progress">${markItemLabel("in-progress")}</button>
8904
9083
  ${LIFECYCLES.map((lifecycle) => `<button type="button" class="prb-bulk-button" data-bulk-lifecycle="${lifecycle.id}">${escapeHtml$1(lifecycle.action)}</button>`).join("\n ")}
8905
9084
  <button type="button" class="prb-bulk-button prb-bulk-button-danger" data-bulk-delete></button>
8906
9085
  <button type="button" class="prb-bulk-button prb-bulk-cancel" data-bulk-cancel>Cancel</button>
@@ -9096,9 +9275,9 @@ function renderHistory(group, now) {
9096
9275
  * branch started. The same numbering the workspace itself uses, so "Review 1 of 3" there and
9097
9276
  * "Review 1" here are the same review.
9098
9277
  *
9099
- * **Only the latest row carries a workflow status.** `New`, `In progress` and `Reviewed` describe
9278
+ * **Only the latest row carries a workflow status.** `New`, `Not reviewed` and `Reviewed` describe
9100
9279
  * where someone is in a piece of work, and on a superseded review that is a claim about work
9101
- * nobody should be continuing: a row reading `IN PROGRESS` under a newer review of the same branch
9280
+ * nobody should be continuing: a row reading `NOT REVIEWED` under a newer review of the same branch
9102
9281
  * invites exactly the wrong click. The second slot is the lineage instead, `Latest` or `Previous`,
9103
9282
  * which is the only thing about an older review that a reader needs before opening it.
9104
9283
  */
@@ -11583,17 +11762,17 @@ function handleNotificationOpen(req, res, params, indexToken) {
11583
11762
  return redirect(res, `/r/${workspace.id}?t=${encodeURIComponent(workspace.token)}${hash}`);
11584
11763
  }
11585
11764
  /**
11586
- * Mark a selection reviewed, or put it back to in progress.
11765
+ * Mark a selection reviewed, or put it back to not reviewed.
11587
11766
  *
11588
11767
  * Authorised by the index credential, like pinning and deleting, because this is the list acting on
11589
11768
  * itself. Deliberately not `/api/<id>/state`, which needs a review's own token: routing a bulk mark
11590
11769
  * through it would mean putting fifteen credentials into one request, and the index is the one page
11591
11770
  * that holds them all.
11592
11771
  *
11593
- * **Marking in progress fills in `lastViewedAt` where there is none.** Clearing `completedAt` alone
11772
+ * **Marking not reviewed fills in `lastViewedAt` where there is none.** Clearing `completedAt` alone
11594
11773
  * would leave a review that was never opened showing `New`, and a button that names a state has to
11595
- * produce it. Saying a change is in progress is a claim that the work has started, which is what
11596
- * that field records. It is only ever filled in, never moved, so a review that has been opened
11774
+ * produce it. Taking the mark back is a claim that the work has started, which is what that field
11775
+ * records. It is only ever filled in, never moved, so a review that has been opened
11597
11776
  * keeps the time it was opened.
11598
11777
  *
11599
11778
  * No lock and no running-job check, unlike deletion. A mark is a note about where the reviewer is,
@@ -12936,7 +13115,11 @@ function applyState(workspace, body) {
12936
13115
  if (Array.isArray(body?.dismissedIssues)) reviewer.dismissedIssues = body.dismissedIssues.filter((entry) => typeof entry?.id === "string" && typeof entry?.sessionId === "string");
12937
13116
  if (typeof body?.dismissFreshnessSha === "string" || body?.dismissFreshnessSha === null) workspace.dismissedFreshnessSha = body.dismissFreshnessSha ?? void 0;
12938
13117
  if (typeof body?.dismissKissFreshnessSha === "string" || body?.dismissKissFreshnessSha === null) workspace.dismissedKissFreshnessSha = body.dismissKissFreshnessSha ?? void 0;
12939
- if (typeof body?.complete === "boolean") workspace.completedAt = body.complete ? Date.now() : void 0;
13118
+ if (typeof body?.complete === "boolean") {
13119
+ const now = Date.now();
13120
+ workspace.completedAt = body.complete ? now : void 0;
13121
+ if (!body.complete) workspace.lastViewedAt = workspace.lastViewedAt ?? now;
13122
+ }
12940
13123
  if (typeof body?.pinned === "boolean") workspace.pinned = body.pinned;
12941
13124
  }
12942
13125
  /** 1 MB is far more than any question or id list, and far less than a memory problem. */