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/CHANGELOG.md +128 -0
- package/dist/{relative_time-vn8Ex9E8.js → delete_review-DUCSC5Bc.js} +4935 -4440
- package/dist/main.js +861 -306
- package/dist/server.js +585 -108
- package/package.json +1 -1
- package/static/reviews.js +1 -1
- package/static/shell.css +80 -3
- package/static/workspace.js +28 -28
package/dist/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
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
|
|
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
|
-
|
|
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}${
|
|
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, {
|
|
3355
|
-
|
|
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.
|
|
3539
|
-
*
|
|
3540
|
-
*
|
|
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">⋯</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="${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ? "
|
|
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 ? "★" : "☆"}</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)}">⋯</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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
6492
|
-
|
|
6493
|
-
|
|
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:
|
|
6499
|
-
...cancelled ? { cancelled: true } : {}
|
|
6947
|
+
ok: true
|
|
6500
6948
|
});
|
|
6501
|
-
|
|
6502
|
-
|
|
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
|
-
|
|
6505
|
-
|
|
6506
|
-
|
|
6507
|
-
|
|
6508
|
-
|
|
6509
|
-
|
|
6510
|
-
|
|
6511
|
-
|
|
6512
|
-
|
|
6513
|
-
|
|
6514
|
-
|
|
6515
|
-
|
|
6516
|
-
|
|
6517
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
6546
|
-
|
|
6547
|
-
|
|
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:
|
|
6553
|
-
...cancelled ? { cancelled: true } : {}
|
|
7032
|
+
ok: true
|
|
6554
7033
|
});
|
|
6555
|
-
|
|
6556
|
-
if (
|
|
6557
|
-
|
|
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);
|