prreviewbuddy 0.63.0 → 0.69.1

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,7 +1,9 @@
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-CeJo9t7h.js";
1
+ import { $ as MAX_WORKSPACE_PORT, $n as isClaimed, $r as resolveAgent, $t as markReadForReview, A as relativeTime$1, An as isWorktreeHeld, Ar as parseLifecycle, At as partFiles, B as readIndexToken, Bn as reviewedShaOf, Br as issuesOutstanding, Bt as isArchitectureId, C as track, Cn as originRemote, Cr as activityAttributes, Ct as mapRuntimes, D as endRun, Dn as MANAGED_ROOT, Dr as lifecycleItemLabel, Dt as ARCHITECTURE_DIR, E as completeRun, En as ReviewBeingDeletedError, Er as lifecycleChip, Et as describeRuntimes, F as runBatch, Fr as queueStamp, Ft as trackedFiles, Gn as loadJob, Gr as describeAuthorship, Gt as createArchitectureNotification, H as clearServerRecordOwnedBy, Hn as endedAt, Hr as reviewerDispositions, Ht as announceJob, I as activateQueued, In as findExistingReview, Ir as readQueue, It as ownersOf, J as stopChild, Jn as isLiveJob, Jr as resolveTarget, Jt as eventName, K as serverEntryExists, Kn as saveJob, Kr as WITHHELD_REASON, Kt as createPrState, L as compactQueue, Ln as checkCodeFreshness, Lr as doneVerb, Lt as QUOTE_LIMITS, M as extractPullRequestLinks, Mn as removeWorktree, Mr as queueAdd, Mt as repositoryKey, N as parsePullRequestLink, Nn as requestJobStop, Nr as queueAddMany, Nt as storeArchitecture, O as reportRun, On as createWorktree, Or as lifecyclePill, Ot as checkArchitecture, P as unsupportedReason, Pr as queueRemove, Pt as storedScopes, Q as DEFAULT_WORKSPACE_PORT, Qr as git, Qt as markRead, Rn as checkReview, Rr as isIssueOutstanding, Rt as REPOSITORY_SCOPE, S as isDriving, Sn as remoteHost, Sr as LIFECYCLES, St as waitingHistory, T as beginRun, Tr as isLifecycle, Tt as RUNTIME_WORDS, Un as fail, Ur as processDiscussion, Ut as branchOf, Vn as allJobs, Vr as questionsOutstanding, Vt as writeJsonAtomically, W as readServerRecord, Wn as isTerminal, Wr as recommendationsOf, Wt as subjectOf, Xn as progressSteps, Xr as isCommitReview, Xt as listNotifications, Yn as phasesFor, Yr as DetachedHeadError, Yt as headline, Z as writeServerRecord, Zn as purposeOf, Zr as resolveBase, Zt as markAllRead, _ as liveUpdateFor, _n as extractJsonText, _t as telemetryUploadConsent, a as wasBlocked, ai as STORE_ROOT, an as withUsageRecorded, ar as kissDecisionRefusal, at as failureOf$1, bn as forgeResolver, br as workspaceRevision, c as RefreshUnavailableError, ci as agentFor$1, cn as authorPreview, cr as lineagePosition, ct as reanalyseReview, d as mergeUpdateInto, di as AgentUnavailableError, dn as renderMarkdown, dr as mutateWorkspace, ei as resolveModel, en as pruneNotifications, er as decideKissRecommendation, et as MIN_WORKSPACE_PORT, f as updateReview, fn as CODE_BLOCK_CLASS, ft as readEvents, g as UpdateAlreadyRunningError, gn as asString, gr as reviewedCommit, gt as recordTelemetryUploadConsent, h as askCheckout, hn as renderInlineText, hr as recentWorkspaces, i as liveJobsFor, in as unreadCount$1, ir as indexReviewGroups, it as writePortPreference, j as batchIdentity, jn as readMarker, jr as sectionOf, jt as readArchitecture, k as stopRun, kn as holdWorktree, kr as offersLifecycle, kt as loadArchitecture, l as cancelUpdate, li as detectAgents, ln as hasAuthorContent, lr as loadWorkspace, lt as runJob, m as isUnchanged, mn as escapeHtml$1, mr as previousKissResult, n as deleteReview, ni as readAgentPreference, nn as removeNotification, nr as groupByLineage, nt as resolveWorkspacePort, o as discardJob, oi as AGENT_IDS, on as EXPLAIN_SIMPLY_PROMPT, or as kissDecisionsOf, ot as prepareReview, p as refreshPrContext, pn as CODE_COPY_CLASS, pr as presentedKissRunOf, pt as record, q as spawnServer, qr as kissOutcome, qt as createPrUpdate, r as lineageIds, ri as writeAgentPreference, rn as toneOf, rr as indexKeptIds, rt as statedWorkspacePort, s as FETCH_TIMEOUT_MS, si as agentById, sn as UNESTABLISHED_MOTIVATION, sr as latestKissRun, st as settleReview, t as deleteLineage, ti as clearAgentPreference, tn as readNotification, tr as followedRefName, tt as parseWorkspacePort, u as cancellableHere, ui as AgentCancelledError, un as GITHUB_MARKDOWN, vn as objectWithAnyOf, w as RUN_KINDS, wn as fillFileUrlTemplate, wr as activityLabel, wt as recordRuntimeBestEffort, x as startKissJob, xn as forgeFor, xr as workspaceStamps, xt as feedbackUrl, y as agentEnvOf, yn as claimRequestUrl, yr as touchWorkspace, yt as BUILD_VERSION, z as settleQueue, zn as followedBranch, zr as isQuestionOutstanding, zt as ancestorsOf } from "./delete_review-CRPRIRKg.js";
2
2
  import { basename, isAbsolute, join, relative, resolve } from "node:path";
3
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { execFile } from "node:child_process";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
4
5
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
6
+ import { homedir } from "node:os";
5
7
  import { fileURLToPath } from "node:url";
6
8
  import { createServer } from "node:http";
7
9
  //#region ../../packages/review-harness/src/domain/ai_review/review_composition.ts
@@ -322,11 +324,35 @@ function countLabel(reviews, branches) {
322
324
  return `${label} · ${branches} branch${branches === 1 ? "" : "es"}`;
323
325
  }
324
326
  /**
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.
327
+ * Every login the author dropdown offers, once each, in alphabetical order of what the reader sees:
328
+ * the name where there is one, the login otherwise, regardless of case. Read from the cards the page
329
+ * is showing, so it never lists somebody with nothing to show.
330
+ *
331
+ * A login's name is the one on its newest review that has a name, chosen here by `createdAt` rather
332
+ * than by the order the caller happens to pass, so an account renamed upstream reads as it does now.
327
333
  */
328
- function authorOptions(handles) {
329
- return [...new Set(handles.filter((handle) => Boolean(handle)))].sort((a, b) => a.localeCompare(b, void 0, { sensitivity: "base" }));
334
+ function authorOptions(cards) {
335
+ const newest = /* @__PURE__ */ new Map();
336
+ for (const card of cards) {
337
+ if (!card.handle) continue;
338
+ const seen = newest.get(card.handle);
339
+ if (!seen) newest.set(card.handle, {
340
+ name: card.name || void 0,
341
+ createdAt: card.createdAt
342
+ });
343
+ else if (card.name && (!seen.name || card.createdAt > seen.createdAt)) newest.set(card.handle, {
344
+ name: card.name,
345
+ createdAt: card.createdAt
346
+ });
347
+ }
348
+ return [...newest].map(([handle, { name }]) => name && name !== handle ? {
349
+ handle,
350
+ name
351
+ } : { handle }).sort((a, b) => (a.name ?? a.handle).localeCompare(b.name ?? b.handle, void 0, { sensitivity: "base" }));
352
+ }
353
+ /** An author option as the dropdown spells it: the name and the login, or the login alone. */
354
+ function authorOptionLabel(option) {
355
+ return option.name ? `${option.name} · @${option.handle}` : `@${option.handle}`;
330
356
  }
331
357
  /**
332
358
  * A card menu's status item, which sets the state it names. The bulk bar's words for the same two
@@ -334,7 +360,7 @@ function authorOptions(handles) {
334
360
  * the browser relabels the item after a change, and that renderer reaches the store.
335
361
  */
336
362
  function markItemLabel(mark) {
337
- return mark === "complete" ? "Mark reviewed" : "Mark in progress";
363
+ return mark === "complete" ? "Mark reviewed" : "Mark not reviewed";
338
364
  }
339
365
  /**
340
366
  * The review page's Pin item, which says what pressing it will do. The words of the index's star,
@@ -371,7 +397,7 @@ function queueOptionLabel(queued) {
371
397
  */
372
398
  function statusLabel(status) {
373
399
  if (status === "complete") return "Reviewed";
374
- return status === "in-progress" ? "In progress" : "New";
400
+ return status === "in-progress" ? "Not reviewed" : "New";
375
401
  }
376
402
  /**
377
403
  * The bulk bar's way to tick everything the filters show, across pages: "Select all 7".
@@ -422,6 +448,206 @@ function anythingRunning(states) {
422
448
  var FETCH_REVIEW_LABEL = "Fetch remote updates";
423
449
  var FETCH_SELECTED_LABEL = "Fetch selected";
424
450
  //#endregion
451
+ //#region ../../packages/review-harness/src/domain/ui/components/message_copy.ts
452
+ var MESSAGE_COPY_CLASS = "pr-helper-msg-copy";
453
+ var MESSAGE_ACTIONS_CLASS = "pr-helper-chat-actions";
454
+ /** The Markdown itself, for surfaces that hold no message state to resolve an id against. */
455
+ var MESSAGE_SOURCE_ATTR = "data-copy-source";
456
+ var STATES$1 = {
457
+ idle: {
458
+ 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>",
459
+ label: "",
460
+ aria: "Copy message"
461
+ },
462
+ copied: {
463
+ 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>",
464
+ label: "Copied",
465
+ aria: "Copied"
466
+ },
467
+ failed: {
468
+ 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>",
469
+ label: "Copy failed",
470
+ aria: "Copy failed"
471
+ }
472
+ };
473
+ /**
474
+ * The same row, carrying the Markdown itself.
475
+ *
476
+ * For a surface with nothing to resolve an id against — the local workspace renders its stored
477
+ * transcript to HTML server-side and appends live answers straight to the DOM, so there is no
478
+ * conversation object on the client at all. Duplicating the source into the attribute is the
479
+ * honest trade there, and it is the same shape the prompt bank already uses to ship its data.
480
+ */
481
+ function renderMessageCopySource(source) {
482
+ return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"`);
483
+ }
484
+ /** The class that places a finding's copy control in its card's heading, and gives it its tooltip. */
485
+ var FINDING_COPY_CLASS = "pr-helper-finding-copy";
486
+ /**
487
+ * The same control on an issue or question card, carrying that finding as Markdown.
488
+ *
489
+ * It sits in the card's heading, not its footer. It copies the whole finding, so the heading is
490
+ * the thing it acts on. And it is a different kind of act from Investigate, Dismiss and Mark
491
+ * answered, which change the review: those keep the footer to themselves, so the footer holds
492
+ * only actions that change something.
493
+ *
494
+ * Icon only at rest, like a chat reply's. It used to show a word because it sat in a row of worded
495
+ * buttons, where an icon alone read as decoration. In the heading it is on its own, and a word
496
+ * would give a utility the weight of the title beside it. What it does is said by a hover
497
+ * tooltip (`data-tip`, the same CSS tooltip the pills use, not a native `title`) and by its
498
+ * accessible name. After a click it shows a green check and says "Copied" (or "Copy failed") in
499
+ * the bubble below it, the words used everywhere else, but not inline: beside it is Ask Buddy. The
500
+ * resting name and tip are kept on the button so the reset returns to "Copy finding" rather than to
501
+ * a chat reply's "Copy message".
502
+ */
503
+ function renderFindingCopySource(source, kind) {
504
+ const name = kind === "issue" ? "Copy finding" : kind === "question" ? "Copy question" : "Copy recommendation";
505
+ return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"`, {
506
+ label: "",
507
+ aria: name,
508
+ tip: name
509
+ });
510
+ }
511
+ /**
512
+ * The same control with its word showing, for a footer of worded actions.
513
+ *
514
+ * A KISS card's Copy draft comment sits among Plan to simplify and Leave it, where an icon alone
515
+ * would read as decoration. `extra` is for attributes the caller's own client needs, such as the
516
+ * handle Copy recommendations is found by; the source may be updated after render, since the
517
+ * listener reads it at the moment of the click.
518
+ */
519
+ function renderLabelledCopySource(source, label, extra = "") {
520
+ return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"${extra ? ` ${extra}` : ""}`, {
521
+ label,
522
+ aria: label,
523
+ tip: label
524
+ });
525
+ }
526
+ /** Where a finding's button keeps its resting word and name. Absent on a chat reply's. */
527
+ var IDLE_LABEL_ATTR = "data-copy-idle-label";
528
+ var IDLE_ARIA_ATTR = "data-copy-idle-aria";
529
+ var IDLE_TIP_ATTR = "data-copy-idle-tip";
530
+ function button(handle, idle) {
531
+ const { icon } = STATES$1.idle;
532
+ const label = idle?.label ?? STATES$1.idle.label;
533
+ const aria = idle?.aria ?? STATES$1.idle.aria;
534
+ const remembered = idle ? ` ${IDLE_LABEL_ATTR}="${escapeHtml$1(label)}" ${IDLE_ARIA_ATTR}="${escapeHtml$1(aria)}" ${IDLE_TIP_ATTR}="${escapeHtml$1(idle.tip)}"` : "";
535
+ 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>`;
536
+ return idle ? control : `<div class="${MESSAGE_ACTIONS_CLASS}">${control}</div>`;
537
+ }
538
+ function svg(paths) {
539
+ return `<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">${paths}</svg>`;
540
+ }
541
+ //#endregion
542
+ //#region ../../packages/review-harness/src/workspace/feedback_view.ts
543
+ /**
544
+ * Feedback selection: the reviewer picks the Issues and Questions they mean to raise with the
545
+ * author, across both panes, and takes them away as one Markdown draft.
546
+ *
547
+ * The words and the composition, in one module the server renders from and the client imports.
548
+ * The same split the inbox's bulk bar settled (`reviews_index.ts`): every sentence here carries a
549
+ * number only the browser knows, so the whole sentence lives where both sides can reach it, and
550
+ * where the copy style and vocabulary scans already look. Nothing here may import a node builtin.
551
+ *
552
+ * Each item is the text the card's own Copy button carries (`finding_copy.ts`), read from the card.
553
+ * There is one rendering of a finding as text, so what this exports, what Copy gives and what Ask
554
+ * Buddy is asked about can never disagree. That is also why no severity, theme or status appears:
555
+ * those sort the findings for the reviewer and are not part of what the author is told.
556
+ */
557
+ function count(n, noun) {
558
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
559
+ }
560
+ /**
561
+ * The tray's count line, in its three parts: "5 chosen", "2 issues", "3 questions".
562
+ *
563
+ * Chosen rather than selected: the reviewer is choosing what to tell the author, not operating a
564
+ * list.
565
+ * "Nothing chosen" rather than "0 chosen", the inbox bar's reason: the mode opens in this
566
+ * state, and a zero reads as a count of something that happened. A side with nothing picked is
567
+ * empty, and left out, rather than said as a zero beside the side that has some. The tray draws
568
+ * the two sides as buttons back to their panes; everywhere else reads the joined sentence.
569
+ */
570
+ function feedbackSelectionParts(issues, questions) {
571
+ const total = issues + questions;
572
+ return {
573
+ total: total === 0 ? "Nothing chosen" : `${total} chosen`,
574
+ issues: issues ? count(issues, "issue") : "",
575
+ questions: questions ? count(questions, "question") : ""
576
+ };
577
+ }
578
+ /** "5 chosen · 2 issues · 3 questions", "1 chosen · 1 question", "Nothing chosen". */
579
+ function feedbackSelectionLabel(issues, questions) {
580
+ const parts = feedbackSelectionParts(issues, questions);
581
+ return [
582
+ parts.total,
583
+ parts.issues,
584
+ parts.questions
585
+ ].filter(Boolean).join(" · ");
586
+ }
587
+ /**
588
+ * The pane's way into the mode, and out of it. Named for what it is for rather than for the
589
+ * mechanism ("Select"). It shares its name with the tray's last step on purpose, and the two are
590
+ * never on screen together: pressed, this one reads Done with a tick, the same act as the tray's
591
+ * Done, and that is when the tray appears. A checklist, not a sparkle, because nothing here is AI.
592
+ * Both faces are in the markup and the stylesheet shows the one `aria-pressed` calls for, so the
593
+ * hidden one is not part of the name a screen reader hears.
594
+ */
595
+ var PREPARE_LABEL = "Prepare feedback";
596
+ var DONE_LABEL = "Done";
597
+ 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>`;
598
+ var LIST_CHECK = ICON("<path d=\"m3 17 2 2 4-4M3 7l2 2 4-4M13 6h8M13 12h8M13 18h8\"/>");
599
+ var CHECK = ICON("<path d=\"M20 6 9 17l-5-5\"/>");
600
+ function renderFeedbackSelectButton() {
601
+ 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>`;
602
+ }
603
+ /**
604
+ * The tray, at the foot of the document pane on every view while the mode is on. The mode belongs
605
+ * to the review rather than to a pane, so leaving Issues does not take the tray with it, and its
606
+ * counts are the way back to each pane.
607
+ */
608
+ function renderFeedbackTray() {
609
+ return `<div class="prb-selection-tray" data-feedback-tray role="region" aria-label="Chosen for feedback" hidden>
610
+ <p class="prb-selection-count" aria-live="polite">
611
+ <span data-feedback-total>${escapeHtml$1(feedbackSelectionLabel(0, 0))}</span>${renderGoto("issues")}${renderGoto("questions")}
612
+ </p>
613
+ <span class="prb-selection-spacer"></span>
614
+ <button type="button" class="prb-selection-button" data-feedback-clear>Clear</button>
615
+ <button type="button" class="prb-selection-button" data-feedback-done>${DONE_LABEL}</button>
616
+ <button type="button" class="prb-selection-button prb-selection-primary" data-feedback-prepare disabled>${PREPARE_LABEL}</button>
617
+ </div>`;
618
+ }
619
+ /** A side's count, which is also the way back to its pane. The dot is text, so the line reads whole. */
620
+ function renderGoto(pane) {
621
+ 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>`;
622
+ }
623
+ /**
624
+ * Where the draft is read, edited and copied.
625
+ *
626
+ * Two views of one draft, as tabs, because that is what they are: Preview, the default, for reading
627
+ * what the author will see, and Markdown for changing it. Both are drawn from the same text, the
628
+ * text area's, so an edit is in the preview the moment the tab changes, and Copy takes exactly what
629
+ * is in the box: the client rewrites the button's source on every keystroke.
630
+ */
631
+ function renderFeedbackDialog() {
632
+ 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>`;
633
+ return `<dialog class="prb-dialog prb-draft-dialog" data-feedback-dialog aria-labelledby="prb-feedback-title">
634
+ <div class="prb-draft-head">
635
+ <h2 id="prb-feedback-title">Prepare feedback</h2>
636
+ <p class="prb-draft-count" data-feedback-dialog-count></p>
637
+ </div>
638
+ <div class="prb-draft-tabs" role="tablist" aria-label="Draft">${tab("preview", "Preview", true)}${tab("markdown", "Markdown", false)}</div>
639
+ <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>
640
+ <div class="prb-draft-panel" id="prb-draft-markdown" role="tabpanel" aria-labelledby="prb-draft-tab-markdown" hidden>
641
+ <textarea class="prb-draft-text" data-feedback-text aria-label="Markdown for the author" spellcheck="true" autocomplete="off"></textarea>
642
+ </div>
643
+ <div class="prb-dialog-actions">
644
+ <span class="prb-dialog-spacer"></span>
645
+ <button type="button" data-feedback-close>Close</button>
646
+ ${renderLabelledCopySource("", "Copy Markdown", "data-feedback-copy")}
647
+ </div>
648
+ </dialog>`;
649
+ }
650
+ //#endregion
425
651
  //#region ../../packages/review-harness/src/workspace/way_back.ts
426
652
  var CLI = {
427
653
  command: "prreviewbuddy open",
@@ -842,97 +1068,6 @@ function deriveReviewMetrics(result) {
842
1068
  };
843
1069
  }
844
1070
  //#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
1071
  //#region ../../packages/review-harness/src/domain/ui/components/ask_buddy_menu.ts
937
1072
  /**
938
1073
  * Ask Buddy on a finding card: a sparkle beside Copy that opens the assistant's actions for the
@@ -2397,6 +2532,7 @@ function renderIssuesTab(session, investigatingId, options) {
2397
2532
  <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
2533
  <div class="pr-helper-issue-header">
2399
2534
  <div class="pr-helper-issue-title-row">
2535
+ ${options?.feedbackSelect ? renderFeedbackPick("issue", issue.title) : ""}
2400
2536
  <p class="pr-helper-issue-title">${renderInlineText(issue.title)}</p>
2401
2537
  ${renderFindingCopySource(issueMarkdown(issue), "issue")}
2402
2538
  ${options?.askBuddy ? renderAskBuddyMenu("issue") : ""}
@@ -2499,6 +2635,7 @@ function renderQuestionCard(q, sections, answered, records, now = Date.now(), in
2499
2635
  <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
2636
  <div class="pr-helper-issue-header">
2501
2637
  <div class="pr-helper-issue-title-row">
2638
+ ${options?.feedbackSelect ? renderFeedbackPick("question", q.question) : ""}
2502
2639
  <p class="pr-helper-issue-title">${renderInlineText(q.question)}</p>
2503
2640
  ${renderFindingCopySource(questionMarkdown(q), "question")}
2504
2641
  ${options?.askBuddy ? renderAskBuddyMenu("question") : ""}
@@ -2550,6 +2687,17 @@ function renderQuestionsProgress(questions, answered) {
2550
2687
  if (counts.total < 2) return "";
2551
2688
  return `<div class="pr-helper-questions-progress" role="status">${questionsProgressBody(counts.total, counts.open)}</div>`;
2552
2689
  }
2690
+ /**
2691
+ * The card's feedback checkbox. Named by the finding, so a list of them is not one word announced
2692
+ * fifteen times. The id is the card's own `data-finding-card`.
2693
+ *
2694
+ * `autocomplete="off"` because the browser otherwise restores a box's tick when the page is loaded
2695
+ * again from history, and the selection it belonged to went with the page that made it: Back to a
2696
+ * review showed a ticked box that nothing had selected.
2697
+ */
2698
+ function renderFeedbackPick(kind, title) {
2699
+ return `<input type="checkbox" class="pr-helper-feedback-pick" data-feedback-pick="${kind}" autocomplete="off" aria-label="${escapeHtml$1(`Include in feedback: ${title.trim()}`)}">`;
2700
+ }
2553
2701
  function renderQuestionJump(file, options) {
2554
2702
  if (!file || options?.suppressFileJump) return "";
2555
2703
  return `<button type="button" class="pr-helper-question-jump" data-file="${escapeHtml$1(file)}">Jump to file →</button>`;
@@ -5441,6 +5589,7 @@ ${updating(view) ? "" : renderUpdateOutcome(view.lastUpdate)}
5441
5589
  ${updating(view) ? "" : renderLineageNotice(view)}
5442
5590
  ${renderReviewBody(view, sections)}
5443
5591
  ${view.token ? renderPromptDialog() : ""}
5592
+ ${offersFeedback(view) ? renderFeedbackDialog() : ""}
5444
5593
  <script type="module" src="/assets/workspace.js"><\/script>
5445
5594
  </body>
5446
5595
  </html>`;
@@ -5498,12 +5647,22 @@ function renderReviewBody(view, sections) {
5498
5647
  ${renderRail(view, sections, updating(view))}
5499
5648
  <main class="prb-doc">
5500
5649
  ${updating(view) ? renderUpdatingDoc(view) : renderReviewDoc(view, sections)}
5650
+ ${offersFeedback(view) ? renderFeedbackTray() : ""}
5501
5651
  </main>
5502
5652
  ${result ? renderAsk(view, sections, updating(view)) : ""}
5503
5653
  ${result ? renderAskTab() : ""}
5504
5654
  </div>`;
5505
5655
  }
5506
5656
  /**
5657
+ * Whether the reviewer can choose findings to raise with the author: a standard result with at least
5658
+ * one issue or question, and no update running over it. Select, the tray and the dialog are drawn
5659
+ * together or not at all.
5660
+ */
5661
+ function offersFeedback(view) {
5662
+ const result = view.session.result;
5663
+ return Boolean(result && (result.issues.length > 0 || result.questions.length > 0)) && !updating(view);
5664
+ }
5665
+ /**
5507
5666
  * Buddy AI's own way in: a tab on the window's right edge, where its pane opens.
5508
5667
  *
5509
5668
  * Out of the header, which says where the review stands; the assistant is a tool beside the review,
@@ -5658,15 +5817,19 @@ function renderStandardViews(view, sections) {
5658
5817
  ${renderDiagrams(view.diagrams ?? [], sections, view.reviewedSha ?? null)}
5659
5818
  </section>
5660
5819
  <section class="prb-view" data-view="issues" hidden>
5661
- ${renderDocHead("Issues", renderFocusToggle("issues", "Issues"))}
5820
+ ${renderDocHead("Issues", `${(result?.issues.length ?? 0) > 0 ? renderFeedbackSelectButton() : ""}${renderFocusToggle("issues", "Issues")}`)}
5662
5821
  ${renderAttributionAge(view)}
5663
- ${renderIssuesTab(session, null, { askBuddy: true })}
5822
+ ${renderIssuesTab(session, null, {
5823
+ askBuddy: true,
5824
+ feedbackSelect: true
5825
+ })}
5664
5826
  </section>
5665
5827
  <section class="prb-view" data-view="questions" hidden>
5666
- ${renderDocHead("Questions", renderFocusToggle("questions", "Questions"))}
5828
+ ${renderDocHead("Questions", `${(result?.questions.length ?? 0) > 0 ? renderFeedbackSelectButton() : ""}${renderFocusToggle("questions", "Questions")}`)}
5667
5829
  ${renderQuestionsTab(session, null, {
5668
5830
  suppressFileJump: true,
5669
- askBuddy: true
5831
+ askBuddy: true,
5832
+ feedbackSelect: true
5670
5833
  })}
5671
5834
  </section>
5672
5835
  <section class="prb-view" data-view="prcontext" hidden>
@@ -5763,7 +5926,7 @@ function renderLineageNotice(view) {
5763
5926
  * Two facts per row and no more. The commit is the one that does the real work, because two
5764
5927
  * analyses an hour apart are told apart by what they were of rather than by when they happened;
5765
5928
  * 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
5929
+ * it. There is deliberately no `New` / `Not reviewed` / `Reviewed` here: those describe where
5767
5930
  * somebody is in a piece of work, and on a superseded review that is a claim about work nobody
5768
5931
  * should be continuing. The index's history rows withhold it for the same reason.
5769
5932
  */
@@ -5894,7 +6057,7 @@ function renderReviewBar(view, freshness) {
5894
6057
  /**
5895
6058
  * Where this review stands, as pills that say and never do.
5896
6059
  *
5897
- * Two, because they answer two questions. Reviewed or In progress is what the reviewer has done;
6060
+ * Two, because they answer two questions. Reviewed or Not reviewed is what the reviewer has done;
5898
6061
  * Waiting or Done is what happens next with the branch. A review can be Reviewed and Waiting, so a
5899
6062
  * single pill would have to drop one of them. Active draws nothing, as an open pull request does
5900
6063
  * not: it is where every review starts, and a pill that never varies teaches nothing.
@@ -6047,8 +6210,8 @@ function renderRail(view, sections, stale = false) {
6047
6210
  ${themes || "<p class=\"prb-rail-heading\">No themes</p>"}
6048
6211
  </div>
6049
6212
  ${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"))}
6213
+ ${railLink("issues", "issues", "Issues", findingsBadge(issuesOutstanding(issues, dispositions)), void 0, void 0, true)}
6214
+ ${railLink("questions", "questions", "Questions", remainingBadge(questionsOutstanding(questions, answered), "question", "answered"), void 0, void 0, true)}
6052
6215
  ${railLink("files", "files", "Files", totalBadge(view.changeSet.data.files.length, "files"))}
6053
6216
  ${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
6217
  }) : "";
@@ -6255,10 +6418,15 @@ function renderBadge(badge) {
6255
6418
  return `<span class="prb-rail-count${badge.done ? " prb-rail-count-done" : ""}" aria-label="${escapeHtml$1(badge.label)}">${escapeHtml$1(badge.text)}</span>`;
6256
6419
  }
6257
6420
  /** 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) {
6421
+ /**
6422
+ * `selectable` leaves room for the feedback selection's quiet "2 selected" under the title, empty and
6423
+ * hidden until the client has something to say there.
6424
+ */
6425
+ function railLink(view, icon, label, badge, note, alsoCurrentOn, selectable = false) {
6426
+ const selected = selectable ? `<span class="prb-rail-selected" data-rail-selected="${view}" hidden></span>` : "";
6259
6427
  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
6428
  ${railIcon(icon)}
6261
- <span class="prb-rail-label"><span class="prb-rail-title">${escapeHtml$1(label)}</span></span>
6429
+ <span class="prb-rail-label"><span class="prb-rail-title">${escapeHtml$1(label)}</span>${selected}</span>
6262
6430
  ${renderBadge(badge)}
6263
6431
  </a>`;
6264
6432
  }
@@ -7075,7 +7243,7 @@ function startArchitectureRun(workspace, scope, deps = {}) {
7075
7243
  const now = deps.now ?? Date.now;
7076
7244
  const startedAt = now();
7077
7245
  const env = agentEnvOf(workspace);
7078
- const agent = agentFor(env);
7246
+ const agent = agentFor$1(env);
7079
7247
  const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
7080
7248
  const record = {
7081
7249
  version: 1,
@@ -7427,7 +7595,7 @@ async function fetchHead(cwd, request, env = {}) {
7427
7595
  * What each pull request's forge last said about whether it is still open.
7428
7596
  *
7429
7597
  * **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
7598
+ * open, and `Not reviewed` while its PR has merged; the reviewer's decision lives on the workspace
7431
7599
  * record as `completedAt`, and nothing here reads or writes it.
7432
7600
  *
7433
7601
  * **Its own file, not a field of `watch.json`**, because the two have different lives. `watch.json`
@@ -8775,7 +8943,11 @@ ${renderGlobalBar({
8775
8943
  unread: view.unreadNotifications ?? 0,
8776
8944
  driving: view.driving
8777
8945
  })}
8778
- ${renderContextBar("Reviews", ordered.length === 0 ? "" : renderControls(queue.length, authorOptions(ordered.map((group) => group.latest.authorHandle)), ordered.some((group) => group.latest.prNumber !== void 0)))}
8946
+ ${renderContextBar("Reviews", ordered.length === 0 ? "" : renderControls(queue.length, authorOptions(ordered.map(({ latest }) => ({
8947
+ handle: latest.authorHandle,
8948
+ name: latest.authorName,
8949
+ createdAt: latest.createdAt
8950
+ }))), ordered.some((group) => group.latest.prNumber !== void 0)))}
8779
8951
  <div class="prb-shell">
8780
8952
  ${renderWorkspaceRail({
8781
8953
  current: "reviews",
@@ -8801,19 +8973,19 @@ ${renderWorkspaceRail({
8801
8973
  *
8802
8974
  * Search covers everything a person might remember about a review — the repository, the branch,
8803
8975
  * 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.
8976
+ * is not predictable. The review state filter is the reviewer's own say and nothing else; a filter
8977
+ * per risk level would be four more controls for a question ("show me only the risky ones") that
8978
+ * sorting by recency already answers badly and that scanning answers well.
8807
8979
  */
8808
8980
  function renderControls(queued, authors, anyRequest) {
8809
8981
  return `<div class="prb-index-controls">
8810
8982
  <input type="search" class="prb-index-search" data-search placeholder="Search repo, branch, title, summary or file…" aria-label="Search reviews" spellcheck="false">
8811
8983
  ${renderAuthorFilter(authors)}
8812
- <label class="prb-index-status">Status:
8813
- <select data-status-filter aria-label="Filter by status">
8984
+ <label class="prb-index-status">Review state:
8985
+ <select data-status-filter aria-label="Filter by review state">
8814
8986
  <option value="all">All</option>
8815
8987
  <option value="queue" data-queue-option>${escapeHtml$1(queueOptionLabel(queued))}</option>
8816
- <option value="in-progress">In progress</option>
8988
+ <option value="in-progress">Not reviewed</option>
8817
8989
  <option value="complete">Reviewed</option>
8818
8990
  </select>
8819
8991
  </label>
@@ -8821,13 +8993,13 @@ function renderControls(queued, authors, anyRequest) {
8821
8993
  </div>`;
8822
8994
  }
8823
8995
  /**
8824
- * The pull request's state upstream, beside Status and not inside it, so the two combine.
8996
+ * The pull request's state upstream, beside Review state and not inside it, so the two combine.
8825
8997
  *
8826
8998
  * Absent when no card is of a pull request, for the author filter's reason: a select whose every
8827
8999
  * choice but Any shows nothing is not a choice.
8828
9000
  */
8829
9001
  function renderPrFilter() {
8830
- return `<label class="prb-index-status">PR:
9002
+ return `<label class="prb-index-status prb-index-forge">${GITHUB_MARK}PR:
8831
9003
  <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
9004
  </label>`;
8833
9005
  }
@@ -8837,16 +9009,25 @@ function prStateOf(review, states) {
8837
9009
  return states?.get(requestKey(review.repoPath, review.prNumber));
8838
9010
  }
8839
9011
  /**
8840
- * Whose pull requests to show.
9012
+ * Marks the filters whose answers come from GitHub, Author and PR, so the mark means one thing.
9013
+ * Review state is the reviewer's own and goes without. Hidden from assistive technology: each
9014
+ * select's label already names it.
9015
+ */
9016
+ 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>";
9017
+ /**
9018
+ * Whose pull requests to show, by name where GitHub gave one. The login is still what it filters
9019
+ * by, and is kept beside the name because two people can share a name and nobody shares a login.
8841
9020
  *
8842
9021
  * Absent when no card carries a login, which is every page of local-branch reviews: a dropdown
8843
9022
  * whose only choice is Everyone is not a choice.
8844
9023
  */
8845
9024
  function renderAuthorFilter(authors) {
8846
9025
  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>`;
9026
+ return `<label class="prb-index-status prb-index-forge">${GITHUB_MARK}Author:
9027
+ <select name="author" data-author-filter aria-label="Filter by pull request author">
9028
+ <option value="">Everyone</option>${authors.map((author) => `<option value="${escapeHtml$1(author.handle)}">${escapeHtml$1(authorOptionLabel(author))}</option>`).join("")}
9029
+ </select>
9030
+ </label>`;
8850
9031
  }
8851
9032
  /**
8852
9033
  * One section's Load more, which the browser shows once that section holds more than it is showing.
@@ -8899,8 +9080,8 @@ function renderBulkBar() {
8899
9080
  <button type="button" class="prb-bulk-button" data-bulk-select-all>${escapeHtml$1(selectAllLabel(0))}</button>
8900
9081
  <button type="button" class="prb-bulk-button" data-bulk-fetch hidden>${escapeHtml$1(FETCH_SELECTED_LABEL)}</button>
8901
9082
  <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>
9083
+ <button type="button" class="prb-bulk-button" data-bulk-mark="complete">${markItemLabel("complete")}</button>
9084
+ <button type="button" class="prb-bulk-button" data-bulk-mark="in-progress">${markItemLabel("in-progress")}</button>
8904
9085
  ${LIFECYCLES.map((lifecycle) => `<button type="button" class="prb-bulk-button" data-bulk-lifecycle="${lifecycle.id}">${escapeHtml$1(lifecycle.action)}</button>`).join("\n ")}
8905
9086
  <button type="button" class="prb-bulk-button prb-bulk-button-danger" data-bulk-delete></button>
8906
9087
  <button type="button" class="prb-bulk-button prb-bulk-cancel" data-bulk-cancel>Cancel</button>
@@ -9096,9 +9277,9 @@ function renderHistory(group, now) {
9096
9277
  * branch started. The same numbering the workspace itself uses, so "Review 1 of 3" there and
9097
9278
  * "Review 1" here are the same review.
9098
9279
  *
9099
- * **Only the latest row carries a workflow status.** `New`, `In progress` and `Reviewed` describe
9280
+ * **Only the latest row carries a workflow status.** `New`, `Not reviewed` and `Reviewed` describe
9100
9281
  * 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
9282
+ * nobody should be continuing: a row reading `NOT REVIEWED` under a newer review of the same branch
9102
9283
  * invites exactly the wrong click. The second slot is the lineage instead, `Latest` or `Previous`,
9103
9284
  * which is the only thing about an older review that a reader needs before opening it.
9104
9285
  */
@@ -10193,6 +10374,656 @@ function reviewsRevision(live = analysisStates()) {
10193
10374
  return hash.digest("base64url");
10194
10375
  }
10195
10376
  //#endregion
10377
+ //#region ../../packages/review-harness/src/workspace/working_folders.ts
10378
+ /**
10379
+ * The local Git working folders PR Review Buddy can find right now.
10380
+ *
10381
+ * One question and nothing more: which folders on this machine could a review run in. The answer is
10382
+ * derived each time from what is already known, the folders past reviews ran in and the other
10383
+ * worktrees of those repositories, and nothing is stored. A worktree an agent made yesterday shows
10384
+ * up because `git worktree list` names it, not because anything remembered it.
10385
+ *
10386
+ * A folder is a folder. A linked worktree is its own entry with its own path and is never folded
10387
+ * into the clone it belongs to: a review is filed under the folder it ran in, and the worktree is
10388
+ * where the branch is checked out.
10389
+ *
10390
+ * Not a registry. If one is wanted later it replaces `discoverWorkingFolders` and keeps the shape.
10391
+ */
10392
+ function canonical(path) {
10393
+ try {
10394
+ return realpathSync(path);
10395
+ } catch {
10396
+ return path;
10397
+ }
10398
+ }
10399
+ /** `git worktree list --porcelain`, main worktree first, bare and prunable entries left out. */
10400
+ async function worktreesOf(repoPath) {
10401
+ let output;
10402
+ try {
10403
+ output = await git(repoPath, [
10404
+ "worktree",
10405
+ "list",
10406
+ "--porcelain"
10407
+ ]);
10408
+ } catch {
10409
+ return [];
10410
+ }
10411
+ const entries = [];
10412
+ for (const block of output.split(/\n\s*\n/)) {
10413
+ const lines = block.split("\n");
10414
+ const path = lines.find((line) => line.startsWith("worktree "))?.slice(9);
10415
+ if (!path || lines.includes("bare") || lines.some((line) => line.startsWith("prunable"))) continue;
10416
+ const ref = lines.find((line) => line.startsWith("branch "))?.slice(7) ?? null;
10417
+ entries.push({
10418
+ path,
10419
+ branch: ref ? ref.replace(/^refs\/heads\//, "") : null
10420
+ });
10421
+ }
10422
+ return entries;
10423
+ }
10424
+ async function discoverWorkingFolders(summaries = recentWorkspaces(Number.MAX_SAFE_INTEGER)) {
10425
+ const lastReviewed = /* @__PURE__ */ new Map();
10426
+ for (const summary of summaries) {
10427
+ if (!summary.repoPath) continue;
10428
+ const key = canonical(summary.repoPath);
10429
+ lastReviewed.set(key, Math.max(lastReviewed.get(key) ?? 0, summary.createdAt));
10430
+ }
10431
+ const found = /* @__PURE__ */ new Map();
10432
+ for (const seed of lastReviewed.keys()) {
10433
+ if (found.has(seed) || !existsSync(seed)) continue;
10434
+ (await worktreesOf(seed)).forEach((entry, index) => {
10435
+ const key = canonical(entry.path);
10436
+ if (!found.has(key) && existsSync(key)) found.set(key, {
10437
+ entry: {
10438
+ ...entry,
10439
+ path: key
10440
+ },
10441
+ main: index === 0
10442
+ });
10443
+ });
10444
+ }
10445
+ return (await Promise.all([...found.values()].map(async ({ entry, main }) => ({
10446
+ path: entry.path,
10447
+ remote: await originRemote(entry.path),
10448
+ branch: entry.branch,
10449
+ main,
10450
+ lastReviewedAt: lastReviewed.get(entry.path) ?? null
10451
+ })))).sort((a, b) => (b.lastReviewedAt ?? 0) - (a.lastReviewedAt ?? 0) || Number(b.main) - Number(a.main) || a.path.localeCompare(b.path));
10452
+ }
10453
+ /** Whether a remote is this github.com repository. GitHub names are case-insensitive. */
10454
+ function isGitHubRepository(remote, owner, repo) {
10455
+ return Boolean(remote && remote.host === "github.com" && remote.owner.toLowerCase() === owner.toLowerCase() && remote.repo.toLowerCase() === repo.toLowerCase());
10456
+ }
10457
+ /**
10458
+ * The folders a pull request could be reviewed in, the suggested one first.
10459
+ *
10460
+ * Every match is returned, because which one is right is the reviewer's call: an agent's worktree
10461
+ * and the main clone hold the same objects and differ in what is checked out. The order only
10462
+ * decides the default. A folder that already has a review of this pull request comes first, so
10463
+ * reviewing it again finds that review rather than starting a second one filed under another
10464
+ * folder; then the main worktree; then whichever was reviewed in most recently.
10465
+ */
10466
+ function candidatesFor(folders, link, summaries) {
10467
+ const reviewedHere = new Set(summaries.filter((summary) => summary.prNumber === link.number).map((summary) => canonical(summary.repoPath)));
10468
+ const matching = folders.filter((folder) => isGitHubRepository(folder.remote, link.owner, link.repo));
10469
+ const rank = (folder) => reviewedHere.has(folder.path) ? 0 : folder.main ? 1 : 2;
10470
+ return [...matching].sort((a, b) => rank(a) - rank(b) || (b.lastReviewedAt ?? 0) - (a.lastReviewedAt ?? 0));
10471
+ }
10472
+ /** The review of this pull request already filed under one of these folders, if there is one. */
10473
+ function reviewedIn(folders, link, summaries) {
10474
+ const paths = new Set(folders.map((folder) => folder.path));
10475
+ return summaries.find((summary) => summary.prNumber === link.number && paths.has(canonical(summary.repoPath))) ?? null;
10476
+ }
10477
+ //#endregion
10478
+ //#region ../../packages/review-harness/src/workspace/choose_folder.ts
10479
+ /**
10480
+ * Ask the person at this machine to pick a folder, with the operating system's own picker.
10481
+ *
10482
+ * The workspace is a page served by a local daemon, and a page cannot learn a folder's path from
10483
+ * the browser's directory picker, so the daemon asks instead. The operation is "choose a working
10484
+ * folder"; how a platform does it stays in this file, so neither the routes nor the page know.
10485
+ *
10486
+ * Only macOS has an implementation, through `osascript`. Elsewhere there is no chooser, and the
10487
+ * Add review panel falls back to the path field and the Recent list, which is all v1 needs.
10488
+ */
10489
+ /** Long enough for somebody to browse; short enough that a forgotten dialog does not hold a request for ever. */
10490
+ var CHOOSER_TIMEOUT_MS = 3e5;
10491
+ function folderChooser(platform = process.platform) {
10492
+ if (platform !== "darwin") return null;
10493
+ return () => new Promise((resolve, reject) => {
10494
+ execFile("osascript", ["-e", "POSIX path of (choose folder with prompt \"Choose the working folder to review\")"], { timeout: CHOOSER_TIMEOUT_MS }, (error, stdout, stderr) => {
10495
+ if (error) return /-128/.test(String(stderr)) ? resolve(null) : reject(/* @__PURE__ */ new Error("The folder picker could not be opened."));
10496
+ resolve(stdout.trim().replace(/\/$/, "") || null);
10497
+ });
10498
+ });
10499
+ }
10500
+ function createSlots(capacity) {
10501
+ let free = capacity;
10502
+ const waiting = [];
10503
+ const releaser = () => {
10504
+ let given = false;
10505
+ return () => {
10506
+ if (given) return;
10507
+ given = true;
10508
+ const next = waiting.shift();
10509
+ if (next) next(releaser());
10510
+ else free += 1;
10511
+ };
10512
+ };
10513
+ return {
10514
+ acquire: () => {
10515
+ if (free > 0) {
10516
+ free -= 1;
10517
+ return Promise.resolve(releaser());
10518
+ }
10519
+ return new Promise((resolve) => waiting.push(resolve));
10520
+ },
10521
+ tryAcquire: () => {
10522
+ if (free === 0 || waiting.length > 0) return null;
10523
+ free -= 1;
10524
+ return releaser();
10525
+ }
10526
+ };
10527
+ }
10528
+ /** How many pull requests one submission may name. See `MAX_PULL_REQUESTS_PER_BATCH`. */
10529
+ var MAX_BATCH = 20;
10530
+ /** How long a finished batch stays answerable, for a panel that is still open on it. */
10531
+ var FINISHED_BATCH_TTL_MS = 6e5;
10532
+ function realAddReviewDeps() {
10533
+ return {
10534
+ resolveAgent: (flag) => resolveAgent(flag ? { flag } : {}),
10535
+ agents: async () => (await detectAgents()).map((agent) => ({
10536
+ id: agent.id,
10537
+ displayName: agent.displayName,
10538
+ installed: agent.state === "installed"
10539
+ })),
10540
+ folders: () => discoverWorkingFolders(),
10541
+ summaries: () => recentWorkspaces(Number.MAX_SAFE_INTEGER),
10542
+ remoteOf: originRemote,
10543
+ resolvePullRequest: (folder, url) => resolveTarget({
10544
+ repoPath: folder,
10545
+ prUrl: url,
10546
+ resolveRequest: forgeResolver
10547
+ }),
10548
+ resolveBranch: (folder, base) => resolveTarget({
10549
+ repoPath: folder,
10550
+ ...base ? { base } : {}
10551
+ }),
10552
+ defaultBase: async (root) => {
10553
+ try {
10554
+ const base = await resolveBase(root, void 0);
10555
+ return (base.followRef ?? base.display).replace(/^refs\/(?:heads|remotes)\//, "");
10556
+ } catch {
10557
+ return null;
10558
+ }
10559
+ },
10560
+ findExisting: findExistingReview,
10561
+ prepare: prepareReview,
10562
+ settle: (workspaceId) => settleReview(workspaceId, "standard"),
10563
+ track,
10564
+ chooseFolder: folderChooser(),
10565
+ concurrency: 2,
10566
+ slots: createSlots(2),
10567
+ resumeOf: (reviewId) => {
10568
+ const failure = failureOf$1(reviewId);
10569
+ return failure ? {
10570
+ agent: agentNameOf(failure.agentId),
10571
+ ...failure.requestedModel ? { model: failure.requestedModel } : {}
10572
+ } : null;
10573
+ },
10574
+ agentName: agentNameOf,
10575
+ home: homedir()
10576
+ };
10577
+ }
10578
+ function agentNameOf(agentId) {
10579
+ try {
10580
+ return agentById(agentId).displayName;
10581
+ } catch {
10582
+ return agentId;
10583
+ }
10584
+ }
10585
+ var refuse = (status, error) => ({
10586
+ status,
10587
+ body: { error }
10588
+ });
10589
+ /** A path as a person reads it, with their home folder as `~`. */
10590
+ function displayPath(path, home) {
10591
+ return home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path;
10592
+ }
10593
+ /** A path a person typed, made absolute: `~` is their home folder, and anything relative is refused. */
10594
+ function expandPath(raw, home) {
10595
+ const trimmed = raw.trim().replace(/(.)\/+$/, "$1");
10596
+ if (!trimmed) return null;
10597
+ const expanded = trimmed === "~" ? home : trimmed.startsWith("~/") ? `${home}${trimmed.slice(1)}` : trimmed;
10598
+ return isAbsolute(expanded) ? expanded : null;
10599
+ }
10600
+ function message(error) {
10601
+ return error instanceof Error ? error.message : String(error);
10602
+ }
10603
+ /** Why no agent could be used, in a sentence for the panel. The CLI's own wording names flags. */
10604
+ function unresolvedAgent(resolution) {
10605
+ switch (resolution.kind) {
10606
+ case "none": return "No review agent is installed on this machine. Install one of the agents PR Review Buddy supports, then try again.";
10607
+ case "unknown-id": return `"${resolution.id}" is not an agent PR Review Buddy knows. Choose another under Review options, or change the default in Settings.`;
10608
+ case "unavailable": return `${resolution.agent.displayName} can't be used from the workspace: ${resolution.reason}`;
10609
+ }
10610
+ }
10611
+ /**
10612
+ * The agent a submission runs under: the one named under Review options, or else the same answer
10613
+ * the CLI reaches with no flag (the configured default, or the only one installed). Never the
10614
+ * harness's own fallback: an id is always passed down, so `startJob` never has to guess.
10615
+ */
10616
+ async function agentFor(flag, deps) {
10617
+ if (flag !== void 0 && typeof flag !== "string") return refuse(400, "Say which agent by its id.");
10618
+ const resolution = await deps.resolveAgent(flag || void 0);
10619
+ if (resolution.kind === "resolved") return { agentId: resolution.agent.id };
10620
+ if (resolution.kind === "ambiguous") return refuse(400, "More than one review agent is installed. Choose one under Review options.");
10621
+ return refuse(resolution.kind === "unknown-id" ? 400 : 409, unresolvedAgent(resolution));
10622
+ }
10623
+ function modelOf(value) {
10624
+ if (value === void 0 || value === null || value === "") return void 0;
10625
+ if (typeof value !== "string" || value.length > 200) return refuse(400, "The model must be a short name.");
10626
+ return value.trim() || void 0;
10627
+ }
10628
+ /**
10629
+ * The tail of each target's queue of creations, by batch identity. In-process, like the fetch
10630
+ * queue in `target.ts`.
10631
+ *
10632
+ * `prepareReview` looks for an existing review and then creates one, and nothing between the two
10633
+ * stops a second start of the same target, from another tab or another paste, from looking in the
10634
+ * same gap and creating a second review of it. One at a time per target closes that gap for every
10635
+ * start this server makes; the second one finds the first one's review.
10636
+ */
10637
+ var creations = /* @__PURE__ */ new Map();
10638
+ function oneCreationAtATime(target, work) {
10639
+ const key = batchIdentity(target);
10640
+ const mine = (creations.get(key) ?? Promise.resolve()).then(work, work);
10641
+ const tail = mine.catch(() => {});
10642
+ creations.set(key, tail);
10643
+ tail.then(() => {
10644
+ if (creations.get(key) === tail) creations.delete(key);
10645
+ });
10646
+ return mine;
10647
+ }
10648
+ var isAnswer = (value) => typeof value === "object" && value !== null && "status" in value && "body" in value;
10649
+ async function addReviewOptions(deps) {
10650
+ const [agents, resolution] = await Promise.all([deps.agents(), deps.resolveAgent()]);
10651
+ return {
10652
+ status: 200,
10653
+ body: {
10654
+ agents,
10655
+ agent: resolution.kind === "resolved" ? {
10656
+ kind: "resolved",
10657
+ id: resolution.agent.id,
10658
+ displayName: resolution.agent.displayName
10659
+ } : resolution.kind === "ambiguous" ? { kind: "ambiguous" } : {
10660
+ kind: "refused",
10661
+ error: unresolvedAgent(resolution)
10662
+ },
10663
+ canChooseFolder: deps.chooseFolder !== null
10664
+ }
10665
+ };
10666
+ }
10667
+ function reviewedRow(reviewId, deps) {
10668
+ const kept = deps.resumeOf(reviewId);
10669
+ return {
10670
+ reviewId,
10671
+ ...kept ? { kept } : {}
10672
+ };
10673
+ }
10674
+ function folderOption(folder, home) {
10675
+ return {
10676
+ path: folder.path,
10677
+ display: displayPath(folder.path, home),
10678
+ branch: folder.branch
10679
+ };
10680
+ }
10681
+ /**
10682
+ * What a paste would start, without starting anything: rows, the folders each could run in, and
10683
+ * which already have a review. No forge is asked and nothing is fetched, so it is cheap enough to
10684
+ * run on every pause in typing.
10685
+ *
10686
+ * Every link gets a row, however many there are. Past the batch limit the panel refuses to start
10687
+ * rather than this quietly dropping the tail: a reviewer who pasted twenty-one pull requests and
10688
+ * got twenty reviews would not find out which one was missing.
10689
+ */
10690
+ async function previewPullRequests(text, deps) {
10691
+ if (typeof text !== "string") return refuse(400, "Paste the pull request links to review.");
10692
+ const links = extractPullRequestLinks(text);
10693
+ const [folders, summaries] = links.some((link) => link.kind === "github") ? [await deps.folders(), deps.summaries()] : [[], []];
10694
+ return {
10695
+ status: 200,
10696
+ body: {
10697
+ rows: links.map((link) => {
10698
+ if (link.kind === "unsupported") return {
10699
+ key: link.key,
10700
+ label: `${link.provider} ${link.provider === "GitLab" ? "merge request" : "pull request"}`,
10701
+ url: link.url,
10702
+ ok: false,
10703
+ reason: unsupportedReason(link.provider),
10704
+ folders: []
10705
+ };
10706
+ const label = `${link.owner}/${link.repo} #${link.number}`;
10707
+ const candidates = candidatesFor(folders, link, summaries);
10708
+ if (candidates.length === 0) return {
10709
+ key: link.key,
10710
+ label,
10711
+ url: link.url,
10712
+ ok: false,
10713
+ reason: "Local repository not found",
10714
+ needsFolder: true,
10715
+ folders: []
10716
+ };
10717
+ const reviewed = reviewedIn(candidates, link, summaries);
10718
+ return {
10719
+ key: link.key,
10720
+ label,
10721
+ url: link.url,
10722
+ ok: true,
10723
+ folders: candidates.map((folder) => folderOption(folder, deps.home)),
10724
+ folder: candidates[0].path,
10725
+ ...reviewed ? { reviewed: reviewedRow(reviewed.id, deps) } : {}
10726
+ };
10727
+ }),
10728
+ limit: MAX_BATCH
10729
+ }
10730
+ };
10731
+ }
10732
+ var batches = /* @__PURE__ */ new Map();
10733
+ function forgetFinished(now) {
10734
+ for (const [id, batch] of batches) if (batch.finishedAt !== null && now - batch.finishedAt > FINISHED_BATCH_TTL_MS) batches.delete(id);
10735
+ }
10736
+ function newBatch(rows) {
10737
+ forgetFinished(Date.now());
10738
+ const batch = {
10739
+ id: randomUUID(),
10740
+ rows,
10741
+ finishedAt: null
10742
+ };
10743
+ batches.set(batch.id, batch);
10744
+ return batch;
10745
+ }
10746
+ var rowsOf = (batch) => batch.rows.map((row) => ({ ...row }));
10747
+ /**
10748
+ * Create the review for a row's target, or find the one it has, under the per-target lock. The
10749
+ * caller holds an analysis slot: nothing is created without one, so nothing created waits.
10750
+ */
10751
+ async function prepareRow(row, target, agentId, model, deps) {
10752
+ const prepared = await oneCreationAtATime(target, () => deps.prepare(target, agentId, false, model));
10753
+ row.reviewId = prepared.kind === "new" ? prepared.workspaceId : prepared.existing.id;
10754
+ if (prepared.kind === "existing") row.status = "already-reviewed";
10755
+ else row.status = "reviewing";
10756
+ if (prepared.kind === "resume") row.kept = keptOf(prepared.failure.agentId, prepared.failure.requestedModel, deps);
10757
+ return prepared;
10758
+ }
10759
+ async function settleRow(row, deps) {
10760
+ const settled = await deps.settle(row.reviewId);
10761
+ if (settled.ok) row.status = "ready";
10762
+ else {
10763
+ row.status = "failed";
10764
+ row.reason = settled.stopped ? "Stopped" : settled.reason;
10765
+ }
10766
+ }
10767
+ function keptOf(agentId, model, deps) {
10768
+ return {
10769
+ agent: deps.agentName(agentId),
10770
+ ...model ? { model } : {}
10771
+ };
10772
+ }
10773
+ /**
10774
+ * Start a batch and answer at once. The analyses take minutes, and the panel is not a progress
10775
+ * dialog: it may watch the batch while it stays open, and the reviews appear in the list either way.
10776
+ *
10777
+ * Each pull request waits for one of the server's analysis slots (`deps.slots`), shared with every
10778
+ * other batch and every branch review, and is resolved and created only once it has one.
10779
+ */
10780
+ async function startPullRequestBatch(body, deps) {
10781
+ const raw = Array.isArray(body?.items) ? body.items : null;
10782
+ if (!raw || raw.length === 0) return refuse(400, "Say which pull requests to review.");
10783
+ if (raw.length > MAX_BATCH) return refuse(400, `At most ${MAX_BATCH} pull requests can be started at once.`);
10784
+ const items = [];
10785
+ for (const entry of raw) {
10786
+ const url = typeof entry?.url === "string" ? entry.url : "";
10787
+ const link = parsePullRequestLink(url);
10788
+ if (!link || link.kind !== "github") return refuse(400, `${url || "That"} is not a github.com pull request link.`);
10789
+ const folder = typeof entry?.folder === "string" ? expandPath(entry.folder, deps.home) : null;
10790
+ if (!folder) return refuse(400, `Choose a local folder for ${link.owner}/${link.repo} #${link.number}.`);
10791
+ items.push({
10792
+ url: link.url,
10793
+ owner: link.owner,
10794
+ repo: link.repo,
10795
+ number: link.number,
10796
+ key: link.key,
10797
+ folder
10798
+ });
10799
+ }
10800
+ const model = modelOf(body?.model);
10801
+ if (isAnswer(model)) return model;
10802
+ const agent = await agentFor(body?.agent, deps);
10803
+ if (isAnswer(agent)) return agent;
10804
+ const batch = newBatch(items.map((item) => ({
10805
+ key: item.key,
10806
+ label: `${item.owner}/${item.repo} #${item.number}`,
10807
+ status: "waiting"
10808
+ })));
10809
+ /** Which row claimed each pull request, by what it resolved to rather than how it was spelled. */
10810
+ const claimed = /* @__PURE__ */ new Map();
10811
+ const lane = async (item, index) => {
10812
+ const row = batch.rows[index];
10813
+ const release = await deps.slots.acquire();
10814
+ row.status = "reviewing";
10815
+ try {
10816
+ const remote = await deps.remoteOf(item.folder);
10817
+ if (!isGitHubRepository(remote, item.owner, item.repo)) {
10818
+ row.status = "failed";
10819
+ row.reason = remote ? `This folder is a clone of ${remote.owner}/${remote.repo}, not ${item.owner}/${item.repo}` : `This folder is not a clone of ${item.owner}/${item.repo}`;
10820
+ return;
10821
+ }
10822
+ const target = await deps.resolvePullRequest(item.folder, item.url);
10823
+ const identity = batchIdentity(target);
10824
+ const first = claimed.get(identity);
10825
+ if (first !== void 0) {
10826
+ row.status = "already-reviewed";
10827
+ row.reason = `Same pull request as ${batch.rows[first].label}`;
10828
+ return;
10829
+ }
10830
+ claimed.set(identity, index);
10831
+ if ((await prepareRow(row, target, agent.agentId, model, deps)).kind !== "existing") await settleRow(row, deps);
10832
+ } catch (error) {
10833
+ row.status = "failed";
10834
+ row.reason = message(error);
10835
+ } finally {
10836
+ release();
10837
+ }
10838
+ };
10839
+ deps.track(runBatch(items, {
10840
+ concurrency: deps.concurrency,
10841
+ run: lane
10842
+ })).catch(() => {}).finally(() => {
10843
+ batch.finishedAt = Date.now();
10844
+ });
10845
+ return {
10846
+ status: 200,
10847
+ body: {
10848
+ batchId: batch.id,
10849
+ rows: rowsOf(batch)
10850
+ }
10851
+ };
10852
+ }
10853
+ /**
10854
+ * A batch's rows as they stand. A batch this server does not know was either finished long ago,
10855
+ * which an open panel never asks about because it stops asking once a batch finishes, or lost to a
10856
+ * restart, which is the case worth saying plainly: whatever had not started never will.
10857
+ */
10858
+ function batchRows(id) {
10859
+ const batch = batches.get(id);
10860
+ if (!batch) return refuse(404, "The workspace server no longer knows this batch, most likely because it restarted. Reviews that had started are in your list. Any that were still waiting did not start.");
10861
+ return {
10862
+ status: 200,
10863
+ body: {
10864
+ rows: rowsOf(batch),
10865
+ finished: batch.finishedAt !== null
10866
+ }
10867
+ };
10868
+ }
10869
+ /** A target refusal in the panel's words where the CLI's would name a command. */
10870
+ function targetRefusal(error) {
10871
+ if (error instanceof DetachedHeadError) return "This folder is on a detached HEAD, so there is no branch to review. Check out a branch in it, or review its pull request instead.";
10872
+ return message(error);
10873
+ }
10874
+ /** The recent and discoverable folders, for the Branch panel's list. */
10875
+ async function listFolders(deps) {
10876
+ return {
10877
+ status: 200,
10878
+ body: { folders: (await deps.folders()).map((folder) => ({
10879
+ ...folderOption(folder, deps.home),
10880
+ repo: folder.remote ? folder.remote.repo : basename(folder.path)
10881
+ })) }
10882
+ };
10883
+ }
10884
+ /**
10885
+ * What reviewing this folder would review, read without changing anything: the same target a bare
10886
+ * `prreviewbuddy` here would resolve, and the review it would find.
10887
+ *
10888
+ * With the base the reviewer named, when they named one, so what this checks is the comparison
10889
+ * that would be submitted. Without it, a repository whose default base is ambiguous could never be
10890
+ * reviewed from here, though naming the base is exactly what settles it.
10891
+ */
10892
+ async function inspectFolder(rawPath, deps, rawBase) {
10893
+ const path = typeof rawPath === "string" ? expandPath(rawPath, deps.home) : null;
10894
+ if (!path) return {
10895
+ status: 200,
10896
+ body: {
10897
+ ok: false,
10898
+ error: "Enter the full path of a folder, or choose one."
10899
+ }
10900
+ };
10901
+ const named = typeof rawBase === "string" && rawBase.trim() ? rawBase.trim() : void 0;
10902
+ let target;
10903
+ try {
10904
+ target = await deps.resolveBranch(path, named);
10905
+ } catch (error) {
10906
+ return {
10907
+ status: 200,
10908
+ body: {
10909
+ ok: false,
10910
+ error: targetRefusal(error)
10911
+ }
10912
+ };
10913
+ }
10914
+ const [remote, base, existing] = await Promise.all([
10915
+ deps.remoteOf(target.originRepoPath),
10916
+ named ? Promise.resolve(named) : deps.defaultBase(target.originRepoPath),
10917
+ deps.findExisting(target)
10918
+ ]);
10919
+ const kept = existing ? deps.resumeOf(existing.id) : null;
10920
+ return {
10921
+ status: 200,
10922
+ body: {
10923
+ ok: true,
10924
+ path: target.originRepoPath,
10925
+ display: displayPath(target.originRepoPath, deps.home),
10926
+ repo: remote ? remote.repo : basename(target.originRepoPath),
10927
+ branch: target.branch,
10928
+ base,
10929
+ ...existing ? { reviewed: {
10930
+ reviewId: existing.id,
10931
+ inProgress: existing.inProgress,
10932
+ ...kept ? { kept } : {}
10933
+ } } : {}
10934
+ }
10935
+ };
10936
+ }
10937
+ /**
10938
+ * Review the branch checked out in a folder: `prreviewbuddy` run there.
10939
+ *
10940
+ * With a free analysis slot it answers once the review exists, and the analysis runs on in this
10941
+ * process, counted by `track` like a Retry. With none free it answers `waiting` and a one-row batch
10942
+ * to watch, and creates nothing until a slot frees: a review created to wait is a job nobody is
10943
+ * running, which the sweep would fail. A review the branch already has needs no slot to hand back.
10944
+ */
10945
+ async function startBranchReview(body, deps) {
10946
+ const path = typeof body?.path === "string" ? expandPath(body.path, deps.home) : null;
10947
+ if (!path) return refuse(400, "Choose the working folder to review.");
10948
+ const base = typeof body?.base === "string" && body.base.trim() ? body.base.trim() : void 0;
10949
+ const model = modelOf(body?.model);
10950
+ if (isAnswer(model)) return model;
10951
+ let target;
10952
+ try {
10953
+ target = await deps.resolveBranch(path, base);
10954
+ } catch (error) {
10955
+ return refuse(400, targetRefusal(error));
10956
+ }
10957
+ const agent = await agentFor(body?.agent, deps);
10958
+ if (isAnswer(agent)) return agent;
10959
+ const row = {
10960
+ key: batchIdentity(target),
10961
+ label: target.branch,
10962
+ status: "waiting"
10963
+ };
10964
+ const release = deps.slots.tryAcquire();
10965
+ if (!release) {
10966
+ const existing = await deps.findExisting(target);
10967
+ if (existing && !deps.resumeOf(existing.id)) return {
10968
+ status: 200,
10969
+ body: {
10970
+ outcome: "existing",
10971
+ reviewId: existing.id
10972
+ }
10973
+ };
10974
+ const batch = newBatch([row]);
10975
+ deps.track((async () => {
10976
+ const later = await deps.slots.acquire();
10977
+ try {
10978
+ if ((await prepareRow(row, target, agent.agentId, model, deps)).kind !== "existing") await settleRow(row, deps);
10979
+ } catch (error) {
10980
+ row.status = "failed";
10981
+ row.reason = message(error);
10982
+ } finally {
10983
+ later();
10984
+ }
10985
+ })()).finally(() => {
10986
+ batch.finishedAt = Date.now();
10987
+ });
10988
+ return {
10989
+ status: 200,
10990
+ body: {
10991
+ outcome: "waiting",
10992
+ batchId: batch.id
10993
+ }
10994
+ };
10995
+ }
10996
+ let prepared;
10997
+ try {
10998
+ prepared = await prepareRow(row, target, agent.agentId, model, deps);
10999
+ } catch (error) {
11000
+ release();
11001
+ return refuse(409, message(error));
11002
+ }
11003
+ if (prepared.kind === "existing") release();
11004
+ else deps.track(deps.settle(row.reviewId)).catch(() => {}).finally(release);
11005
+ return {
11006
+ status: 200,
11007
+ body: {
11008
+ outcome: prepared.kind,
11009
+ reviewId: row.reviewId,
11010
+ ...row.kept ? { kept: row.kept } : {}
11011
+ }
11012
+ };
11013
+ }
11014
+ async function chooseFolder(deps) {
11015
+ if (!deps.chooseFolder) return refuse(404, "Choosing a folder is not available on this system. Type its path instead.");
11016
+ try {
11017
+ const path = await deps.chooseFolder();
11018
+ return {
11019
+ status: 200,
11020
+ body: path ? { path } : { cancelled: true }
11021
+ };
11022
+ } catch (error) {
11023
+ return refuse(500, message(error));
11024
+ }
11025
+ }
11026
+ //#endregion
10196
11027
  //#region ../../packages/review-harness/src/workspace/ask.ts
10197
11028
  /** Long enough for a real look at the repository; short enough that a hang is not forever. */
10198
11029
  var ASK_TIMEOUT_MS = 18e4;
@@ -10233,7 +11064,7 @@ async function askAboutChange(workspace, question, sectionId, signal) {
10233
11064
  const env = agentEnvOf(workspace);
10234
11065
  const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
10235
11066
  try {
10236
- return extractAnswer(await withUsageRecorded(workspace.id, "ask", env, (onUsage) => agentFor(env).run({
11067
+ return extractAnswer(await withUsageRecorded(workspace.id, "ask", env, (onUsage) => agentFor$1(env).run({
10237
11068
  capability: "read-code",
10238
11069
  prompt: buildPrompt$2(workspace, question, sectionId, cwd),
10239
11070
  cwd,
@@ -10269,7 +11100,7 @@ async function askAboutChange(workspace, question, sectionId, signal) {
10269
11100
  function explained(error, env) {
10270
11101
  if (!(error instanceof Error) || error instanceof AgentCancelledError) return error;
10271
11102
  try {
10272
- const plain = agentFor(env).explainFailure?.(error.message, env);
11103
+ const plain = agentFor$1(env).explainFailure?.(error.message, env);
10273
11104
  return plain ? new AskFailedError(plain) : error;
10274
11105
  } catch {
10275
11106
  return error;
@@ -10381,7 +11212,7 @@ async function drawDiagram(workspace, sectionId, signal) {
10381
11212
  const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
10382
11213
  let raw;
10383
11214
  try {
10384
- raw = await withUsageRecorded(workspace.id, "diagram", env, (onUsage) => agentFor(env).run({
11215
+ raw = await withUsageRecorded(workspace.id, "diagram", env, (onUsage) => agentFor$1(env).run({
10385
11216
  capability: "read-code",
10386
11217
  prompt: buildPrompt$1(workspace, sectionId, cwd),
10387
11218
  cwd,
@@ -10568,7 +11399,7 @@ async function drawChangeMap(workspace, signal) {
10568
11399
  const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
10569
11400
  let raw;
10570
11401
  try {
10571
- raw = await withUsageRecorded(workspace.id, "change-map", env, (onUsage) => agentFor(env).run({
11402
+ raw = await withUsageRecorded(workspace.id, "change-map", env, (onUsage) => agentFor$1(env).run({
10572
11403
  capability: "read-code",
10573
11404
  prompt: buildPrompt(workspace, cwd),
10574
11405
  cwd,
@@ -11266,6 +12097,7 @@ async function handle(req, res, options) {
11266
12097
  if (url.pathname === "/fetch-updates") return handleFetchUpdates(req, res, options.indexToken);
11267
12098
  if (url.pathname === "/shutdown") return handleIndexShutdown(req, res, options);
11268
12099
  if (url.pathname === "/restart") return handleIndexRestart(req, res, options);
12100
+ if (url.pathname.startsWith("/add-review/")) return handleAddReview(req, res, url.pathname, options);
11269
12101
  const match = /^\/(?:r|api)\/([0-9a-f-]{36})(\/[a-z]+)?$/.exec(url.pathname);
11270
12102
  if (!match) return send(res, 404, { error: "Not found" });
11271
12103
  const [, id, action] = match;
@@ -11583,17 +12415,17 @@ function handleNotificationOpen(req, res, params, indexToken) {
11583
12415
  return redirect(res, `/r/${workspace.id}?t=${encodeURIComponent(workspace.token)}${hash}`);
11584
12416
  }
11585
12417
  /**
11586
- * Mark a selection reviewed, or put it back to in progress.
12418
+ * Mark a selection reviewed, or put it back to not reviewed.
11587
12419
  *
11588
12420
  * Authorised by the index credential, like pinning and deleting, because this is the list acting on
11589
12421
  * itself. Deliberately not `/api/<id>/state`, which needs a review's own token: routing a bulk mark
11590
12422
  * through it would mean putting fifteen credentials into one request, and the index is the one page
11591
12423
  * that holds them all.
11592
12424
  *
11593
- * **Marking in progress fills in `lastViewedAt` where there is none.** Clearing `completedAt` alone
12425
+ * **Marking not reviewed fills in `lastViewedAt` where there is none.** Clearing `completedAt` alone
11594
12426
  * 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
12427
+ * produce it. Taking the mark back is a claim that the work has started, which is what that field
12428
+ * records. It is only ever filled in, never moved, so a review that has been opened
11597
12429
  * keeps the time it was opened.
11598
12430
  *
11599
12431
  * No lock and no running-job check, unlike deletion. A mark is a note about where the reviewer is,
@@ -11823,6 +12655,43 @@ async function handleFetchUpdates(req, res, indexToken) {
11823
12655
  send(res, 200, await runFetchUpdates(new Set(ids)));
11824
12656
  }
11825
12657
  /**
12658
+ * Add review: start pull request and branch reviews from any workspace page.
12659
+ *
12660
+ * The index credential, as the cookie, because starting a review is the machine's business rather
12661
+ * than any one review's, the same authority as `/queue` and `/fetch-updates`. The cookie is set on
12662
+ * `Path=/`, so a review page carries it too. See `add_review.ts` for what each route does.
12663
+ */
12664
+ async function handleAddReview(req, res, pathname, options) {
12665
+ if (!options.indexToken || !secretMatches(options.indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
12666
+ const deps = options.addReview ?? (addReviewDeps ??= realAddReviewDeps());
12667
+ const route = pathname.slice(12);
12668
+ const answer = async () => {
12669
+ if (req.method === "GET") {
12670
+ if (route === "options") return addReviewOptions(deps);
12671
+ if (route === "folders") return listFolders(deps);
12672
+ const batch = /^batches\/([0-9a-f-]{36})$/.exec(route);
12673
+ if (batch) return batchRows(batch[1]);
12674
+ return null;
12675
+ }
12676
+ if (req.method !== "POST") return {
12677
+ status: 405,
12678
+ body: { error: "Method not allowed." }
12679
+ };
12680
+ if (route === "folder/choose") return chooseFolder(deps);
12681
+ const body = await readJson(req);
12682
+ if (route === "pull-requests/preview") return previewPullRequests(body?.text, deps);
12683
+ if (route === "pull-requests") return startPullRequestBatch(body, deps);
12684
+ if (route === "folder/inspect") return inspectFolder(body?.path, deps, body?.base);
12685
+ if (route === "branch") return startBranchReview(body, deps);
12686
+ return null;
12687
+ };
12688
+ const result = await answer();
12689
+ if (!result) return send(res, 404, { error: "Not found." });
12690
+ send(res, result.status, result.body);
12691
+ }
12692
+ /** Built on first use, so a server nobody adds a review on never runs the detectors. */
12693
+ var addReviewDeps;
12694
+ /**
11826
12695
  * Fetch remote updates, from one review's own page, on its own token.
11827
12696
  *
11828
12697
  * The list's fetch scoped to this review: `GET` says whether it applies (the page draws the item
@@ -12936,7 +13805,11 @@ function applyState(workspace, body) {
12936
13805
  if (Array.isArray(body?.dismissedIssues)) reviewer.dismissedIssues = body.dismissedIssues.filter((entry) => typeof entry?.id === "string" && typeof entry?.sessionId === "string");
12937
13806
  if (typeof body?.dismissFreshnessSha === "string" || body?.dismissFreshnessSha === null) workspace.dismissedFreshnessSha = body.dismissFreshnessSha ?? void 0;
12938
13807
  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;
13808
+ if (typeof body?.complete === "boolean") {
13809
+ const now = Date.now();
13810
+ workspace.completedAt = body.complete ? now : void 0;
13811
+ if (!body.complete) workspace.lastViewedAt = workspace.lastViewedAt ?? now;
13812
+ }
12940
13813
  if (typeof body?.pinned === "boolean") workspace.pinned = body.pinned;
12941
13814
  }
12942
13815
  /** 1 MB is far more than any question or id list, and far less than a memory problem. */