prreviewbuddy 0.25.13 → 0.29.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,4 +1,4 @@
1
- import { $ as checkCodeFreshness, $t as AgentCancelledError, A as liveUpdateFor, At as workspaceStamps, B as recordTelemetryUploadConsent, C as writePortPreference, D as isUnchanged, E as refreshPrContext, Et as saveWorkspace, Ft as questionsOutstanding, G as withUsageRecorded, Gt as writeAgentPreference, H as BUILD_VERSION, I as runJob, It as reviewerDispositions, J as fillFileUrlTemplate, Jt as AGENT_IDS, K as EXPLAIN_SIMPLY_PROMPT, Lt as processDiscussion, M as agentEnvOf, Mt as isIssueOutstanding, Nt as isQuestionOutstanding, O as askCheckout, Ot as touchWorkspace, P as startKissJob, Pt as issuesOutstanding, Q as removeWorktree, R as readEvents, Rt as describeAuthorship, S as statedWorkspacePort, St as recentReviewGroups, T as updateReview, Ut as clearAgentPreference, V as telemetryUploadConsent, Vt as git, W as feedbackUrl, Wt as readAgentPreference, X as MANAGED_ROOT, Y as ReviewBeingDeletedError, Z as readMarker, Zt as agentFor, _ as DEFAULT_WORKSPACE_PORT, _t as lineagePosition, a as liveJobsFor, at as loadJob, b as parseWorkspacePort, c as readIndexToken, ct as phasesFor, en as AgentUnavailableError, et as checkFreshness, f as readServerRecord, ft as isClaimed, g as writeServerRecord, ht as latestKissRun, i as lineageIds, it as isTerminal, jt as doneVerb, k as UpdateAlreadyRunningError, kt as workspaceRevision, lt as progressSteps, n as deleteLineage, nt as endedAt, o as wasBlocked, ot as saveJob, pt as followedRefName, qt as STORE_ROOT, r as deleteReview, rt as fail, s as discardJob, t as relativeTime$1, tt as allJobs, u as clearServerRecord, ut as purposeOf, v as MAX_WORKSPACE_PORT, vt as loadWorkspace, w as RefreshUnavailableError, wt as reviewedCommit, x as resolveWorkspacePort, xt as previousKissResult, y as MIN_WORKSPACE_PORT, z as record } from "./relative_time-vn8Ex9E8.js";
1
+ import { $ as fillFileUrlTemplate, $t as writeAgentPreference, A as removeWorktree, At as workspaceRevision, Bt as isQuestionOutstanding, C as recordTelemetryUploadConsent, Ct as previousKissResult, D as ReviewBeingDeletedError, Dt as saveWorkspace, E as EXPLAIN_SIMPLY_PROMPT, F as clearServerRecord, Ft as queueStamp, G as DEFAULT_WORKSPACE_PORT, Gt as describeAuthorship, H as BUILD_VERSION, Ht as questionsOutstanding, It as readQueue, J as parseWorkspacePort, Jt as git, K as MAX_WORKSPACE_PORT, L as readServerRecord, Lt as writeQueue, M as relativeTime$1, Mt as dequeueOnComplete, N as readIndexToken, Nt as queueAdd, O as MANAGED_ROOT, Pt as queueRemove, Qt as readAgentPreference, Rt as doneVerb, S as record, T as withUsageRecorded, Tt as reviewedCommit, Ut as reviewerDispositions, V as writeServerRecord, Vt as issuesOutstanding, W as feedbackUrl, Wt as processDiscussion, X as statedWorkspacePort, Xt as resolveModel, Y as resolveWorkspacePort, Z as writePortPreference, Zt as clearAgentPreference, _ as startKissJob, _t as latestKissRun, a as wasBlocked, an as agentFor, at as isTerminal, bt as loadWorkspace, c as updateReview, cn as AgentUnavailableError, d as askCheckout, dt as purposeOf, et as checkCodeFreshness, f as UpdateAlreadyRunningError, gt as indexReviewGroups, h as agentEnvOf, i as liveJobsFor, it as fail, jt as workspaceStamps, k as readMarker, kt as touchWorkspace, l as refreshPrContext, lt as phasesFor, mt as followedRefName, n as deleteReview, nn as AGENT_IDS, nt as allJobs, o as discardJob, ot as loadJob, p as liveUpdateFor, pt as isClaimed, q as MIN_WORKSPACE_PORT, r as lineageIds, rt as endedAt, s as RefreshUnavailableError, sn as AgentCancelledError, st as saveJob, t as deleteLineage, tn as STORE_ROOT, tt as checkFreshness, u as isUnchanged, ut as progressSteps, w as telemetryUploadConsent, wt as recentWorkspaces, x as readEvents, y as runJob, yt as lineagePosition, zt as isIssueOutstanding } from "./delete_review-DUCSC5Bc.js";
2
2
  import { isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
4
  import { createHash, timingSafeEqual } from "node:crypto";
@@ -229,6 +229,36 @@ function wayBackSentence(way = wayBack()) {
229
229
  return way.commands.map(({ command, where }) => `${command} in ${where}`).join(", or ");
230
230
  }
231
231
  //#endregion
232
+ //#region ../../packages/review-harness/src/workspace/rail_icons.ts
233
+ /**
234
+ * The rail's icons, one per row that is not a theme.
235
+ *
236
+ * A pure module, because the client draws the Diagrams row itself when the first drawing lands and
237
+ * cannot import `page.ts`. Themes keep their numbers: the order is information, and an icon there
238
+ * would replace it with decoration.
239
+ *
240
+ * Stroked in `currentColor` on a 24-unit grid, so every icon takes the row's colour, including
241
+ * Stop server's red on hover.
242
+ */
243
+ var PATHS = {
244
+ review: "<path d=\"M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2\"/><rect x=\"9\" y=\"3\" width=\"6\" height=\"4\" rx=\"1\"/><path d=\"m9 14 2 2 4-4\"/>",
245
+ kiss: "<path d=\"M12 3v4M12 17v4M3 12h4M17 12h4\"/><circle cx=\"12\" cy=\"12\" r=\"2\"/>",
246
+ diagrams: "<rect x=\"3\" y=\"3\" width=\"7\" height=\"6\" rx=\"1\"/><rect x=\"14\" y=\"15\" width=\"7\" height=\"6\" rx=\"1\"/><path d=\"M6.5 9v3a2 2 0 0 0 2 2h7a2 2 0 0 1 2 2v-1\"/>",
247
+ issues: "<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 8v4M12 16h.01\"/>",
248
+ questions: "<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3M12 17h.01\"/>",
249
+ files: "<path d=\"M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z\"/><path d=\"M14 3v5h5M9 13h6M9 17h6\"/>",
250
+ prcontext: "<path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"/>",
251
+ recommendations: "<path d=\"M9 18h6M10 22h4\"/><path d=\"M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.2 1 2V17h6v-.3c0-.8.4-1.5 1-2A7 7 0 0 0 12 2z\"/>",
252
+ reviews: "<path d=\"M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01\"/>",
253
+ settings: "<circle cx=\"12\" cy=\"12\" r=\"3\"/><path d=\"M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z\"/>",
254
+ feedback: "<path d=\"m22 2-7 20-4-9-9-4z\"/><path d=\"M22 2 11 13\"/>",
255
+ stop: "<path d=\"M12 2v10\"/><path d=\"M18.4 6.6a9 9 0 1 1-12.8 0\"/>"
256
+ };
257
+ /** The icon slot for one rail row. Hidden from assistive technology: the label beside it says it. */
258
+ function railIcon(icon) {
259
+ return `<span class="prb-rail-icon" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">${PATHS[icon]}</svg></span>`;
260
+ }
261
+ //#endregion
232
262
  //#region ../../packages/review-harness/src/domain/utils/file_path.ts
233
263
  /**
234
264
  * Splits a repo-relative path for display, so the filename can be shown prominently and the
@@ -571,6 +601,119 @@ function humanizeModelId(id) {
571
601
  return display;
572
602
  }
573
603
  //#endregion
604
+ //#region ../../packages/review-harness/src/domain/utils/agent_label.ts
605
+ /**
606
+ * What a person is shown when a review names the tool that produced it.
607
+ *
608
+ * Here in the domain rather than read off `AgentIdentity.displayName`, because this runs in the
609
+ * extension bundle and the adapter registry reaches `node:child_process`. Deriving the label from
610
+ * the stored `agentId` keeps the client free of node builtins and adds no stored field.
611
+ *
612
+ * A closed set of this product's own adapter ids, which is not the kind of table that goes stale
613
+ * invisibly: adding an adapter is our own act, and anything unrecognised still renders through
614
+ * `humanizeModelId` rather than disappearing. That is why this is not folded into
615
+ * `humanizeModelId` itself, whose value is knowing nothing about which names exist.
616
+ */
617
+ var AGENT_DISPLAY_NAMES = {
618
+ claude: "Claude Code",
619
+ codex: "Codex",
620
+ gemini: "Gemini CLI"
621
+ };
622
+ /**
623
+ * Absent reads as Claude, matching `agentIdOf` and `agentName`: a session written before agents
624
+ * were selectable ran under Claude, and showing it as unknown would invent a gap in the oldest
625
+ * reviews on disk.
626
+ */
627
+ var DEFAULT_AGENT_DISPLAY_NAME = AGENT_DISPLAY_NAMES.claude;
628
+ function agentDisplayName(agentId) {
629
+ if (!agentId) return DEFAULT_AGENT_DISPLAY_NAME;
630
+ return AGENT_DISPLAY_NAMES[agentId] ?? humanizeModelId(agentId);
631
+ }
632
+ //#endregion
633
+ //#region ../../packages/review-harness/src/domain/ai_review/model_provenance.ts
634
+ /**
635
+ * Values `model` carried when it meant "which agent", before the field was frozen.
636
+ *
637
+ * A closed set of three of our own historical strings, not a catalogue of vendor models, so it
638
+ * never grows and never goes stale. It lives here rather than in `humanizeModelId` on purpose:
639
+ * the corruption belongs to how sessions were once written, and that label function's whole value
640
+ * is knowing nothing about which models exist.
641
+ */
642
+ var LEGACY_AGENT_IDS = /* @__PURE__ */ new Set([
643
+ "codex",
644
+ "claude-code",
645
+ "gemini-cli"
646
+ ]);
647
+ var present = (value) => typeof value === "string" && value !== "" ? value : null;
648
+ function modelProvenanceOf(session) {
649
+ return {
650
+ requestedModel: present(session.requestedModel) ?? legacyModel(session.model),
651
+ observedModel: present(session.observedModel),
652
+ agentVersion: present(session.agentVersion)
653
+ };
654
+ }
655
+ /**
656
+ * A legacy `model`, read as a request or discarded.
657
+ *
658
+ * The extension put the model it was about to call into this field, so for its sessions the value
659
+ * really is what was requested. The CLI put an agent id there, which is not a model at all and is
660
+ * dropped rather than displayed: showing "Reviewed with Codex, model Codex" would be the record
661
+ * inventing provenance out of its own filing.
662
+ */
663
+ function legacyModel(model) {
664
+ const value = present(model);
665
+ if (value === null || LEGACY_AGENT_IDS.has(value)) return null;
666
+ return value;
667
+ }
668
+ /**
669
+ * Whether a coding agent made this, as opposed to the extension calling a provider API.
670
+ *
671
+ * The awkward case is a session with no `agentId` at all, because two different producers leave it
672
+ * empty. A CLI review from before agents were selectable means Claude, which is why
673
+ * `agentDisplayName` defaults that way. An extension review means no agent ran at all, and naming
674
+ * one would credit Claude Code for somebody's OpenAI review.
675
+ *
676
+ * The legacy sentinel tells them apart, which is the last job that field has: only the CLI ever
677
+ * wrote one, so a record with no agent id and no sentinel came from the extension.
678
+ */
679
+ function producedByAgent(session) {
680
+ if (session.agentId) return true;
681
+ const legacy = present(session.model);
682
+ return legacy !== null && LEGACY_AGENT_IDS.has(legacy);
683
+ }
684
+ /**
685
+ * The full provenance, for the disclosure rather than the page.
686
+ *
687
+ * Opens with "Reviewed with X" rather than "Agent: X", keeping the sentence that was already here:
688
+ * a review is not owned by an agent, it holds analyses, and this names the one being shown.
689
+ *
690
+ * The line that earns this list its place is the last one: a pinned model that nobody confirmed.
691
+ * `--model X` states what we asked for, and on an agent that reports nothing back there is no way
692
+ * to know it was honoured, so the pin is shown alongside the fact that it is unverified. Without
693
+ * that line a reader would take a requested model for a measured one, which is the overstatement
694
+ * this whole split exists to prevent.
695
+ *
696
+ * Said only when something was pinned. On an unpinned run there is no claim outstanding, and a
697
+ * standing "not reported" would be noise on every review anybody ever runs.
698
+ *
699
+ * Both sentences that name an agent are withheld from a review no agent produced, through the same
700
+ * `producedByAgent` distinction `attributionLabel` makes. An extension review has no agent id, and
701
+ * naming one anyway credited Claude Code for somebody's OpenAI review and then reported that Claude
702
+ * Code had not confirmed a model nobody had asked it for. What remains for such a review is the
703
+ * model it requested and the model that answered, which is the whole of what is known about it.
704
+ */
705
+ function provenanceDetailLines(session) {
706
+ const agent = producedByAgent(session) ? agentDisplayName(session.agentId) : null;
707
+ const { requestedModel, observedModel, agentVersion } = modelProvenanceOf(session);
708
+ return [
709
+ ...agent ? [`Reviewed with ${agent}`] : [],
710
+ ...agentVersion ? [`Agent version ${agentVersion}`] : [],
711
+ ...requestedModel ? [`Requested model ${humanizeModelId(requestedModel)}`] : [],
712
+ ...observedModel ? [`Reported model ${humanizeModelId(observedModel)}`] : [],
713
+ ...agent && requestedModel && !observedModel ? [`Reported model not reported by ${agent}`] : []
714
+ ];
715
+ }
716
+ //#endregion
574
717
  //#region ../../packages/review-harness/src/domain/ui/components/pill_tooltips.ts
575
718
  /**
576
719
  * What every pill in the sidebar actually means, in one place.
@@ -881,7 +1024,8 @@ var CANCEL_BUTTON = `<button type="button" class="pr-helper-cancel-button">Cance
881
1024
  function renderGeneratingOverview(session, iconUrl) {
882
1025
  const activeIndex = session.progress.findIndex((step) => !step.done);
883
1026
  const brandMark = iconUrl ? `<img src="${escapeHtml$1(iconUrl)}" class="pr-helper-brand-mark" alt="" />` : "";
884
- const poweredByName = session.provider === "openai_compatible" ? humanizeModelId(session.model) : PROVIDER_DISPLAY_NAME[session.provider];
1027
+ const pickedModel = modelProvenanceOf(session).requestedModel;
1028
+ const poweredByName = session.provider === "openai_compatible" && pickedModel ? humanizeModelId(pickedModel) : PROVIDER_DISPLAY_NAME[session.provider];
885
1029
  const isThinking = activeIndex === 2;
886
1030
  return `
887
1031
  <div class="pr-helper-generating pr-helper-generating-focused">
@@ -991,8 +1135,7 @@ function coverageDetails(session) {
991
1135
  * agent: it holds analyses, and this names the one it is currently showing.
992
1136
  */
993
1137
  function agentDetails(session) {
994
- const name = humanizeModelId(session.model);
995
- return name ? [`Reviewed with ${name}`] : [];
1138
+ return provenanceDetailLines(session);
996
1139
  }
997
1140
  /**
998
1141
  * Everything the Analysis details disclosure says, as facts rather than as markup.
@@ -1250,7 +1393,7 @@ function renderGuideSectionCard(section, counts, activeGuidedSectionId, status,
1250
1393
  const statusBadge = isCompleted ? `<span class="pr-helper-guide-badge pr-helper-completed-badge">Completed</span>` : status === "active" ? `<span class="pr-helper-guide-badge pr-helper-start-here-badge">${escapeHtml$1(activeBadgeLabel)}</span>` : "";
1251
1394
  const undo = isCompleted ? `<button type="button" class="pr-helper-guide-uncomplete" data-guide-uncomplete="${escapeHtml$1(section.id)}">Mark as not reviewed</button>` : "";
1252
1395
  return `
1253
- <section class="pr-helper-guide-section${isCompleted ? " pr-helper-guide-section-completed" : ""}" id="pr-helper-guide-section-${escapeHtml$1(section.id)}" data-guide-section-card="${escapeHtml$1(section.id)}">
1396
+ <section class="pr-helper-guide-section${isCompleted ? " pr-helper-guide-section-completed" : ""}${options?.collapsedSections ? " pr-helper-guide-section-collapsed" : ""}" id="pr-helper-guide-section-${escapeHtml$1(section.id)}" data-guide-section-card="${escapeHtml$1(section.id)}">
1254
1397
  <button type="button" class="pr-helper-guide-section-header" data-guide-toggle="${escapeHtml$1(section.id)}">
1255
1398
  <div class="pr-helper-guide-section-badges">
1256
1399
  ${check}
@@ -2220,6 +2363,9 @@ function phaseSentence(job) {
2220
2363
  function renderFailureBody(job, options = {}) {
2221
2364
  if (!job.failure) return "";
2222
2365
  const noun = isWorkPhase(job.failure.phase) ? PHASE_NOUN[job.failure.phase] : job.failure.phase;
2366
+ if (job.failure.stopped) return `<p>${options.lead === false ? "" : "<strong>Review stopped.</strong> "}Nothing went wrong, and what it had finished is kept.</p>
2367
+ <button type="button" class="prb-job-retry" data-job-retry>Carry on</button>
2368
+ <p class="prb-job-note">It picks up from the ${escapeHtml$1(noun)}, not from the start.</p>`;
2223
2369
  const next = job.failure.kind === "agent_authentication_failed" ? `<p class="prb-job-note">Signing that agent in is what changes this. Once it is signed in, update the review.</p>` : `<button type="button" class="prb-job-retry" data-job-retry>Retry</button>
2224
2370
  <p class="prb-job-note">Retry picks up from the ${escapeHtml$1(noun)}, not from the start.</p>`;
2225
2371
  return `<p>${options.lead === false ? "" : "<strong>Review paused.</strong> "}${escapeHtml$1(job.failure.message)}</p>
@@ -2584,7 +2730,7 @@ function provenanceFacts(run) {
2584
2730
  run.completedAt ? `Completed ${relativeTime$1(run.completedAt)}` : "",
2585
2731
  `Commit ${run.reviewedSha.slice(0, 7)}`,
2586
2732
  audienceSentence(run.audience),
2587
- run.agentId ? `Analysed by ${run.agentId}${run.model ? ` (${run.model})` : ""}` : "",
2733
+ run.agentId ? `Analysed by ${agentDisplayName(run.agentId)}${modelSuffix(run)}` : "",
2588
2734
  `Rubric version ${run.promptVersion}`
2589
2735
  ].filter((fact) => fact !== "");
2590
2736
  }
@@ -2725,6 +2871,22 @@ function renderLocationList(locations) {
2725
2871
  </button></li>`;
2726
2872
  }).join("")}</ul>`;
2727
2873
  }
2874
+ /**
2875
+ * The model this run ran under, as far as it is known.
2876
+ *
2877
+ * Observed over requested, which is the precedence `attributionLabel` applies to a standard
2878
+ * review's line and the reason this reads `modelProvenanceOf` rather than a field: what the agent
2879
+ * said answered outranks what it was asked for, and only one of the two is ever a claim about what
2880
+ * actually happened. Neither known leaves the agent to stand alone rather than inventing a pin.
2881
+ *
2882
+ * `modelProvenanceOf` is also what keeps the frozen `model` out of here, which on a KISS run only
2883
+ * ever held an agent id: "Analysed by Codex (Codex)" was the shape that produced.
2884
+ */
2885
+ function modelSuffix(run) {
2886
+ const { requestedModel, observedModel } = modelProvenanceOf(run);
2887
+ const model = observedModel ?? requestedModel;
2888
+ return model ? ` (${humanizeModelId(model)})` : "";
2889
+ }
2728
2890
  //#endregion
2729
2891
  //#region ../../packages/review-harness/src/workspace/update_view.ts
2730
2892
  /**
@@ -3351,8 +3513,11 @@ function renderStandardViews(view, sections) {
3351
3513
  diagrams: true,
3352
3514
  detailsInHeader: true
3353
3515
  })}</section>
3354
- <section class="prb-view" data-view="guide" hidden>${renderGuideTab(session, null, null, { diagrams: true })}</section>
3355
- <section class="prb-view" data-view="diagram" hidden data-diagram-pane>
3516
+ <section class="prb-view" data-view="guide" hidden>${renderGuideTab(session, null, null, {
3517
+ diagrams: true,
3518
+ collapsedSections: true
3519
+ })}</section>
3520
+ <section class="prb-view" data-view="diagram" hidden data-diagram-pane${view.pendingDiagram ? ` data-diagram-running="${escapeHtml$1(view.pendingDiagram.sectionId ?? "")}"` : ""}>
3356
3521
  <h3 class="prb-doc-title">Diagrams</h3>
3357
3522
  ${renderDiagrams(view.diagrams ?? [], sections, view.reviewedSha ?? null)}
3358
3523
  </section>
@@ -3520,7 +3685,7 @@ function renderHeader(view, freshness) {
3520
3685
  return `<button type="button" class="prb-fresh-check" data-fresh-check data-state="${state}" aria-expanded="false" title="How this review stands against the branch as it is now">${escapeHtml$1(label)}</button>`;
3521
3686
  })() : ""}
3522
3687
  ${view.token ? `<button type="button" class="prb-complete" data-complete aria-pressed="${view.complete === true}">${view.complete ? "Reviewed" : "Mark reviewed"}</button>` : ""}
3523
- ${view.id ? renderIdMenu(view.id) : ""}
3688
+ ${view.id ? renderIdMenu(view.id, Boolean(view.token)) : ""}
3524
3689
  ${view.session.result ? "<button type=\"button\" class=\"prb-ask-toggle\" data-ask-toggle aria-expanded=\"false\">Buddy AI</button>" : ""}
3525
3690
  </div>
3526
3691
  </header>`;
@@ -3535,17 +3700,29 @@ function renderHeader(view, freshness) {
3535
3700
  * the address bar.
3536
3701
  *
3537
3702
  * The same `...` and the same menu markup as the index, deliberately. One gesture, in both places
3538
- * it can be wanted, behaving identically. Copy alone and no delete: removing the review you are
3539
- * currently reading is a different act needing somewhere to send you afterwards, and the list is
3540
- * one click away in the rail.
3703
+ * it can be wanted, behaving identically. That includes deleting, with the same confirmation in the
3704
+ * same panel and the same sentence. The list is where you land afterwards, since the page you were
3705
+ * reading no longer exists. It deletes this review only, as a history row on the list does.
3706
+ *
3707
+ * No delete where there is no server, which is the offline preview: the item would be a lie there.
3541
3708
  */
3542
- function renderIdMenu(id) {
3709
+ function renderIdMenu(id, canDelete) {
3710
+ const safe = escapeHtml$1(id);
3543
3711
  return `<div class="prb-menu" data-menu>
3544
3712
  <button type="button" class="prb-menu-trigger" data-menu-trigger aria-expanded="false" aria-haspopup="true" aria-label="Actions for this review">&#8943;</button>
3545
3713
  <div class="prb-menu-panel" data-menu-panel hidden>
3546
3714
  <div data-menu-items>
3547
- <button type="button" class="prb-menu-item" data-copy-id="${escapeHtml$1(id)}">Copy review ID</button>
3715
+ <button type="button" class="prb-menu-item" data-copy-id="${safe}">Copy review ID</button>
3716
+ ${canDelete ? `<button type="button" class="prb-menu-item prb-menu-item-danger" data-delete="${safe}">Delete review</button>` : ""}
3548
3717
  </div>
3718
+ ${canDelete ? `<div class="prb-menu-confirm" data-menu-confirm hidden>
3719
+ <p class="prb-menu-question">Delete this review?</p>
3720
+ <p class="prb-menu-note">This cannot be undone. Your branch and its commits are untouched.</p>
3721
+ <div class="prb-menu-buttons">
3722
+ <button type="button" class="prb-menu-item prb-menu-item-danger" data-confirm-delete>Delete</button>
3723
+ <button type="button" class="prb-menu-item" data-cancel-delete>Cancel</button>
3724
+ </div>
3725
+ </div>` : ""}
3549
3726
  </div>
3550
3727
  </div>`;
3551
3728
  }
@@ -3567,22 +3744,22 @@ function renderIdMenu(id) {
3567
3744
  */
3568
3745
  function renderRailFoot(view) {
3569
3746
  return `<div class="prb-rail-group prb-rail-foot">
3570
- ${view.token ? `<a class="prb-rail-item" href="/">
3571
- <span class="prb-rail-order">&lsaquo;</span>
3747
+ ${view.token ? `<a class="prb-rail-item prb-rail-link" href="/">
3748
+ ${railIcon("reviews")}
3572
3749
  <span class="prb-rail-label"><span class="prb-rail-title">All reviews</span></span>
3573
3750
  </a>
3574
- <a class="prb-rail-item" href="/settings">
3575
- <span class="prb-rail-order">&#9881;</span>
3751
+ <a class="prb-rail-item prb-rail-link" href="/settings">
3752
+ ${railIcon("settings")}
3576
3753
  <span class="prb-rail-label"><span class="prb-rail-title">Settings</span></span>
3577
3754
  </a>` : ""}
3578
- <a class="prb-rail-item" href="${escapeHtml$1(feedbackUrl())}" target="_blank" rel="noopener noreferrer">
3579
- <span class="prb-rail-order"></span>
3755
+ <a class="prb-rail-item prb-rail-link" href="${escapeHtml$1(feedbackUrl())}" target="_blank" rel="noopener noreferrer">
3756
+ ${railIcon("feedback")}
3580
3757
  <span class="prb-rail-label"><span class="prb-rail-title">Send feedback</span></span>
3581
3758
  </a>
3582
3759
  </div>
3583
3760
  <div class="prb-rail-group prb-rail-ops">
3584
- ${view.token ? `<button type="button" class="prb-rail-item prb-rail-danger" data-shutdown>
3585
- <span class="prb-rail-order"></span>
3761
+ ${view.token ? `<button type="button" class="prb-rail-item prb-rail-link prb-rail-danger" data-shutdown>
3762
+ ${railIcon("stop")}
3586
3763
  <span class="prb-rail-label"><span class="prb-rail-title">Stop server</span></span>
3587
3764
  </button>` : ""}
3588
3765
  <p class="prb-build">v${escapeHtml$1(BUILD_VERSION)}</p>
@@ -3634,6 +3811,7 @@ function renderRail(view, sections) {
3634
3811
  ${(view.session.result ? railGroup({
3635
3812
  id: "prb-rail-review",
3636
3813
  title: "Review",
3814
+ icon: "review",
3637
3815
  view: "overview",
3638
3816
  current: true,
3639
3817
  badge: null,
@@ -3645,11 +3823,11 @@ function renderRail(view, sections) {
3645
3823
  <div class="prb-rail-path" id="prb-rail-path">
3646
3824
  ${themes || "<p class=\"prb-rail-heading\">No themes</p>"}
3647
3825
  </div>
3648
- ${(view.diagrams?.length ?? 0) > 0 ? railLink("diagram", "Diagrams", diagramsBadge(view.diagrams?.length ?? 0)) : ""}
3649
- ${railLink("issues", "Issues", findingsBadge(issuesOutstanding(issues, dispositions)))}
3650
- ${railLink("questions", "Questions", remainingBadge(questionsOutstanding(questions, answered), "question", "answered"))}
3651
- ${railLink("files", "Files", totalBadge(view.changeSet.data.files.length, "files"))}
3652
- ${view.prContext ? railLink("prcontext", "PR context", totalBadge(view.prContext.processed.stats.totalThreads, "PR comment threads")) : railLink("prcontext", "PR context", null, "No pull request was connected to this review")}`
3826
+ ${(view.diagrams?.length ?? 0) > 0 ? railLink("diagram", "diagrams", "Diagrams", diagramsBadge(view.diagrams?.length ?? 0)) : ""}
3827
+ ${railLink("issues", "issues", "Issues", findingsBadge(issuesOutstanding(issues, dispositions)))}
3828
+ ${railLink("questions", "questions", "Questions", remainingBadge(questionsOutstanding(questions, answered), "question", "answered"))}
3829
+ ${railLink("files", "files", "Files", totalBadge(view.changeSet.data.files.length, "files"))}
3830
+ ${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")}`
3653
3831
  }) : "") || renderRunningRail(view)}
3654
3832
  ${renderKissRail(view)}
3655
3833
  ${renderRailFoot(view)}
@@ -3675,11 +3853,12 @@ function renderRunningRail(view) {
3675
3853
  return railGroup({
3676
3854
  id: "prb-rail-review",
3677
3855
  title: "Review",
3856
+ icon: "review",
3678
3857
  view: "progress",
3679
3858
  current: true,
3680
3859
  badge: null,
3681
3860
  collapses: "the review",
3682
- body: view.job && changeIsRead(view.job) ? railLink("files", "Files", totalBadge(files, "files")) : ""
3861
+ body: view.job && changeIsRead(view.job) ? railLink("files", "files", "Files", totalBadge(files, "files")) : ""
3683
3862
  });
3684
3863
  }
3685
3864
  /**
@@ -3702,11 +3881,12 @@ function renderKissRail(view) {
3702
3881
  return railGroup({
3703
3882
  id: "prb-rail-kiss",
3704
3883
  title: "KISS",
3884
+ icon: "kiss",
3705
3885
  view: "kiss",
3706
3886
  current: view.session.result === null && !showsProgress(view),
3707
3887
  badge: result ? recommendationsBadge(result.recommendations.length) : null,
3708
3888
  collapses: "the KISS review",
3709
- body: result ? railLink("kissrecs", "Recommendations", recommendationsBadge(result.recommendations.length)) : ""
3889
+ body: result ? railLink("kissrecs", "recommendations", "Recommendations", recommendationsBadge(result.recommendations.length)) : ""
3710
3890
  });
3711
3891
  }
3712
3892
  /** The one chevron both twisties draw. Rotated by CSS off `aria-expanded`, never by markup. */
@@ -3726,7 +3906,7 @@ function railGroup(group) {
3726
3906
  return `<div class="prb-rail-group">
3727
3907
  <div class="prb-rail-row">
3728
3908
  <button type="button" class="prb-rail-item prb-rail-group-title" data-view-nav="${escapeHtml$1(group.view)}"${group.current ? " aria-current=\"true\"" : ""}>
3729
- <span class="prb-rail-order"></span>
3909
+ ${railIcon(group.icon)}
3730
3910
  <span class="prb-rail-label"><span class="prb-rail-title">${escapeHtml$1(group.title)}</span></span>
3731
3911
  ${renderBadge(group.badge)}
3732
3912
  </button>
@@ -3812,9 +3992,9 @@ function renderBadge(badge) {
3812
3992
  return `<span class="prb-rail-count${badge.done ? " prb-rail-count-done" : ""}" aria-label="${escapeHtml$1(badge.label)}">${escapeHtml$1(badge.text)}</span>`;
3813
3993
  }
3814
3994
  /** A null badge renders nothing, for the case where zero would be a claim rather than a total. */
3815
- function railLink(view, label, badge, note) {
3816
- return `<button type="button" class="prb-rail-item" data-view-nav="${view}"${note ? ` title="${escapeHtml$1(note)}"` : ""}>
3817
- <span class="prb-rail-order"></span>
3995
+ function railLink(view, icon, label, badge, note) {
3996
+ return `<button type="button" class="prb-rail-item prb-rail-link" data-view-nav="${view}"${note ? ` title="${escapeHtml$1(note)}"` : ""}>
3997
+ ${railIcon(icon)}
3818
3998
  <span class="prb-rail-label"><span class="prb-rail-title">${escapeHtml$1(label)}</span></span>
3819
3999
  ${renderBadge(badge)}
3820
4000
  </button>`;
@@ -4058,6 +4238,9 @@ function renderShutdownDialog() {
4058
4238
  function renderAsk(view, sections) {
4059
4239
  const suggestions = buildSuggestions(view, sections);
4060
4240
  const transcript = view.transcript ?? [];
4241
+ const pending = view.pendingAsk ?? null;
4242
+ const pendingTurn = pending ? `<div class="prb-msg prb-msg-user">${escapeHtml$1(pending.question ?? "")}</div>
4243
+ <div class="prb-thinking" data-ask-pending><i></i><i></i><i></i><span>Reading the change…</span></div>` : "";
4061
4244
  return `<aside class="prb-ask" aria-label="Ask Buddy AI">
4062
4245
  <div
4063
4246
  class="prb-ask-resize"
@@ -4073,8 +4256,8 @@ function renderAsk(view, sections) {
4073
4256
  </div>
4074
4257
  ${renderPromptBank(view.prompts ?? [])}
4075
4258
  <div class="prb-ask-log" data-ask-log>
4076
- ${transcript.length > 0 ? transcript.map((exchange) => `<div class="prb-msg prb-msg-user">${escapeHtml$1(exchange.question)}</div>
4077
- <div class="prb-msg prb-msg-assistant prb-md">${renderMarkdown(exchange.answer)}${renderMessageCopySource(exchange.answer)}</div>`).join("") : `<div class="prb-ask-empty">
4259
+ ${transcript.length > 0 || pending ? transcript.map((exchange) => `<div class="prb-msg prb-msg-user">${escapeHtml$1(exchange.question)}</div>
4260
+ <div class="prb-msg prb-msg-assistant prb-md">${renderMarkdown(exchange.answer)}${renderMessageCopySource(exchange.answer)}</div>`).join("") + pendingTurn : `<div class="prb-ask-empty">
4078
4261
  <p>Ask about this change. Answers come from the review, the diff and the repository on disk — not from anywhere else.</p>
4079
4262
  ${suggestions.map((text) => `<button type="button" class="prb-suggestion" data-suggestion>${escapeHtml$1(text)}</button>`).join("")}
4080
4263
  </div>`}
@@ -4141,6 +4324,40 @@ function countLabel(reviews, branches) {
4141
4324
  return `${label} · ${branches} branch${branches === 1 ? "" : "es"}`;
4142
4325
  }
4143
4326
  /**
4327
+ * Every login the author dropdown offers, once each, in alphabetical order regardless of case.
4328
+ * Read from the cards the page is showing, so it never lists somebody with nothing to show.
4329
+ */
4330
+ function authorOptions(handles) {
4331
+ return [...new Set(handles.filter((handle) => Boolean(handle)))].sort((a, b) => a.localeCompare(b, void 0, { sensitivity: "base" }));
4332
+ }
4333
+ /**
4334
+ * A card menu's status item, which sets the state it names. The bulk bar's words for the same two
4335
+ * actions, so a card and a selection never disagree. Here rather than in the page renderer because
4336
+ * the browser relabels the item after a change, and that renderer reaches the store.
4337
+ */
4338
+ function markItemLabel(mark) {
4339
+ return mark === "complete" ? "Mark reviewed" : "Mark in progress";
4340
+ }
4341
+ /**
4342
+ * What `Continue reviewing` opens, and whether it exists at all.
4343
+ *
4344
+ * One rule rather than an expression in the template, because "is there anything to continue" and
4345
+ * "what is first" are the same question and must not be able to answer differently -- a button that
4346
+ * is present but navigates nowhere is worse than no button.
4347
+ */
4348
+ function continueTarget(queue) {
4349
+ return queue[0] ?? null;
4350
+ }
4351
+ /**
4352
+ * The Queue pill, which carries a number only once there is one.
4353
+ *
4354
+ * "Queue 0" is a count of something that is not there, and reads as a fault. Bare "Queue" reads as
4355
+ * the place it is -- empty, and still the thing you would click to put something in it.
4356
+ */
4357
+ function queuePillLabel(queued) {
4358
+ return queued === 0 ? "Queue" : `Queue ${queued}`;
4359
+ }
4360
+ /**
4144
4361
  * The words on the status chip.
4145
4362
  *
4146
4363
  * Here rather than in the renderer because the browser now writes this chip too: marking a
@@ -4173,6 +4390,10 @@ function renderReviewsPage(view) {
4173
4390
  const now = view.now ?? Date.now();
4174
4391
  const ordered = [...view.groups].sort((a, b) => Number(b.latest.pinned) - Number(a.latest.pinned) || touchedAt(b.latest) - touchedAt(a.latest));
4175
4392
  const storedReviews = view.groups.reduce((total, group) => total + group.history.length, 0);
4393
+ const queue = view.queue ?? [];
4394
+ const positions = new Map(queue.map((id, position) => [id, position]));
4395
+ const continues = continueTarget(queue);
4396
+ const front = continues === null ? void 0 : findReview(view.groups, continues);
4176
4397
  return `<!doctype html>
4177
4398
  <html lang="en">
4178
4399
  <head>
@@ -4194,18 +4415,19 @@ function renderReviewsPage(view) {
4194
4415
  </div>
4195
4416
  </header>
4196
4417
  <main class="prb-index">
4197
- ${ordered.length === 0 ? "" : renderControls()}
4418
+ ${ordered.length === 0 ? "" : renderControls(queue.length, authorOptions(ordered.map((group) => group.latest.authorHandle)))}
4419
+ ${front === void 0 ? "" : renderContinue(front)}
4198
4420
  ${ordered.length === 0 ? "" : `<div class="prb-index-summary">
4199
4421
  <p class="prb-index-count" data-count>${escapeHtml$1(countLabel(storedReviews, ordered.length))}</p>
4200
4422
  <button type="button" class="prb-index-select" data-select-mode aria-pressed="false">Select</button>
4201
4423
  </div>`}
4202
4424
  <div class="prb-cards" data-cards>
4203
- ${ordered.map((group) => renderCard(group, now, view.analyses?.get(group.latest.id))).join("")}
4425
+ ${ordered.map((group) => renderCard(group, now, view.analyses?.get(group.latest.id), positions.get(group.latest.id))).join("")}
4204
4426
  </div>
4205
4427
  ${ordered.length === 0 ? `<p class="prb-index-empty">No reviews stored yet. Ask Claude Code to review a change and one will appear here.</p>` : `<p class="prb-index-none" data-no-matches hidden>Nothing matches that.</p>`}
4206
4428
  ${ordered.length > 15 ? renderPager() : ""}
4207
4429
  ${ordered.length === 0 ? "" : renderBulkBar()}
4208
- <p class="prb-index-note">Reviews are kept for thirty days, on this machine only. Every review has a fixed address, so a link to one is worth keeping and still opens after a restart. Stopping the server deletes nothing; it only stops anything being served until the next review starts it again.</p>
4430
+ <p class="prb-index-note">Reviews are kept for thirty days, on this machine only. Saved reviews and anything in your review queue are kept for ninety days instead, counting from the last time you opened them. Every review has a fixed address, so a link to one is worth keeping and still opens after a restart. Stopping the server deletes nothing; it only stops anything being served until the next review starts it again.</p>
4209
4431
  </main>
4210
4432
  <script src="/assets/reviews.js" type="module"><\/script>
4211
4433
  </body>
@@ -4220,17 +4442,51 @@ function renderReviewsPage(view) {
4220
4442
  * would be four more controls for a question ("show me only the risky ones") that sorting by
4221
4443
  * recency already answers badly and that scanning answers well.
4222
4444
  */
4223
- function renderControls() {
4445
+ function renderControls(queued, authors) {
4224
4446
  return `<div class="prb-index-controls">
4225
4447
  <input type="search" class="prb-index-search" data-search placeholder="Search repo, branch, title, summary or file…" aria-label="Search reviews" spellcheck="false">
4448
+ ${renderAuthorFilter(authors)}
4226
4449
  <div class="prb-index-filters" role="group" aria-label="Filter reviews">
4227
4450
  <button type="button" data-filter="all" aria-pressed="true">All</button>
4451
+ <button type="button" data-filter="queue" aria-pressed="false" data-queue-pill>${escapeHtml$1(queuePillLabel(queued))}</button>
4452
+ <button type="button" data-filter="saved" aria-pressed="false">Saved</button>
4228
4453
  <button type="button" data-filter="in-progress" aria-pressed="false">In progress</button>
4229
4454
  <button type="button" data-filter="complete" aria-pressed="false">Reviewed</button>
4230
4455
  </div>
4231
4456
  </div>`;
4232
4457
  }
4233
4458
  /**
4459
+ * Whose pull requests to show.
4460
+ *
4461
+ * Absent when no card carries a login, which is every page of local-branch reviews: a dropdown
4462
+ * whose only choice is Everyone is not a choice.
4463
+ */
4464
+ function renderAuthorFilter(authors) {
4465
+ if (authors.length === 0) return "";
4466
+ return `<select class="prb-index-author" name="author" data-author-filter aria-label="Filter by pull request author">
4467
+ <option value="">Everyone</option>${authors.map((handle) => `<option value="${escapeHtml$1(handle)}">@${escapeHtml$1(handle)}</option>`).join("")}
4468
+ </select>`;
4469
+ }
4470
+ /**
4471
+ * The way back into the batch, above the list.
4472
+ *
4473
+ * A link rather than a button, and carrying the review's own token, because that is what every
4474
+ * other way into a review on this page is -- the list is the only thing holding every credential.
4475
+ *
4476
+ * It names the front of the queue rather than saying only "Continue reviewing", so somebody
4477
+ * returning after an hour can tell whether it is about to open what they expect before they click.
4478
+ */
4479
+ function renderContinue(review) {
4480
+ return `<a class="prb-index-continue" href="${escapeHtml$1(`/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`)}">
4481
+ <span class="prb-index-continue-label">Continue reviewing</span>
4482
+ <span class="prb-index-continue-title">${escapeHtml$1(review.title)}</span>
4483
+ </a>`;
4484
+ }
4485
+ /** The review a queued id refers to. The caller has already guaranteed it is one this page shows. */
4486
+ function findReview(groups, id) {
4487
+ for (const group of groups) for (const review of group.history) if (review.id === id) return review;
4488
+ }
4489
+ /**
4234
4490
  * The pager, with its numbers left out.
4235
4491
  *
4236
4492
  * How many pages there are depends on what the search matched, and matching happens in the browser
@@ -4301,15 +4557,15 @@ function renderBulkBar() {
4301
4557
  * card and is shown only in selection mode, so entering that mode is a stylesheet change rather
4302
4558
  * than a rebuild of the list.
4303
4559
  */
4304
- function renderCard(group, now, analysis) {
4560
+ function renderCard(group, now, analysis, queued) {
4305
4561
  const review = group.latest;
4306
4562
  const status = statusOf(review);
4307
4563
  const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
4308
4564
  const followed = followedRefName(review.branch, review.headResolvedRef) ?? review.branch;
4309
- return `<article class="prb-card" data-card data-id="${escapeHtml$1(review.id)}" data-status="${status}" data-pinned="${review.pinned}" data-reviews="${group.history.length}" data-search-text="${escapeHtml$1(groupSearchText(group))}">
4565
+ return `<article class="prb-card" data-card data-id="${escapeHtml$1(review.id)}" data-status="${status}" data-pinned="${review.pinned}"${queued === void 0 ? "" : ` data-queue="${queued}"`}${review.authorHandle ? ` data-author="${escapeHtml$1(review.authorHandle)}"` : ""} data-reviews="${group.history.length}" data-search-text="${escapeHtml$1(groupSearchText(group))}">
4310
4566
  <input type="checkbox" class="prb-card-select" data-select="${escapeHtml$1(review.id)}" aria-label="Select ${escapeHtml$1(followed)}">
4311
- <button type="button" class="prb-card-pin" data-pin="${escapeHtml$1(review.id)}" aria-pressed="${review.pinned}" aria-label="${review.pinned ? "Unpin" : "Pin"} this review" title="${review.pinned ? "Unpin" : "Pin to the top"}">${review.pinned ? "&#9733;" : "&#9734;"}</button>
4312
- ${renderCardMenu(group, followed)}
4567
+ <button type="button" class="prb-card-pin" data-pin="${escapeHtml$1(review.id)}" aria-pressed="${review.pinned}" aria-label="${review.pinned ? "Unsave" : "Save"} this review">${review.pinned ? "&#9733;" : "&#9734;"}</button>
4568
+ ${renderCardMenu(group, followed, queued !== void 0, status)}
4313
4569
  <a class="prb-card-link" href="${escapeHtml$1(href)}">
4314
4570
  <div class="prb-card-head">
4315
4571
  <h2>${escapeHtml$1(review.title)}</h2>
@@ -4352,15 +4608,18 @@ function renderCard(group, now, analysis) {
4352
4608
  *
4353
4609
  * The rows underneath carry the per-review menus, and they only exist once there is more than one.
4354
4610
  */
4355
- function renderCardMenu(group, followed) {
4611
+ function renderCardMenu(group, followed, queued, status) {
4356
4612
  const count = group.history.length;
4613
+ const mark = status === "complete" ? "in-progress" : "complete";
4357
4614
  if (count < 2) return renderMenu({
4358
4615
  id: group.latest.id,
4359
4616
  scope: "review",
4360
4617
  label: `Actions for ${followed}`,
4361
4618
  action: "Delete review",
4362
4619
  question: "Delete this review?",
4363
- copy: "Copy review ID"
4620
+ copy: "Copy review ID",
4621
+ queue: queued ? "remove" : "add",
4622
+ mark
4364
4623
  });
4365
4624
  const all = `all ${count} reviews following ${followed}`;
4366
4625
  return renderMenu({
@@ -4369,6 +4628,8 @@ function renderCardMenu(group, followed) {
4369
4628
  label: `Actions for ${followed}`,
4370
4629
  action: `Delete ${all}`,
4371
4630
  question: `Delete ${all}?`,
4631
+ queue: queued ? "remove" : "add",
4632
+ mark,
4372
4633
  copy: "Copy latest review ID"
4373
4634
  });
4374
4635
  }
@@ -4387,10 +4648,14 @@ function renderCardMenu(group, followed) {
4387
4648
  */
4388
4649
  function renderMenu(item) {
4389
4650
  const id = escapeHtml$1(item.id);
4651
+ const markItem = item.mark === void 0 ? "" : `<button type="button" class="prb-menu-item" data-mark-status="${item.mark}" data-mark-id="${id}">${markItemLabel(item.mark)}</button>`;
4652
+ const queueItem = item.queue === void 0 ? "" : `<button type="button" class="prb-menu-item" data-queue-action="${item.queue}" data-queue-id="${id}">${item.queue === "remove" ? "Remove from review queue" : "Add to review queue"}</button>`;
4390
4653
  return `<div class="prb-menu" data-menu>
4391
4654
  <button type="button" class="prb-menu-trigger" data-menu-trigger aria-expanded="false" aria-haspopup="true" aria-label="${escapeHtml$1(item.label)}">&#8943;</button>
4392
4655
  <div class="prb-menu-panel" data-menu-panel hidden>
4393
4656
  <div data-menu-items>
4657
+ ${markItem}
4658
+ ${queueItem}
4394
4659
  <button type="button" class="prb-menu-item" data-copy-id="${id}">${escapeHtml$1(item.copy)}</button>
4395
4660
  <button type="button" class="prb-menu-item prb-menu-item-danger" data-delete="${id}" data-scope="${item.scope}">${escapeHtml$1(item.action)}</button>
4396
4661
  </div>
@@ -4481,7 +4746,7 @@ function renderHistoryRow(review, number, latest, now) {
4481
4746
  */
4482
4747
  function renderAnalysisState(analysis) {
4483
4748
  if (analysis === void 0) return "";
4484
- if (analysis.phase === "failed") return "<span class=\"prb-card-analysis\" data-analysis=\"stopped\">Analysis stopped</span>";
4749
+ if (analysis.phase === "failed") return analysis.stopped ? "<span class=\"prb-card-analysis\" data-analysis=\"stopped\">Analysis stopped</span>" : "<span class=\"prb-card-analysis\" data-analysis=\"failed\">Analysis failed</span>";
4485
4750
  return `<span class="prb-card-analysis" data-analysis="running"><i></i>${escapeHtml$1(phasesFor({ purpose: analysis.purpose })[analysis.phase].doing)}</span>`;
4486
4751
  }
4487
4752
  /**
@@ -4916,9 +5181,11 @@ function analysisStates(now = Date.now(), jobs = allJobs()) {
4916
5181
  });
4917
5182
  continue;
4918
5183
  }
4919
- if (jobs.filter((job) => isTerminal(job) && purposeOf(job) === "review").sort(endedLast)[0]?.phase === "failed") states.set(reviewId, {
5184
+ const ended = jobs.filter((job) => isTerminal(job) && purposeOf(job) === "review").sort(endedLast);
5185
+ if (ended[0]?.phase === "failed") states.set(reviewId, {
4920
5186
  phase: "failed",
4921
- purpose: "review"
5187
+ purpose: "review",
5188
+ ...ended[0].failure?.stopped ? { stopped: true } : {}
4922
5189
  });
4923
5190
  }
4924
5191
  return states;
@@ -4936,8 +5203,9 @@ function reviewsRevision(live = analysisStates()) {
4936
5203
  for (const [id, revision] of workspaceStamps()) hash.update(`${id}:${revision}\n`);
4937
5204
  for (const id of [...live.keys()].sort()) {
4938
5205
  const state = live.get(id);
4939
- hash.update(`${id}@${state.purpose}:${state.phase}\n`);
5206
+ hash.update(`${id}@${state.purpose}:${state.phase}${state.stopped ? ":stopped" : ""}\n`);
4940
5207
  }
5208
+ hash.update(`queue:${queueStamp()}\n`);
4941
5209
  return hash.digest("base64url");
4942
5210
  }
4943
5211
  //#endregion
@@ -4979,6 +5247,7 @@ var AskFailedError = class extends Error {};
4979
5247
  async function askAboutChange(workspace, question, sectionId, signal) {
4980
5248
  const cwd = await askCheckout(workspace);
4981
5249
  const env = agentEnvOf(workspace);
5250
+ const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
4982
5251
  try {
4983
5252
  return extractAnswer(await withUsageRecorded(workspace.id, "ask", env, (onUsage) => agentFor(env).run({
4984
5253
  capability: "read-code",
@@ -4986,10 +5255,11 @@ async function askAboutChange(workspace, question, sectionId, signal) {
4986
5255
  cwd,
4987
5256
  originRepoPath: workspace.repoPath,
4988
5257
  env,
5258
+ ...requestedModel ? { requestedModel } : {},
4989
5259
  timeoutMs: ASK_TIMEOUT_MS,
4990
5260
  signal,
4991
5261
  onUsage
4992
- })));
5262
+ }), void 0, requestedModel));
4993
5263
  } catch (error) {
4994
5264
  if (error instanceof AgentUnavailableError) throw new AskUnavailableError(`${error.message} It is what this review was made with, so the assistant cannot answer.`);
4995
5265
  throw explained(error, env);
@@ -5064,6 +5334,86 @@ function buildPrompt$1(workspace, question, sectionId, cwd) {
5064
5334
  ].join("\n");
5065
5335
  }
5066
5336
  //#endregion
5337
+ //#region ../../packages/review-harness/src/workspace/assistant_runs.ts
5338
+ var RUN_KINDS = ["ask", "diagram"];
5339
+ var runs = /* @__PURE__ */ new Map();
5340
+ function keyOf(workspaceId, kind) {
5341
+ return `${workspaceId}:${kind}`;
5342
+ }
5343
+ /**
5344
+ * Start a run, or learn that one is already going.
5345
+ *
5346
+ * One call rather than a check followed by a start, so there is no gap between them for a second
5347
+ * request to fall through. The existing run comes back rather than a bare refusal because the
5348
+ * caller's next move is to show it, not to report an error.
5349
+ *
5350
+ * A stopped run gives up its place at once, though its handler may still be unwinding. Stop is
5351
+ * over for the reviewer when it answers, and the page reopens Ask then. A stopped run can keep
5352
+ * nothing, and `endRun` leaves a newer run alone, so the two cannot collide. An answered run still
5353
+ * holds its place until it is saved, or the next question would be asked without it.
5354
+ */
5355
+ function beginRun(workspaceId, kind, detail, now = Date.now()) {
5356
+ const key = keyOf(workspaceId, kind);
5357
+ const existing = runs.get(key);
5358
+ if (existing && existing.state !== "stopped") return { alreadyRunning: existing };
5359
+ const controller = new AbortController();
5360
+ const run = {
5361
+ workspaceId,
5362
+ kind,
5363
+ question: detail.question ?? null,
5364
+ sectionId: detail.sectionId,
5365
+ startedAt: now,
5366
+ signal: controller.signal,
5367
+ state: "running",
5368
+ controller
5369
+ };
5370
+ runs.set(key, run);
5371
+ return { run };
5372
+ }
5373
+ /** Null for a stopped run as well: it is over for the page, whatever its handler is still doing. */
5374
+ function reportRun(workspaceId, kind) {
5375
+ const run = runs.get(keyOf(workspaceId, kind));
5376
+ if (!run || run.state === "stopped") return null;
5377
+ return {
5378
+ question: run.question,
5379
+ sectionId: run.sectionId,
5380
+ startedAt: run.startedAt
5381
+ };
5382
+ }
5383
+ /**
5384
+ * The reviewer pressed Stop.
5385
+ *
5386
+ * Idempotent: a second press, a press after the answer landed, and a press with nothing running are
5387
+ * all harmless and all answer false. True only when this call is the one that stopped the run.
5388
+ */
5389
+ function stopRun(workspaceId, kind) {
5390
+ const run = runs.get(keyOf(workspaceId, kind));
5391
+ if (!run || run.state !== "running") return false;
5392
+ run.state = "stopped";
5393
+ run.controller.abort();
5394
+ return true;
5395
+ }
5396
+ /**
5397
+ * The agent answered. Call this in the same continuation that received the answer, before anything
5398
+ * else is awaited, so a Stop handled in between cannot slip ahead of an answer that had already won.
5399
+ *
5400
+ * False when Stop got there first, and then the answer must not be kept however complete it is.
5401
+ */
5402
+ function completeRun(run) {
5403
+ const held = run;
5404
+ if (held.state !== "running") return false;
5405
+ held.state = "completed";
5406
+ return true;
5407
+ }
5408
+ /**
5409
+ * The run is over, whichever way. Removes this run and only this one: by the time a slow handler
5410
+ * gets here a newer run of the same kind may have begun, and it is not this handler's to end.
5411
+ */
5412
+ function endRun(run) {
5413
+ const key = keyOf(run.workspaceId, run.kind);
5414
+ if (runs.get(key) === run) runs.delete(key);
5415
+ }
5416
+ //#endregion
5067
5417
  //#region ../../packages/review-harness/src/workspace/diagram.ts
5068
5418
  /** The same budget a question gets. Drawing is reading, and reading is what takes the time. */
5069
5419
  var DIAGRAM_TIMEOUT_MS = 18e4;
@@ -5120,6 +5470,7 @@ flowchart LR
5120
5470
  async function drawDiagram(workspace, sectionId, signal) {
5121
5471
  const cwd = await askCheckout(workspace);
5122
5472
  const env = agentEnvOf(workspace);
5473
+ const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
5123
5474
  let raw;
5124
5475
  try {
5125
5476
  raw = await withUsageRecorded(workspace.id, "diagram", env, (onUsage) => agentFor(env).run({
@@ -5128,10 +5479,11 @@ async function drawDiagram(workspace, sectionId, signal) {
5128
5479
  cwd,
5129
5480
  originRepoPath: workspace.repoPath,
5130
5481
  env,
5482
+ ...requestedModel ? { requestedModel } : {},
5131
5483
  timeoutMs: DIAGRAM_TIMEOUT_MS,
5132
5484
  signal,
5133
5485
  onUsage
5134
- }));
5486
+ }), void 0, requestedModel);
5135
5487
  } catch (error) {
5136
5488
  if (error instanceof AgentUnavailableError) throw new DiagramUnavailableError(`${error.message} It is what this review was made with, so nothing can be drawn.`);
5137
5489
  throw error;
@@ -5682,6 +6034,7 @@ async function handle(req, res, options) {
5682
6034
  if (url.pathname === "/open") return handleOpen(req, res, url.searchParams, options.indexToken);
5683
6035
  if (url.pathname === "/settings") return void handleSettings(req, res, url.searchParams, options.indexToken);
5684
6036
  if (url.pathname === "/pin") return handlePin(req, res, options.indexToken);
6037
+ if (url.pathname === "/queue") return handleQueue(req, res, options.indexToken);
5685
6038
  if (url.pathname === "/delete") return handleDelete(req, res, options.indexToken);
5686
6039
  if (url.pathname === "/status") return handleStatus(req, res, options.indexToken);
5687
6040
  if (url.pathname === "/revision") return handleRevision(req, res, options.indexToken);
@@ -5704,8 +6057,10 @@ async function handle(req, res, options) {
5704
6057
  if (action === "/file") return void sendFile(res, workspace, url.searchParams);
5705
6058
  if (action === "/ask") return handleAsk(req, res, workspace);
5706
6059
  if (action === "/diagram") return handleDiagram(req, res, workspace);
6060
+ if (action === "/stop") return handleStop(req, res, workspace);
5707
6061
  if (action === "/kiss") return handleKiss(req, res, workspace);
5708
6062
  if (action === "/state") return handleState(req, res, workspace);
6063
+ if (action === "/delete") return handleDeleteOwn(req, res, workspace);
5709
6064
  if (action === "/event") return handleEvent(req, res, workspace);
5710
6065
  if (action === "/prefs") return handlePrefs(req, res);
5711
6066
  if (action === "/prompts") return handlePrompts(req, res);
@@ -5768,8 +6123,14 @@ function handleIndex(req, res, params, indexToken) {
5768
6123
  const cookie = indexCookieHeader(indexToken);
5769
6124
  if (onward) return redirect(res, onward, cookie);
5770
6125
  const states = analysisStates();
6126
+ const queue = readQueue();
6127
+ const kept = new Set(queue);
6128
+ for (const review of recentWorkspaces(Number.MAX_SAFE_INTEGER)) if (review.pinned) kept.add(review.id);
6129
+ const groups = indexReviewGroups(MAX_LISTED_BRANCHES, MAX_KEPT_BRANCHES, kept);
6130
+ const rendered = new Set(groups.flatMap((group) => group.history.map((review) => review.id)));
5771
6131
  sendHtml(res, 200, renderReviewsPage({
5772
- groups: recentReviewGroups(MAX_LISTED_BRANCHES),
6132
+ groups,
6133
+ queue: queue.filter((id) => rendered.has(id)),
5773
6134
  analyses: states,
5774
6135
  revision: reviewsRevision(states)
5775
6136
  }), cookie);
@@ -5922,6 +6283,7 @@ async function handleStatus(req, res, indexToken) {
5922
6283
  workspace.completedAt = complete ? now : void 0;
5923
6284
  if (!complete) workspace.lastViewedAt = workspace.lastViewedAt ?? now;
5924
6285
  saveWorkspace(workspace);
6286
+ dequeueOnComplete(id, complete);
5925
6287
  changed += 1;
5926
6288
  }
5927
6289
  send(res, 200, {
@@ -5975,6 +6337,32 @@ async function handlePin(req, res, indexToken) {
5975
6337
  send(res, 200, { ok: true });
5976
6338
  }
5977
6339
  /**
6340
+ * Adding to and taking out of the working set.
6341
+ *
6342
+ * The index cookie for the same reason as `handlePin` above: this is the list acting on itself, and
6343
+ * a review's own token must not be able to rearrange the other reviews around it.
6344
+ *
6345
+ * The answer carries the queue **as stored**, which is not always the queue the page asked for --
6346
+ * the cap applies, duplicates collapse, and a review somebody deleted in another tab drops out on
6347
+ * the next write. The browser paints from this rather than from its own optimism, which is the one
6348
+ * thing that keeps a pill claiming "Queue 4" honest.
6349
+ *
6350
+ * An unrecognised action is a 400 rather than a no-op 200. A route that silently accepts a word it
6351
+ * does not implement is how a later `top` appears to work while doing nothing.
6352
+ */
6353
+ async function handleQueue(req, res, indexToken) {
6354
+ if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
6355
+ const body = await readJson(req);
6356
+ const id = typeof body?.id === "string" ? body.id : null;
6357
+ const action = body?.action;
6358
+ if (action !== "add" && action !== "remove") return send(res, 400, { error: "Unknown queue action." });
6359
+ if (!id || !loadWorkspace(id)) return send(res, 404, { error: "No such review" });
6360
+ const stored = action === "add" ? queueAdd(id) : queueRemove(id);
6361
+ if (stored === null) return send(res, 500, { error: "The queue could not be saved." });
6362
+ const live = stored.filter((queued) => loadWorkspace(queued) !== null);
6363
+ send(res, 200, { queue: live.length === stored.length ? stored : writeQueue(live) ?? stored });
6364
+ }
6365
+ /**
5978
6366
  * Delete one review, or everything on one card.
5979
6367
  *
5980
6368
  * Authorised the same way pinning is, and for the same reason: this is the list acting on itself,
@@ -6002,7 +6390,35 @@ async function handleDelete(req, res, indexToken) {
6002
6390
  const live = (wholeCard ? lineageIds(id) : [id]).flatMap((each) => liveJobsFor(each));
6003
6391
  if (live.length > 0) return send(res, 409, { error: stillRunning(live.map((one) => one.job.target.branch)) });
6004
6392
  const report = wholeCard ? await deleteLineage(id) : await deleteReview(id);
6005
- if (wasBlocked(report)) return send(res, 409, { error: report.live.length > 0 ? stillRunning(report.live.map((one) => one.job.target.branch)) : "This review is being worked on right now, so nothing was deleted. Try again in a moment." });
6393
+ if (wasBlocked(report)) return send(res, 409, { error: blockedSentence(report) });
6394
+ send(res, 200, {
6395
+ ok: true,
6396
+ deleted: report.deleted.length,
6397
+ refusals: report.refusals
6398
+ });
6399
+ }
6400
+ /** Why a deletion that was allowed to start still removed nothing. */
6401
+ function blockedSentence(report) {
6402
+ return report.live.length > 0 ? stillRunning(report.live.map((one) => one.job.target.branch)) : "This review is being worked on right now, so nothing was deleted. Try again in a moment.";
6403
+ }
6404
+ /**
6405
+ * Delete the review a workspace page is showing, from that page.
6406
+ *
6407
+ * The review's own token rather than the index credential. A page opened from a terminal link has
6408
+ * never been shown the list and so holds no index cookie, and this token already writes the review's
6409
+ * state. Deleting it is the same authority over the same one record, and it reaches no other review.
6410
+ *
6411
+ * Only this review, never its card. A page showing one analysis does not get to decide the fate of
6412
+ * the others in its lineage; the list is where a whole branch is deleted.
6413
+ *
6414
+ * POST only, because it is the one route under `/api/` that destroys something.
6415
+ */
6416
+ async function handleDeleteOwn(req, res, workspace) {
6417
+ if (req.method !== "POST") return send(res, 405, { error: "Deleting a review needs a POST." });
6418
+ const live = liveJobsFor(workspace.id);
6419
+ if (live.length > 0) return send(res, 409, { error: stillRunning(live.map((one) => one.job.target.branch)) });
6420
+ const report = await deleteReview(workspace.id);
6421
+ if (wasBlocked(report)) return send(res, 409, { error: blockedSentence(report) });
6006
6422
  send(res, 200, {
6007
6423
  ok: true,
6008
6424
  deleted: report.deleted.length,
@@ -6041,7 +6457,7 @@ async function deleteCards(ids) {
6041
6457
  if (wasBlocked(report)) {
6042
6458
  blocked.push({
6043
6459
  id,
6044
- error: report.live.length > 0 ? stillRunning(report.live.map((one) => one.job.target.branch)) : "This review is being worked on right now, so nothing was deleted. Try again in a moment."
6460
+ error: blockedSentence(report)
6045
6461
  });
6046
6462
  continue;
6047
6463
  }
@@ -6094,6 +6510,15 @@ function indexCookie(req) {
6094
6510
  /** Branches, not reviews: a branch reviewed five times is one card and must not cost five slots. */
6095
6511
  var MAX_LISTED_BRANCHES = 50;
6096
6512
  /**
6513
+ * How many branches the reviewer's own working set may add on top of the recency window.
6514
+ *
6515
+ * Its own constant rather than a second use of the one above, because the two bound different
6516
+ * things and sharing a name would imply a ceiling the page does not have: what it lists is at most
6517
+ * these two added together. Saved and Queue exist to defeat recency, so they cannot be made to
6518
+ * compete for its slots -- saving eleven reviews must not push eleven recent ones off the page.
6519
+ */
6520
+ var MAX_KEPT_BRANCHES = 50;
6521
+ /**
6097
6522
  * A finished review's workspace was served.
6098
6523
  *
6099
6524
  * **This counts page serves, not people and not sessions.** A reload, a restored tab, the CLI
@@ -6162,6 +6587,8 @@ async function sendWorkspacePage(res, workspace) {
6162
6587
  repoPath: workspace.repoPath,
6163
6588
  askWidth: readPreferences().askWidth,
6164
6589
  transcript: workspace.transcript,
6590
+ pendingAsk: reportRun(workspace.id, "ask"),
6591
+ pendingDiagram: reportRun(workspace.id, "diagram"),
6165
6592
  prompts: readPromptBank(),
6166
6593
  complete: workspace.completedAt !== void 0,
6167
6594
  prContext: workspace.prContext ? {
@@ -6265,7 +6692,9 @@ function jobReport(workspace) {
6265
6692
  return {
6266
6693
  ...jobStatus(workspace.jobId ? loadJob(workspace.jobId) : null),
6267
6694
  kiss: jobStatus(kissRun ? loadJob(kissRun.jobId) : null),
6268
- update: jobStatus(liveUpdateFor(workspace.id))
6695
+ update: jobStatus(liveUpdateFor(workspace.id)),
6696
+ ask: reportRun(workspace.id, "ask"),
6697
+ diagram: reportRun(workspace.id, "diagram")
6269
6698
  };
6270
6699
  }
6271
6700
  /**
@@ -6474,6 +6903,15 @@ async function handleAsk(req, res, workspace) {
6474
6903
  const question = typeof body?.question === "string" ? body.question.trim() : "";
6475
6904
  if (!question) return send(res, 400, { error: "No question given." });
6476
6905
  const sectionId = typeof body?.sectionId === "string" ? body.sectionId : null;
6906
+ const started = beginRun(workspace.id, "ask", {
6907
+ question,
6908
+ sectionId
6909
+ });
6910
+ if ("alreadyRunning" in started) return send(res, 409, {
6911
+ error: "Buddy AI is still answering the last question on this review.",
6912
+ running: true
6913
+ });
6914
+ const run = started.run;
6477
6915
  record({
6478
6916
  event: "assistant_question_asked",
6479
6917
  reviewId: workspace.id,
@@ -6481,40 +6919,64 @@ async function handleAsk(req, res, workspace) {
6481
6919
  scoped: sectionId !== null,
6482
6920
  turn: workspace.transcript.length + 1
6483
6921
  });
6484
- const cancel = new AbortController();
6485
- res.on("close", () => {
6486
- if (!res.writableEnded) cancel.abort();
6487
- });
6488
6922
  const startedAt = Date.now();
6489
- let answer;
6923
+ const failed = () => record({
6924
+ event: "assistant_answered",
6925
+ reviewId: workspace.id,
6926
+ durationMs: Date.now() - startedAt,
6927
+ ok: false,
6928
+ ...run.state === "stopped" ? { cancelled: true } : {}
6929
+ });
6490
6930
  try {
6491
- answer = await askAboutChange(workspace, question, sectionId, cancel.signal);
6492
- } catch (error) {
6493
- const cancelled = error instanceof AgentCancelledError;
6931
+ let answer;
6932
+ try {
6933
+ answer = await askAboutChange(workspace, question, sectionId, run.signal);
6934
+ } catch (error) {
6935
+ failed();
6936
+ if (run.state === "stopped") return sendIfOpen(res, 200, { stopped: true });
6937
+ throw error;
6938
+ }
6939
+ if (!completeRun(run)) {
6940
+ failed();
6941
+ return sendIfOpen(res, 200, { stopped: true });
6942
+ }
6494
6943
  record({
6495
6944
  event: "assistant_answered",
6496
6945
  reviewId: workspace.id,
6497
6946
  durationMs: Date.now() - startedAt,
6498
- ok: false,
6499
- ...cancelled ? { cancelled: true } : {}
6947
+ ok: true
6500
6948
  });
6501
- if (cancelled) return;
6502
- throw error;
6949
+ const fresh = loadWorkspace(workspace.id);
6950
+ if (fresh) {
6951
+ fresh.transcript.push({
6952
+ question,
6953
+ answer,
6954
+ sectionId,
6955
+ askedAt: Date.now()
6956
+ });
6957
+ saveWorkspace(fresh);
6958
+ }
6959
+ sendIfOpen(res, 200, { answer });
6960
+ } finally {
6961
+ endRun(run);
6503
6962
  }
6504
- record({
6505
- event: "assistant_answered",
6506
- reviewId: workspace.id,
6507
- durationMs: Date.now() - startedAt,
6508
- ok: true
6509
- });
6510
- workspace.transcript.push({
6511
- question,
6512
- answer,
6513
- sectionId,
6514
- askedAt: Date.now()
6515
- });
6516
- saveWorkspace(workspace);
6517
- send(res, 200, { answer });
6963
+ }
6964
+ /** Only while somebody is listening. A page that left took its socket with it. */
6965
+ function sendIfOpen(res, status, body) {
6966
+ if (res.writableEnded || res.destroyed) return;
6967
+ send(res, status, body);
6968
+ }
6969
+ /**
6970
+ * The reviewer pressed Stop, on this page or on another one showing the same review.
6971
+ *
6972
+ * The only way a question or a drawing ends early. Idempotent, so a double press and a press that
6973
+ * lands as the answer does are both harmless: the run decides once which of the two won.
6974
+ */
6975
+ async function handleStop(req, res, workspace) {
6976
+ const body = await readJson(req);
6977
+ const kind = RUN_KINDS.find((candidate) => candidate === body?.kind);
6978
+ if (!kind) return send(res, 400, { error: "Say what to stop: the question or the drawing." });
6979
+ send(res, 200, { stopped: stopRun(workspace.id, kind) });
6518
6980
  }
6519
6981
  /**
6520
6982
  * Draw the change, or one theme of it, and keep the drawing.
@@ -6528,46 +6990,60 @@ async function handleDiagram(req, res, workspace) {
6528
6990
  const body = await readJson(req);
6529
6991
  const sectionId = typeof body?.sectionId === "string" ? body.sectionId : null;
6530
6992
  const scope = sectionId ?? "change";
6531
- const existing = (workspace.diagrams ?? []).findIndex((diagram) => diagram.scope === scope);
6993
+ const redraw = (workspace.diagrams ?? []).some((diagram) => diagram.scope === scope);
6994
+ const started = beginRun(workspace.id, "diagram", { sectionId });
6995
+ if ("alreadyRunning" in started) return send(res, 409, {
6996
+ error: "A diagram of this review is already being drawn.",
6997
+ running: true
6998
+ });
6999
+ const run = started.run;
6532
7000
  record({
6533
7001
  event: "diagram_requested",
6534
7002
  reviewId: workspace.id,
6535
7003
  scoped: sectionId !== null,
6536
- redraw: existing !== -1
6537
- });
6538
- const cancel = new AbortController();
6539
- res.on("close", () => {
6540
- if (!res.writableEnded) cancel.abort();
7004
+ redraw
6541
7005
  });
6542
7006
  const startedAt = Date.now();
6543
- let diagram;
7007
+ const failed = () => record({
7008
+ event: "diagram_drawn",
7009
+ reviewId: workspace.id,
7010
+ durationMs: Date.now() - startedAt,
7011
+ ok: false,
7012
+ ...run.state === "stopped" ? { cancelled: true } : {}
7013
+ });
6544
7014
  try {
6545
- diagram = await drawDiagram(workspace, sectionId, cancel.signal);
6546
- } catch (error) {
6547
- const cancelled = error instanceof AgentCancelledError;
7015
+ let diagram;
7016
+ try {
7017
+ diagram = await drawDiagram(workspace, sectionId, run.signal);
7018
+ } catch (error) {
7019
+ failed();
7020
+ if (run.state === "stopped") return sendIfOpen(res, 200, { stopped: true });
7021
+ if (error instanceof DiagramUnavailableError || error instanceof DiagramMalformedError) return sendIfOpen(res, 502, { error: error.message });
7022
+ throw error;
7023
+ }
7024
+ if (!completeRun(run)) {
7025
+ failed();
7026
+ return sendIfOpen(res, 200, { stopped: true });
7027
+ }
6548
7028
  record({
6549
7029
  event: "diagram_drawn",
6550
7030
  reviewId: workspace.id,
6551
7031
  durationMs: Date.now() - startedAt,
6552
- ok: false,
6553
- ...cancelled ? { cancelled: true } : {}
7032
+ ok: true
6554
7033
  });
6555
- if (cancelled) return;
6556
- if (error instanceof DiagramUnavailableError || error instanceof DiagramMalformedError) return send(res, 502, { error: error.message });
6557
- throw error;
7034
+ const fresh = loadWorkspace(workspace.id);
7035
+ if (fresh) {
7036
+ const diagrams = [...fresh.diagrams ?? []];
7037
+ const existing = diagrams.findIndex((one) => one.scope === scope);
7038
+ if (existing === -1) diagrams.push(diagram);
7039
+ else diagrams[existing] = diagram;
7040
+ fresh.diagrams = diagrams;
7041
+ saveWorkspace(fresh);
7042
+ }
7043
+ sendIfOpen(res, 200, { diagram });
7044
+ } finally {
7045
+ endRun(run);
6558
7046
  }
6559
- record({
6560
- event: "diagram_drawn",
6561
- reviewId: workspace.id,
6562
- durationMs: Date.now() - startedAt,
6563
- ok: true
6564
- });
6565
- const diagrams = [...workspace.diagrams ?? []];
6566
- if (existing === -1) diagrams.push(diagram);
6567
- else diagrams[existing] = diagram;
6568
- workspace.diagrams = diagrams;
6569
- saveWorkspace(workspace);
6570
- send(res, 200, { diagram });
6571
7047
  }
6572
7048
  /**
6573
7049
  * Stop serving, at the request of a workspace.
@@ -6631,6 +7107,7 @@ async function handleState(req, res, workspace) {
6631
7107
  }
6632
7108
  if (typeof body?.complete === "boolean") {
6633
7109
  workspace.completedAt = body.complete ? Date.now() : void 0;
7110
+ dequeueOnComplete(workspace.id, body.complete);
6634
7111
  changed = true;
6635
7112
  }
6636
7113
  if (changed) saveWorkspace(workspace);