prreviewbuddy 0.44.0 → 0.46.0
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 +81 -0
- package/dist/{delete_review-BWMmO81Q.js → delete_review-lmVdoybL.js} +25 -1
- package/dist/main.js +1 -1
- package/dist/server.js +933 -92
- package/package.json +1 -1
- package/static/shell.css +549 -1
- package/static/workspace.js +79 -38
package/dist/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { A as createPrState, An as writeAgentPreference, At as allJobs, B as toneOf, Bt as purposeOf, C as readEvents, Ct as forgeFor, D as announceJob, Dn as resolveModel, Dt as checkReview, E as telemetryUploadConsent, Et as checkCodeFreshness, F as markAllRead, Ft as saveJob, G as extractJsonText, H as withUsageRecorded, I as markRead, In as agentFor, J as readMarker, K as ReviewBeingDeletedError, Kt as indexReviewGroups, L as pruneNotifications, Lt as isLiveJob, M as eventName, Mn as STORE_ROOT, Mt as fail, N as headline, Nn as AGENT_IDS, Nt as isTerminal, O as branchOf, On as clearAgentPreference, Ot as followedBranch, P as listNotifications, Pt as loadJob, Q as compactQueue, Qt as mutateWorkspace, R as readNotification, Rn as AgentCancelledError, Rt as phasesFor, T as recordTelemetryUploadConsent, Tn as git, Tt as fillFileUrlTemplate, U as EXPLAIN_SIMPLY_PROMPT, Ut as isClaimed, V as unreadCount$1, W as asString, Wt as followedRefName, Xt as loadWorkspace, Y as removeWorktree, Yt as lineagePosition, Z as relativeTime$1, _ as agentEnvOf, _n as issuesOutstanding, _t as parseWorkspacePort, a as wasBlocked, an as touchWorkspace, bn as processDiscussion, bt as writePortPreference, c as RefreshUnavailableError, cn as dequeueOnComplete, d as refreshPrContext, dn as queueRemove, dt as BUILD_VERSION, en as previousKissResult, et as readIndexToken, f as isUnchanged, fn as queueStamp, gn as isQuestionOutstanding, gt as MIN_WORKSPACE_PORT, h as liveUpdateFor, hn as isIssueOutstanding, ht as MAX_WORKSPACE_PORT, i as liveJobsFor, it as readServerRecord, j as createPrUpdate, jt as endedAt, k as subjectOf, kn as readAgentPreference, kt as reviewedShaOf, l as mergeUpdateInto, ln as queueAdd, m as UpdateAlreadyRunningError, mn as doneVerb, mt as DEFAULT_WORKSPACE_PORT, n as deleteReview, nn as reviewedCommit, nt as clearServerRecord, o as discardJob, on as workspaceRevision, ot as serverEntryExists, p as askCheckout, pn as readQueue, pt as feedbackUrl, q as MANAGED_ROOT, qt as latestKissRun, r as lineageIds, s as FETCH_TIMEOUT_MS, sn as workspaceStamps, st as spawnServer, t as deleteLineage, tn as recentWorkspaces, u as updateReview, un as queueAddMany, ut as writeServerRecord, vn as questionsOutstanding, vt as resolveWorkspacePort, w as record, wn as isCommitReview, wt as remoteHost, x as runJob, xn as describeAuthorship, xt as claimRequestUrl, y as startKissJob, yn as reviewerDispositions, yt as statedWorkspacePort, z as removeNotification, zn as AgentUnavailableError, zt as progressSteps } from "./delete_review-lmVdoybL.js";
|
|
2
2
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
@@ -785,6 +785,34 @@ function svg(paths) {
|
|
|
785
785
|
return `<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">${paths}</svg>`;
|
|
786
786
|
}
|
|
787
787
|
//#endregion
|
|
788
|
+
//#region ../../packages/review-harness/src/domain/ui/components/ask_buddy_menu.ts
|
|
789
|
+
/**
|
|
790
|
+
* Ask Buddy on a finding card: a sparkle beside Copy that opens the assistant's actions for the
|
|
791
|
+
* whole finding.
|
|
792
|
+
*
|
|
793
|
+
* The selection menu has offered these actions for a long time, but only to someone who thought to
|
|
794
|
+
* highlight text first, and few did. This puts the same actions on the card itself. The selection
|
|
795
|
+
* menu stays the precision tool for asking about an excerpt.
|
|
796
|
+
*
|
|
797
|
+
* The markup is the page's existing `[data-menu]` menu (the header's `...` uses it), so it opens,
|
|
798
|
+
* closes and positions exactly as that one does. The panel is empty here and filled when it opens,
|
|
799
|
+
* because it lists the reviewer's saved prompts and those change without the page re-rendering.
|
|
800
|
+
*
|
|
801
|
+
* Workspace only. The extension renders these cards too, and there is no Buddy pane behind a card
|
|
802
|
+
* there for this to open, so `ReviewTabOptions.askBuddy` gates it and the extension never sets it.
|
|
803
|
+
*/
|
|
804
|
+
var ASK_BUDDY_LABEL = "Ask Buddy";
|
|
805
|
+
/** Four-point sparkle, the conventional mark for "ask the AI". currentColor, like Copy beside it. */
|
|
806
|
+
var SPARKLE = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true"><path d="M7.53 1.28a.5.5 0 0 1 .94 0l.9 2.6a3 3 0 0 0 1.85 1.85l2.6.9a.5.5 0 0 1 0 .94l-2.6.9a3 3 0 0 0-1.85 1.85l-.9 2.6a.5.5 0 0 1-.94 0l-.9-2.6a3 3 0 0 0-1.85-1.85l-2.6-.9a.5.5 0 0 1 0-.94l2.6-.9a3 3 0 0 0 1.85-1.85z"/><path d="M13 11.2a.3.3 0 0 1 .56 0l.3.86a1 1 0 0 0 .62.62l.86.3a.3.3 0 0 1 0 .56l-.86.3a1 1 0 0 0-.62.62l-.3.86a.3.3 0 0 1-.56 0l-.3-.86a1 1 0 0 0-.62-.62l-.86-.3a.3.3 0 0 1 0-.56l.86-.3a1 1 0 0 0 .62-.62z"/></svg>`;
|
|
807
|
+
/** `kind` goes into the accessible name, so a screen reader hears which finding it is about. */
|
|
808
|
+
function renderAskBuddyMenu(kind) {
|
|
809
|
+
const name = `${ASK_BUDDY_LABEL} about this ${kind}`;
|
|
810
|
+
return `<div class="prb-menu prb-ask-buddy" data-menu data-ask-buddy-menu>
|
|
811
|
+
<button type="button" class="prb-menu-trigger prb-ask-buddy-trigger" data-menu-trigger aria-haspopup="menu" aria-expanded="false" aria-label="${escapeHtml$1(name)}" data-tip="${escapeHtml$1(ASK_BUDDY_LABEL)}">${SPARKLE}</button>
|
|
812
|
+
<div class="prb-menu-panel prb-ask-buddy-panel" data-menu-panel role="menu" aria-label="${escapeHtml$1(name)}" hidden></div>
|
|
813
|
+
</div>`;
|
|
814
|
+
}
|
|
815
|
+
//#endregion
|
|
788
816
|
//#region ../../packages/review-harness/src/domain/ui/components/finding_copy.ts
|
|
789
817
|
/** An issue: its title in bold, the description, then the location when it has one. */
|
|
790
818
|
function issueMarkdown(issue) {
|
|
@@ -1629,7 +1657,7 @@ function renderProvisionalReason(session) {
|
|
|
1629
1657
|
function renderOverviewActions(session, options) {
|
|
1630
1658
|
const explain = hasExplainedSimply(session) ? `<p class="pr-helper-explain-simply-done">Explained in simple terms. See the Assistant tab.</p>` : `<button type="button" class="pr-helper-explain-simply-button">Explain in simple terms</button>`;
|
|
1631
1659
|
if (!options?.diagrams) return explain;
|
|
1632
|
-
return `<div class="pr-helper-overview-actions">${explain}<button type="button" class="pr-helper-
|
|
1660
|
+
return `<div class="pr-helper-overview-actions">${explain}<button type="button" class="pr-helper-map-button" data-view-nav="map">Diagrams</button></div>`;
|
|
1633
1661
|
}
|
|
1634
1662
|
function renderOverviewTab(session, iconUrl = "", options) {
|
|
1635
1663
|
if (!session) return renderNoReviewState(iconUrl);
|
|
@@ -1873,7 +1901,7 @@ function renderGuideSectionCard(section, counts, activeGuidedSectionId, status,
|
|
|
1873
1901
|
<button type="button" class="pr-helper-guide-start-cta" data-guide-start="${escapeHtml$1(section.id)}">
|
|
1874
1902
|
${isActive ? "Resume guided review →" : isCompleted ? "Review again →" : "Start guided review →"}
|
|
1875
1903
|
</button>
|
|
1876
|
-
${options?.diagrams ? `<button type="button" class="pr-helper-diagram-button" data-diagram-request="${escapeHtml$1(section.id)}">
|
|
1904
|
+
${options?.diagrams ? `<button type="button" class="pr-helper-diagram-button" data-diagram-request="${escapeHtml$1(section.id)}">Generate theme diagram</button>` : ""}
|
|
1877
1905
|
${undo}
|
|
1878
1906
|
</div>
|
|
1879
1907
|
</div>
|
|
@@ -2139,7 +2167,7 @@ function renderClosedFindings(summary, cards, kind, nothingOpen) {
|
|
|
2139
2167
|
<div class="pr-helper-closed-findings-list">${cards}</div>
|
|
2140
2168
|
</details>`;
|
|
2141
2169
|
}
|
|
2142
|
-
function renderIssuesTab(session, investigatingId) {
|
|
2170
|
+
function renderIssuesTab(session, investigatingId, options) {
|
|
2143
2171
|
if (!session) return emptyState("Generate an analysis to see issues.");
|
|
2144
2172
|
const outcome = outcomeOf(session);
|
|
2145
2173
|
if (outcome.kind === "none") return emptyState("Generate an analysis to see issues.");
|
|
@@ -2162,6 +2190,7 @@ function renderIssuesTab(session, investigatingId) {
|
|
|
2162
2190
|
<div class="pr-helper-issue-title-row">
|
|
2163
2191
|
<p class="pr-helper-issue-title">${renderInlineText(issue.title)}</p>
|
|
2164
2192
|
${renderFindingCopySource(issueMarkdown(issue), "issue")}
|
|
2193
|
+
${options?.askBuddy ? renderAskBuddyMenu("issue") : ""}
|
|
2165
2194
|
</div>
|
|
2166
2195
|
<div class="pr-helper-issue-badges">
|
|
2167
2196
|
${renderFindingStatus(issue.reconciliationStatus)}
|
|
@@ -2253,6 +2282,7 @@ function renderQuestionCard(q, sections, answered, records, now = Date.now(), in
|
|
|
2253
2282
|
<div class="pr-helper-issue-title-row">
|
|
2254
2283
|
<p class="pr-helper-issue-title">${renderInlineText(q.question)}</p>
|
|
2255
2284
|
${renderFindingCopySource(questionMarkdown(q), "question")}
|
|
2285
|
+
${options?.askBuddy ? renderAskBuddyMenu("question") : ""}
|
|
2256
2286
|
</div>
|
|
2257
2287
|
<div class="pr-helper-issue-badges">
|
|
2258
2288
|
${renderQuestionStatus(q.reconciliationStatus)}
|
|
@@ -2350,6 +2380,70 @@ function renderQuestionsTab(session, investigatingId, options) {
|
|
|
2350
2380
|
`).join("")}${ungrouped.map(card).join("")}${closed}`;
|
|
2351
2381
|
}
|
|
2352
2382
|
//#endregion
|
|
2383
|
+
//#region ../../packages/review-harness/src/workspace/doc_head.ts
|
|
2384
|
+
/**
|
|
2385
|
+
* The title row a document pane opens with, and the provenance disclosure at the right of it.
|
|
2386
|
+
*
|
|
2387
|
+
* Two things sit in this row and they are not the same kind of thing. The title names the pane;
|
|
2388
|
+
* Analysis details answers "how was this made, and does it describe the code I am looking at" --
|
|
2389
|
+
* when it completed, which commit, which audience, which model, what it spent, which rubric. That
|
|
2390
|
+
* is a question about the whole analysis, so it belongs level with the pane's name rather than
|
|
2391
|
+
* below the output: a reader who meets it after the recommendations has already decided how much
|
|
2392
|
+
* to trust them.
|
|
2393
|
+
*
|
|
2394
|
+
* A control, not a strip. Full width would give provenance the weight of content, which it is not,
|
|
2395
|
+
* and it is the reason the disclosure opens into a panel anchored under the button rather than
|
|
2396
|
+
* pushing the report down the page. Opening it must not move the thing the reader was reading.
|
|
2397
|
+
*
|
|
2398
|
+
* This row lives inside the light document pane, never in the dark page header above it. The
|
|
2399
|
+
* header names the change; these details describe one run against it, and the two would be read as
|
|
2400
|
+
* one fact if they shared a surface. The `prb-doc-` prefix is load-bearing for that: it is what
|
|
2401
|
+
* `pane_palette.test.ts` looks for when it checks that nothing in the pane borrows a shell colour.
|
|
2402
|
+
*/
|
|
2403
|
+
/**
|
|
2404
|
+
* The panel's contents are plain sentences, escaped here, because every caller is passing facts
|
|
2405
|
+
* rather than markup and one of them is a model's own words about a file it cited.
|
|
2406
|
+
*/
|
|
2407
|
+
function renderAnalysisDetails(items) {
|
|
2408
|
+
if (items.length === 0) return "";
|
|
2409
|
+
return `<details class="prb-doc-provenance" data-analysis-details>
|
|
2410
|
+
<summary class="prb-doc-provenance-summary">Analysis details</summary>
|
|
2411
|
+
<div class="prb-doc-provenance-panel">
|
|
2412
|
+
<ul>${items.map((item) => `<li>${escapeHtml$1(item)}</li>`).join("")}</ul>
|
|
2413
|
+
</div>
|
|
2414
|
+
</details>`;
|
|
2415
|
+
}
|
|
2416
|
+
/**
|
|
2417
|
+
* `aside` is markup rather than a string of text: it is a rendered control, and the only one so
|
|
2418
|
+
* far is the disclosure above. Empty when a pane has no provenance to show, which is every state
|
|
2419
|
+
* before a run has produced something, and the row then draws as the bare title it used to be.
|
|
2420
|
+
*/
|
|
2421
|
+
function renderDocHead(title, aside = "") {
|
|
2422
|
+
return `<div class="prb-doc-head">
|
|
2423
|
+
<h3 class="prb-doc-title">${escapeHtml$1(title)}</h3>
|
|
2424
|
+
${aside}
|
|
2425
|
+
</div>`;
|
|
2426
|
+
}
|
|
2427
|
+
/**
|
|
2428
|
+
* Expand a surface to fill the workspace, or bring it back.
|
|
2429
|
+
*
|
|
2430
|
+
* Icon-only, like Copy on a card, because it sits in a title row and a word there would compete
|
|
2431
|
+
* with the title. Both icons are in the markup and the stylesheet shows the one `aria-pressed`
|
|
2432
|
+
* calls for, so the client only has to flip the attribute (see `paintFocus` in focus_mode.ts),
|
|
2433
|
+
* which also rewrites the name to say what pressing it will do.
|
|
2434
|
+
*
|
|
2435
|
+
* `name` is the surface as a person would say it, and goes into the accessible name.
|
|
2436
|
+
*/
|
|
2437
|
+
var EXPAND_ICON = "<path d=\"M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7\"/>";
|
|
2438
|
+
var COLLAPSE_ICON = "<path d=\"M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7\"/>";
|
|
2439
|
+
function focusIcon(paths, className) {
|
|
2440
|
+
return `<svg class="${className}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
|
2441
|
+
}
|
|
2442
|
+
function renderFocusToggle(surface, name) {
|
|
2443
|
+
const label = `Expand ${name}`;
|
|
2444
|
+
return `<button type="button" class="prb-focus-toggle" data-focus-toggle="${surface}" data-focus-name="${escapeHtml$1(name)}" aria-pressed="false" aria-label="${escapeHtml$1(label)}" title="${escapeHtml$1(label)}">${focusIcon(EXPAND_ICON, "prb-focus-expand")}${focusIcon(COLLAPSE_ICON, "prb-focus-collapse")}</button>`;
|
|
2445
|
+
}
|
|
2446
|
+
//#endregion
|
|
2353
2447
|
//#region ../../packages/review-harness/src/workspace/guide_view.ts
|
|
2354
2448
|
/**
|
|
2355
2449
|
* The workspace's Guide: one theme at a time.
|
|
@@ -2400,7 +2494,7 @@ function renderWorkspaceGuide(session, followUpInFlight = null) {
|
|
|
2400
2494
|
return `<div class="prb-guide-detail" data-guide-detail="${escapeHtml$1(section.id)}"${hidden}>${card}</div>`;
|
|
2401
2495
|
}).join("");
|
|
2402
2496
|
return `
|
|
2403
|
-
<div class="pr-helper-guide prb-guide">
|
|
2497
|
+
<div class="pr-helper-guide prb-guide" data-guide-start="${escapeHtml$1(selected.id)}">
|
|
2404
2498
|
${renderGuideProgress(sections, completed)}
|
|
2405
2499
|
${renderThemePicker(sections, selected.id)}
|
|
2406
2500
|
<div class="prb-guide-details">${details}</div>
|
|
@@ -2420,7 +2514,7 @@ function renderGuideProgress(sections, completed) {
|
|
|
2420
2514
|
const done = sections.filter((section) => completed.has(section.id)).length;
|
|
2421
2515
|
const left = remainingMinutes(sections, completed);
|
|
2422
2516
|
const time = left > 0 ? ` · ~${left} min` : "";
|
|
2423
|
-
return `<p class="prb-guide-progress">${done > 0 ? `${done} of ${total} theme${total === 1 ? "" : "s"} completed` : `${total} theme${total === 1 ? "" : "s"}`}${time}</p>`;
|
|
2517
|
+
return `<div class="prb-guide-head"><p class="prb-guide-progress">${done > 0 ? `${done} of ${total} theme${total === 1 ? "" : "s"} completed` : `${total} theme${total === 1 ? "" : "s"}`}${time}</p>${renderFocusToggle("guide", "Review path")}</div>`;
|
|
2424
2518
|
}
|
|
2425
2519
|
/**
|
|
2426
2520
|
* The same theme list the rail draws, for the width where the rail is not drawn.
|
|
@@ -2434,51 +2528,6 @@ function renderThemePicker(sections, selectedId) {
|
|
|
2434
2528
|
return `<div class="prb-guide-picker">${sections.map((section, index) => `<button type="button" class="prb-guide-pick" data-goto-section="${escapeHtml$1(section.id)}" data-theme-index="${index}" data-recommended-start="${section.recommendedStartingPoint}" aria-current="${section.id === selectedId}"><span class="prb-guide-pick-order">${index + 1}</span>${escapeHtml$1(section.title)}</button>`).join("")}</div>`;
|
|
2435
2529
|
}
|
|
2436
2530
|
//#endregion
|
|
2437
|
-
//#region ../../packages/review-harness/src/workspace/doc_head.ts
|
|
2438
|
-
/**
|
|
2439
|
-
* The title row a document pane opens with, and the provenance disclosure at the right of it.
|
|
2440
|
-
*
|
|
2441
|
-
* Two things sit in this row and they are not the same kind of thing. The title names the pane;
|
|
2442
|
-
* Analysis details answers "how was this made, and does it describe the code I am looking at" --
|
|
2443
|
-
* when it completed, which commit, which audience, which model, what it spent, which rubric. That
|
|
2444
|
-
* is a question about the whole analysis, so it belongs level with the pane's name rather than
|
|
2445
|
-
* below the output: a reader who meets it after the recommendations has already decided how much
|
|
2446
|
-
* to trust them.
|
|
2447
|
-
*
|
|
2448
|
-
* A control, not a strip. Full width would give provenance the weight of content, which it is not,
|
|
2449
|
-
* and it is the reason the disclosure opens into a panel anchored under the button rather than
|
|
2450
|
-
* pushing the report down the page. Opening it must not move the thing the reader was reading.
|
|
2451
|
-
*
|
|
2452
|
-
* This row lives inside the light document pane, never in the dark page header above it. The
|
|
2453
|
-
* header names the change; these details describe one run against it, and the two would be read as
|
|
2454
|
-
* one fact if they shared a surface. The `prb-doc-` prefix is load-bearing for that: it is what
|
|
2455
|
-
* `pane_palette.test.ts` looks for when it checks that nothing in the pane borrows a shell colour.
|
|
2456
|
-
*/
|
|
2457
|
-
/**
|
|
2458
|
-
* The panel's contents are plain sentences, escaped here, because every caller is passing facts
|
|
2459
|
-
* rather than markup and one of them is a model's own words about a file it cited.
|
|
2460
|
-
*/
|
|
2461
|
-
function renderAnalysisDetails(items) {
|
|
2462
|
-
if (items.length === 0) return "";
|
|
2463
|
-
return `<details class="prb-doc-provenance" data-analysis-details>
|
|
2464
|
-
<summary class="prb-doc-provenance-summary">Analysis details</summary>
|
|
2465
|
-
<div class="prb-doc-provenance-panel">
|
|
2466
|
-
<ul>${items.map((item) => `<li>${escapeHtml$1(item)}</li>`).join("")}</ul>
|
|
2467
|
-
</div>
|
|
2468
|
-
</details>`;
|
|
2469
|
-
}
|
|
2470
|
-
/**
|
|
2471
|
-
* `aside` is markup rather than a string of text: it is a rendered control, and the only one so
|
|
2472
|
-
* far is the disclosure above. Empty when a pane has no provenance to show, which is every state
|
|
2473
|
-
* before a run has produced something, and the row then draws as the bare title it used to be.
|
|
2474
|
-
*/
|
|
2475
|
-
function renderDocHead(title, aside = "") {
|
|
2476
|
-
return `<div class="prb-doc-head">
|
|
2477
|
-
<h3 class="prb-doc-title">${escapeHtml$1(title)}</h3>
|
|
2478
|
-
${aside}
|
|
2479
|
-
</div>`;
|
|
2480
|
-
}
|
|
2481
|
-
//#endregion
|
|
2482
2531
|
//#region ../../packages/review-harness/src/workspace/diagram_view.ts
|
|
2483
2532
|
/**
|
|
2484
2533
|
* The diagrams a review holds, drawn as cards in one scrolling pane.
|
|
@@ -2560,6 +2609,458 @@ function renderDrawnFrom(diagram, reviewedSha) {
|
|
|
2560
2609
|
return `<p class="prb-diagram-stale">Drawn from <code>${escapeHtml$1(diagram.commitSha.slice(0, 7))}</code>, before this review was brought up to date. Regenerate to draw it from the commit the review now describes.</p>`;
|
|
2561
2610
|
}
|
|
2562
2611
|
//#endregion
|
|
2612
|
+
//#region ../../packages/review-harness/src/domain/change_map/types.ts
|
|
2613
|
+
var MAP_ROLES = [
|
|
2614
|
+
"intent",
|
|
2615
|
+
"actor",
|
|
2616
|
+
"concept",
|
|
2617
|
+
"state",
|
|
2618
|
+
"constraint",
|
|
2619
|
+
"risk",
|
|
2620
|
+
"outcome"
|
|
2621
|
+
];
|
|
2622
|
+
var MAP_STAGES = [
|
|
2623
|
+
"Intent",
|
|
2624
|
+
"Behaviour",
|
|
2625
|
+
"Shared state",
|
|
2626
|
+
"Risks & constraints",
|
|
2627
|
+
"Outcome"
|
|
2628
|
+
];
|
|
2629
|
+
//#endregion
|
|
2630
|
+
//#region ../../packages/review-harness/src/domain/change_map/parse.ts
|
|
2631
|
+
/**
|
|
2632
|
+
* A model's answer, turned into a change map.
|
|
2633
|
+
*
|
|
2634
|
+
* Tolerant in the same way `kiss/parse.ts` is. Fields go missing, arrive as the wrong type or
|
|
2635
|
+
* run long, and all of that degrades to something honest. What it will not do is invent a map: a
|
|
2636
|
+
* JSON object with no nodes in it throws, because an empty picture presented as "this change has no
|
|
2637
|
+
* ideas in it" is a confident wrong claim.
|
|
2638
|
+
*
|
|
2639
|
+
* Only shape is settled here. Whether the edges point at real nodes, whether a theme id is one this
|
|
2640
|
+
* review has, and whether what is left is still worth drawing are `validate.ts`'s questions, because
|
|
2641
|
+
* they need the review to answer.
|
|
2642
|
+
*/
|
|
2643
|
+
/**
|
|
2644
|
+
* Lengths past which text stops fitting the medium.
|
|
2645
|
+
*
|
|
2646
|
+
* A node is read at a glance, beside eleven others. The prompt asks for this brevity; these are
|
|
2647
|
+
* what happens when it is ignored, and they cut at a word rather than mid-word.
|
|
2648
|
+
*/
|
|
2649
|
+
var LIMITS = {
|
|
2650
|
+
mapTitle: 100,
|
|
2651
|
+
intent: 320,
|
|
2652
|
+
title: 80,
|
|
2653
|
+
body: 220,
|
|
2654
|
+
why: 160,
|
|
2655
|
+
anchor: 80,
|
|
2656
|
+
label: 48,
|
|
2657
|
+
note: 200
|
|
2658
|
+
};
|
|
2659
|
+
/** Where a role sits when the model forgot to say. The usual place, not the only one. */
|
|
2660
|
+
var STAGE_OF_ROLE = {
|
|
2661
|
+
intent: 0,
|
|
2662
|
+
concept: 1,
|
|
2663
|
+
actor: 2,
|
|
2664
|
+
state: 2,
|
|
2665
|
+
constraint: 3,
|
|
2666
|
+
risk: 3,
|
|
2667
|
+
outcome: 4
|
|
2668
|
+
};
|
|
2669
|
+
function parseChangeMap(raw) {
|
|
2670
|
+
let parsed;
|
|
2671
|
+
try {
|
|
2672
|
+
parsed = JSON.parse(extractJsonText(raw));
|
|
2673
|
+
} catch {
|
|
2674
|
+
throw new Error("Could not parse the change map as JSON.");
|
|
2675
|
+
}
|
|
2676
|
+
return normalizeChangeMap(parsed);
|
|
2677
|
+
}
|
|
2678
|
+
/**
|
|
2679
|
+
* Any value, made into a change map or refused.
|
|
2680
|
+
*
|
|
2681
|
+
* Exported because a stored map is read back through it too. A review file is JSON on disk that a
|
|
2682
|
+
* previous version wrote, or someone edited, and the page drawing it deserves the same guarantees
|
|
2683
|
+
* as the page drawing a map that has just arrived. Running it twice changes nothing.
|
|
2684
|
+
*/
|
|
2685
|
+
function normalizeChangeMap(value) {
|
|
2686
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("The change map was not a JSON object.");
|
|
2687
|
+
const obj = value;
|
|
2688
|
+
if (!Array.isArray(obj.nodes) || obj.nodes.length === 0) throw new Error("The change map had no nodes. This usually means the model ignored the requested shape.");
|
|
2689
|
+
const { nodes, ids } = normalizeNodes(obj.nodes);
|
|
2690
|
+
return {
|
|
2691
|
+
schemaVersion: 1,
|
|
2692
|
+
title: clip(asString(obj.title).trim(), LIMITS.mapTitle) || "Change map",
|
|
2693
|
+
intent: register(obj.intent, LIMITS.intent) ?? {
|
|
2694
|
+
simple: "",
|
|
2695
|
+
technical: ""
|
|
2696
|
+
},
|
|
2697
|
+
nodes,
|
|
2698
|
+
edges: normalizeEdges(obj.edges, ids)
|
|
2699
|
+
};
|
|
2700
|
+
}
|
|
2701
|
+
/**
|
|
2702
|
+
* Nodes, with ids made safe and unique.
|
|
2703
|
+
*
|
|
2704
|
+
* The model's own ids are what its edges refer to, so they are remembered alongside the safe ones;
|
|
2705
|
+
* `ids` maps one to the other. A second node claiming an id already taken is dropped rather than
|
|
2706
|
+
* renamed: the edges naming that id cannot say which of the two they meant.
|
|
2707
|
+
*/
|
|
2708
|
+
function normalizeNodes(value) {
|
|
2709
|
+
const ids = /* @__PURE__ */ new Map();
|
|
2710
|
+
const taken = /* @__PURE__ */ new Set();
|
|
2711
|
+
const nodes = [];
|
|
2712
|
+
for (const item of value) {
|
|
2713
|
+
if (typeof item !== "object" || item === null) continue;
|
|
2714
|
+
const entry = item;
|
|
2715
|
+
const title = register(entry.title, LIMITS.title);
|
|
2716
|
+
if (!title) continue;
|
|
2717
|
+
const given = asString(entry.id).trim() || title.technical;
|
|
2718
|
+
if (ids.has(given)) continue;
|
|
2719
|
+
const id = uniqueId(slug(given) || `n${nodes.length + 1}`, taken);
|
|
2720
|
+
ids.set(given, id);
|
|
2721
|
+
taken.add(id);
|
|
2722
|
+
const role = MAP_ROLES.includes(entry.role) ? entry.role : "concept";
|
|
2723
|
+
const why = register(entry.why, LIMITS.why);
|
|
2724
|
+
const anchor = clip(asString(entry.anchor).trim(), LIMITS.anchor);
|
|
2725
|
+
const sectionId = asString(entry.sectionId).trim();
|
|
2726
|
+
nodes.push({
|
|
2727
|
+
id,
|
|
2728
|
+
role,
|
|
2729
|
+
stage: stageOf(entry.stage, role),
|
|
2730
|
+
weight: entry.weight === "primary" ? "primary" : "supporting",
|
|
2731
|
+
title,
|
|
2732
|
+
body: register(entry.body, LIMITS.body) ?? {
|
|
2733
|
+
simple: "",
|
|
2734
|
+
technical: ""
|
|
2735
|
+
},
|
|
2736
|
+
...why ? { why } : {},
|
|
2737
|
+
...anchor ? { anchor } : {},
|
|
2738
|
+
...sectionId ? { sectionId } : {}
|
|
2739
|
+
});
|
|
2740
|
+
}
|
|
2741
|
+
return {
|
|
2742
|
+
nodes,
|
|
2743
|
+
ids
|
|
2744
|
+
};
|
|
2745
|
+
}
|
|
2746
|
+
function normalizeEdges(value, ids) {
|
|
2747
|
+
if (!Array.isArray(value)) return [];
|
|
2748
|
+
const edges = [];
|
|
2749
|
+
for (const item of value) {
|
|
2750
|
+
if (typeof item !== "object" || item === null) continue;
|
|
2751
|
+
const entry = item;
|
|
2752
|
+
const label = register(entry.label, LIMITS.label);
|
|
2753
|
+
const from = ids.get(asString(entry.from).trim()) ?? asString(entry.from).trim();
|
|
2754
|
+
const to = ids.get(asString(entry.to).trim()) ?? asString(entry.to).trim();
|
|
2755
|
+
if (!label || !from || !to) continue;
|
|
2756
|
+
const note = register(entry.note, LIMITS.note);
|
|
2757
|
+
edges.push({
|
|
2758
|
+
from,
|
|
2759
|
+
to,
|
|
2760
|
+
label,
|
|
2761
|
+
...note ? { note } : {}
|
|
2762
|
+
});
|
|
2763
|
+
}
|
|
2764
|
+
return edges;
|
|
2765
|
+
}
|
|
2766
|
+
/**
|
|
2767
|
+
* Both registers, or null when there is nothing in either.
|
|
2768
|
+
*
|
|
2769
|
+
* A missing register borrows the other rather than rendering blank: the reviewer who chose Simple
|
|
2770
|
+
* and gets the technical sentence has lost some ease, where one who gets nothing has lost the node.
|
|
2771
|
+
* A bare string is taken as both, which is what a model means when it forgets the shape.
|
|
2772
|
+
*/
|
|
2773
|
+
function register(value, limit) {
|
|
2774
|
+
if (typeof value === "string") {
|
|
2775
|
+
const text = clip(value.trim(), limit);
|
|
2776
|
+
return text ? {
|
|
2777
|
+
simple: text,
|
|
2778
|
+
technical: text
|
|
2779
|
+
} : null;
|
|
2780
|
+
}
|
|
2781
|
+
if (typeof value !== "object" || value === null) return null;
|
|
2782
|
+
const entry = value;
|
|
2783
|
+
const simple = clip(asString(entry.simple).trim(), limit);
|
|
2784
|
+
const technical = clip(asString(entry.technical).trim(), limit);
|
|
2785
|
+
if (!simple && !technical) return null;
|
|
2786
|
+
return {
|
|
2787
|
+
simple: simple || technical,
|
|
2788
|
+
technical: technical || simple
|
|
2789
|
+
};
|
|
2790
|
+
}
|
|
2791
|
+
function stageOf(value, role) {
|
|
2792
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 4 ? value : STAGE_OF_ROLE[role];
|
|
2793
|
+
}
|
|
2794
|
+
/** Short, lowercase, and safe in an attribute, a selector and a URL. */
|
|
2795
|
+
function slug(value) {
|
|
2796
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
|
|
2797
|
+
}
|
|
2798
|
+
function uniqueId(base, taken) {
|
|
2799
|
+
if (!taken.has(base)) return base;
|
|
2800
|
+
let n = 2;
|
|
2801
|
+
while (taken.has(`${base}-${n}`)) n += 1;
|
|
2802
|
+
return `${base}-${n}`;
|
|
2803
|
+
}
|
|
2804
|
+
/** Cut at the last word boundary under the limit, and say that something was cut. */
|
|
2805
|
+
function clip(text, limit) {
|
|
2806
|
+
if (text.length <= limit) return text;
|
|
2807
|
+
const cut = text.slice(0, limit - 1);
|
|
2808
|
+
const space = cut.lastIndexOf(" ");
|
|
2809
|
+
return `${(space > limit / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
|
|
2810
|
+
}
|
|
2811
|
+
//#endregion
|
|
2812
|
+
//#region ../../packages/review-harness/src/domain/change_map/validate.ts
|
|
2813
|
+
/**
|
|
2814
|
+
* Checking a change map against the review it claims to describe, and against what can be drawn.
|
|
2815
|
+
*
|
|
2816
|
+
* The enforcement boundary, in the spirit of `kiss/validate.ts`: what the model got slightly wrong
|
|
2817
|
+
* is repaired and counted, and what cannot be repaired is refused. An edge to a node that does not
|
|
2818
|
+
* exist is dropped rather than drawn into nowhere. A theme id this review never had is withdrawn,
|
|
2819
|
+
* because "Open in Review Path" on it would open nothing. A map with more than a reader can hold is
|
|
2820
|
+
* cut back in reading order, keeping the ideas the model said matter most.
|
|
2821
|
+
*
|
|
2822
|
+
* What it refuses is a map with nothing left to connect. Two ideas and one relationship is the
|
|
2823
|
+
* least that is a map rather than a list, and a list is what the written guide already is.
|
|
2824
|
+
*/
|
|
2825
|
+
/** A map that could not be used. Carries the reply, as `KissRejected` does, for the same reason. */
|
|
2826
|
+
var ChangeMapRejected = class extends Error {
|
|
2827
|
+
answer;
|
|
2828
|
+
constructor(message, answer) {
|
|
2829
|
+
super(message);
|
|
2830
|
+
this.answer = answer;
|
|
2831
|
+
}
|
|
2832
|
+
};
|
|
2833
|
+
function validateChangeMap(raw, sectionIds) {
|
|
2834
|
+
try {
|
|
2835
|
+
return checkChangeMap(parseChangeMap(raw), sectionIds);
|
|
2836
|
+
} catch (error) {
|
|
2837
|
+
throw new ChangeMapRejected(error instanceof Error ? error.message : String(error), raw);
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
/**
|
|
2841
|
+
* A stored map, or null if it is no longer one this page can draw.
|
|
2842
|
+
*
|
|
2843
|
+
* The same rules a fresh map passes, applied to what is on disk. A review file written by an older
|
|
2844
|
+
* version, edited by hand or cut short is a page that shows the ordinary empty state and a way to
|
|
2845
|
+
* generate a new map, never a page that throws.
|
|
2846
|
+
*/
|
|
2847
|
+
function readStoredChangeMap(value, sectionIds) {
|
|
2848
|
+
try {
|
|
2849
|
+
return checkChangeMap(normalizeChangeMap(value), sectionIds).map;
|
|
2850
|
+
} catch {
|
|
2851
|
+
return null;
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
function checkChangeMap(map, sectionIds) {
|
|
2855
|
+
const audit = {
|
|
2856
|
+
droppedNodes: 0,
|
|
2857
|
+
droppedEdges: 0,
|
|
2858
|
+
withdrawnSections: 0,
|
|
2859
|
+
demotedPrimaries: 0
|
|
2860
|
+
};
|
|
2861
|
+
const nodes = demoteExtraPrimaries(capNodes(map.nodes, audit).map((node) => withKnownSection(node, sectionIds, audit)), audit);
|
|
2862
|
+
const known = new Set(nodes.map((node) => node.id));
|
|
2863
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2864
|
+
const edges = [];
|
|
2865
|
+
for (const edge of map.edges) {
|
|
2866
|
+
const key = `${edge.from}\u0000${edge.to}`;
|
|
2867
|
+
if (!known.has(edge.from) || !known.has(edge.to) || edge.from === edge.to || seen.has(key) || edges.length >= 16) {
|
|
2868
|
+
audit.droppedEdges += 1;
|
|
2869
|
+
continue;
|
|
2870
|
+
}
|
|
2871
|
+
seen.add(key);
|
|
2872
|
+
edges.push(edge);
|
|
2873
|
+
}
|
|
2874
|
+
if (nodes.length < 2 || edges.length === 0) throw new Error("The change map had too little in it to draw: it needs at least two ideas and one relationship.");
|
|
2875
|
+
return {
|
|
2876
|
+
map: {
|
|
2877
|
+
...map,
|
|
2878
|
+
nodes: inReadingOrder(nodes),
|
|
2879
|
+
edges
|
|
2880
|
+
},
|
|
2881
|
+
audit
|
|
2882
|
+
};
|
|
2883
|
+
}
|
|
2884
|
+
/**
|
|
2885
|
+
* Down to `MAX_NODES`, keeping the primaries and then the earliest of the rest.
|
|
2886
|
+
*
|
|
2887
|
+
* The model lists nodes in reading order, so "earliest" is "the part of the story it reached
|
|
2888
|
+
* first". Cutting the tail loses detail at the end; cutting a primary would lose the point.
|
|
2889
|
+
*/
|
|
2890
|
+
function capNodes(nodes, audit) {
|
|
2891
|
+
if (nodes.length <= 12) return nodes;
|
|
2892
|
+
const primaries = nodes.filter((node) => node.weight === "primary").slice(0, 3);
|
|
2893
|
+
const rest = nodes.filter((node) => !primaries.includes(node)).slice(0, 12 - primaries.length);
|
|
2894
|
+
const kept = /* @__PURE__ */ new Set([...primaries, ...rest]);
|
|
2895
|
+
audit.droppedNodes += nodes.length - kept.size;
|
|
2896
|
+
return nodes.filter((node) => kept.has(node));
|
|
2897
|
+
}
|
|
2898
|
+
function withKnownSection(node, sectionIds, audit) {
|
|
2899
|
+
if (node.sectionId === void 0 || sectionIds.includes(node.sectionId)) return node;
|
|
2900
|
+
audit.withdrawnSections += 1;
|
|
2901
|
+
const { sectionId: _withdrawn, ...rest } = node;
|
|
2902
|
+
return rest;
|
|
2903
|
+
}
|
|
2904
|
+
/** Everything is not the point. Past three, "primary" has stopped meaning anything. */
|
|
2905
|
+
function demoteExtraPrimaries(nodes, audit) {
|
|
2906
|
+
let primaries = 0;
|
|
2907
|
+
return nodes.map((node) => {
|
|
2908
|
+
if (node.weight !== "primary") return node;
|
|
2909
|
+
primaries += 1;
|
|
2910
|
+
if (primaries <= 3) return node;
|
|
2911
|
+
audit.demotedPrimaries += 1;
|
|
2912
|
+
return {
|
|
2913
|
+
...node,
|
|
2914
|
+
weight: "supporting"
|
|
2915
|
+
};
|
|
2916
|
+
});
|
|
2917
|
+
}
|
|
2918
|
+
/** By stage, keeping the model's own order within one. A stable sort does exactly that. */
|
|
2919
|
+
function inReadingOrder(nodes) {
|
|
2920
|
+
return [...nodes].sort((a, b) => a.stage - b.stage);
|
|
2921
|
+
}
|
|
2922
|
+
//#endregion
|
|
2923
|
+
//#region ../../packages/review-harness/src/workspace/change_map_view.ts
|
|
2924
|
+
/** The body of the map pane: the map, or the way to get one. The pane's frame is `page.ts`'s. */
|
|
2925
|
+
function renderChangeMapBody(input) {
|
|
2926
|
+
const { stored, sections, reviewedSha, register } = input;
|
|
2927
|
+
const map = stored ? readStoredChangeMap(stored, sections.map((section) => section.id)) : null;
|
|
2928
|
+
const stale = Boolean(map && stored?.commitSha && reviewedSha && stored.commitSha !== reviewedSha);
|
|
2929
|
+
if (!map || !stored || stale) return renderEmpty(stale);
|
|
2930
|
+
const titles = new Map(sections.map((section) => [section.id, section.title]));
|
|
2931
|
+
return `<div class="prb-map" data-change-map data-register="${register}">
|
|
2932
|
+
<header class="prb-map-head">
|
|
2933
|
+
<div class="prb-map-heading">
|
|
2934
|
+
<h4 class="prb-map-title">${escapeHtml$1(map.title)}</h4>
|
|
2935
|
+
${map.intent.simple || map.intent.technical ? `<p class="prb-map-intent">${both(map.intent)}</p>` : ""}
|
|
2936
|
+
</div>
|
|
2937
|
+
<div class="prb-map-tools">
|
|
2938
|
+
${renderRegisterSwitch(register)}
|
|
2939
|
+
<button type="button" class="prb-diagram-redraw" data-diagram-request="" data-diagram-kind="map">Regenerate</button>
|
|
2940
|
+
</div>
|
|
2941
|
+
</header>
|
|
2942
|
+
<p class="prb-map-hint">Read from the goal to the outcome. Select an idea to see what it connects to.</p>
|
|
2943
|
+
${renderBoard(map, titles)}
|
|
2944
|
+
${renderList(map)}
|
|
2945
|
+
</div>`;
|
|
2946
|
+
}
|
|
2947
|
+
function renderEmpty(stale) {
|
|
2948
|
+
return `<div class="prb-map-empty" data-change-map-empty>
|
|
2949
|
+
${stale ? "<p class=\"prb-map-stale\">The review has moved on since the last map was drawn.</p>" : ""}
|
|
2950
|
+
<h4 class="prb-map-empty-title">See the change as a map</h4>
|
|
2951
|
+
<p>See how the main ideas in this change connect, what depends on what, and where the important risks are. Each idea links back to its theme in the Review Path.</p>
|
|
2952
|
+
<p class="prb-map-empty-note">Buddy AI builds it from this review, in plain words and technical ones. It takes a minute or two.</p>
|
|
2953
|
+
<button type="button" class="prb-map-generate" data-diagram-request="" data-diagram-kind="map">Generate change map</button>
|
|
2954
|
+
</div>`;
|
|
2955
|
+
}
|
|
2956
|
+
/** Two pressed-state buttons rather than a checkbox: both choices are named, and neither is "off". */
|
|
2957
|
+
function renderRegisterSwitch(register) {
|
|
2958
|
+
const option = (value, label) => `<button type="button" class="prb-map-register" data-map-register-option="${value}" aria-pressed="${register === value}">${label}</button>`;
|
|
2959
|
+
return `<div class="prb-map-registers" role="group" aria-label="Wording">${option("simple", "Simple")}${option("technical", "Technical")}</div>`;
|
|
2960
|
+
}
|
|
2961
|
+
/**
|
|
2962
|
+
* The board: one column per stage the map uses, in reading order, and the lines between them.
|
|
2963
|
+
*
|
|
2964
|
+
* Only the stages that have something in them get a column. A map of a small change with no shared
|
|
2965
|
+
* state should not carry an empty "Shared state" column that asks the reviewer to wonder what is
|
|
2966
|
+
* missing from it.
|
|
2967
|
+
*/
|
|
2968
|
+
function renderBoard(map, titles) {
|
|
2969
|
+
const stages = [...new Set(map.nodes.map((node) => node.stage))].sort((a, b) => a - b);
|
|
2970
|
+
const columns = stages.map((stage) => `<div class="prb-map-stage" data-map-stage="${stage}">
|
|
2971
|
+
<h5 class="prb-map-stage-title">${escapeHtml$1(MAP_STAGES[stage] ?? "")}</h5>
|
|
2972
|
+
${map.nodes.filter((node) => node.stage === stage).sort((a, b) => Number(b.weight === "primary") - Number(a.weight === "primary")).map((node) => renderNode(node, titles, map)).join("")}
|
|
2973
|
+
</div>`).join("");
|
|
2974
|
+
const labels = map.edges.map((edge, index) => renderEdgeLabel(edge, index)).join("");
|
|
2975
|
+
return `<div class="prb-map-board" data-map-board style="--prb-map-stages: ${stages.length}">
|
|
2976
|
+
<svg class="prb-map-edges" data-map-edges aria-hidden="true" focusable="false"></svg>
|
|
2977
|
+
${columns}
|
|
2978
|
+
<div class="prb-map-labels" aria-hidden="true">${labels}</div>
|
|
2979
|
+
</div>`;
|
|
2980
|
+
}
|
|
2981
|
+
/** The part a node plays, as a word. Never colour alone. */
|
|
2982
|
+
var ROLE_LABEL = {
|
|
2983
|
+
intent: "Goal",
|
|
2984
|
+
actor: "Actor",
|
|
2985
|
+
concept: "Idea",
|
|
2986
|
+
state: "Shared state",
|
|
2987
|
+
constraint: "Rule",
|
|
2988
|
+
risk: "Risk",
|
|
2989
|
+
outcome: "Outcome"
|
|
2990
|
+
};
|
|
2991
|
+
function renderNode(node, titles, map) {
|
|
2992
|
+
const id = escapeHtml$1(node.id);
|
|
2993
|
+
const theme = node.sectionId ? titles.get(node.sectionId) : void 0;
|
|
2994
|
+
const names = new Map(map.nodes.map((other) => [other.id, other.title]));
|
|
2995
|
+
const inline = map.edges.map((edge, index) => ({
|
|
2996
|
+
edge,
|
|
2997
|
+
index
|
|
2998
|
+
})).filter(({ edge }) => edge.from === node.id && names.has(edge.to)).map(({ edge, index }) => `<li data-inline-edge="${index}" hidden><em>${both(edge.label)}</em> <span aria-hidden="true">→</span> ${both(names.get(edge.to))}</li>`).join("");
|
|
2999
|
+
return `<article class="prb-map-node" data-map-node="${id}" data-role="${node.role}" data-weight="${node.weight}" tabindex="0" aria-labelledby="prb-map-node-${id}">
|
|
3000
|
+
<p class="prb-map-role">${ROLE_LABEL[node.role]}${node.weight === "primary" ? "<span class=\"prb-map-core\"> · Core idea</span>" : ""}</p>
|
|
3001
|
+
<h5 class="prb-map-node-title" id="prb-map-node-${id}">${both(node.title)}</h5>
|
|
3002
|
+
${node.body.simple || node.body.technical ? `<p class="prb-map-node-body">${both(node.body)}</p>` : ""}
|
|
3003
|
+
${node.why ? `<p class="prb-map-why"><span class="prb-map-why-lead">Why it matters:</span> ${both(node.why)}</p>` : ""}
|
|
3004
|
+
${node.anchor ? `<code class="prb-map-anchor">${escapeHtml$1(node.anchor)}</code>` : ""}
|
|
3005
|
+
${inline ? `<ul class="prb-map-inline" aria-hidden="true">${inline}</ul>` : ""}
|
|
3006
|
+
${node.sectionId && theme ? `<button type="button" class="prb-map-goto" data-goto-section="${escapeHtml$1(node.sectionId)}" aria-label="Open ${escapeHtml$1(theme)} in the Review Path">Review Path: ${escapeHtml$1(theme)} <span aria-hidden="true">→</span></button>` : ""}
|
|
3007
|
+
</article>`;
|
|
3008
|
+
}
|
|
3009
|
+
/** Placed by the client, at the middle of its line. Written here so its words are escaped here. */
|
|
3010
|
+
function renderEdgeLabel(edge, index) {
|
|
3011
|
+
return `<span class="prb-map-label" data-map-edge="${index}" data-from="${escapeHtml$1(edge.from)}" data-to="${escapeHtml$1(edge.to)}">${both(edge.label)}</span>`;
|
|
3012
|
+
}
|
|
3013
|
+
/**
|
|
3014
|
+
* The same relationships as sentences.
|
|
3015
|
+
*
|
|
3016
|
+
* For a screen reader, which cannot follow a line; for a narrow pane, where the lines are hardest to
|
|
3017
|
+
* follow; and for the case where laying the lines out failed, when this is the map.
|
|
3018
|
+
*/
|
|
3019
|
+
function renderList(map) {
|
|
3020
|
+
const names = new Map(map.nodes.map((node) => [node.id, node.title]));
|
|
3021
|
+
return `<details class="prb-map-list">
|
|
3022
|
+
<summary>Read as a list</summary>
|
|
3023
|
+
<ol>${map.edges.map((edge) => {
|
|
3024
|
+
const from = names.get(edge.from);
|
|
3025
|
+
const to = names.get(edge.to);
|
|
3026
|
+
if (!from || !to) return "";
|
|
3027
|
+
return `<li><strong>${both(from)}</strong> <em>${both(edge.label)}</em> <strong>${both(to)}</strong>${edge.note ? `<span class="prb-map-note"> — ${both(edge.note)}</span>` : ""}</li>`;
|
|
3028
|
+
}).join("")}</ol>
|
|
3029
|
+
</details>`;
|
|
3030
|
+
}
|
|
3031
|
+
/**
|
|
3032
|
+
* Both registers, side by side in the markup; the board's `data-register` shows one.
|
|
3033
|
+
*
|
|
3034
|
+
* Backtick spans become code, after escaping, as in every other place model prose is shown: the
|
|
3035
|
+
* technical register names identifiers, and a literal pair of ticks around one reads as noise.
|
|
3036
|
+
*/
|
|
3037
|
+
function both(text) {
|
|
3038
|
+
if (text.simple === text.technical) return renderInlineText(text.simple);
|
|
3039
|
+
return `<span data-reg="simple">${renderInlineText(text.simple)}</span><span data-reg="technical">${renderInlineText(text.technical)}</span>`;
|
|
3040
|
+
}
|
|
3041
|
+
//#endregion
|
|
3042
|
+
//#region ../../packages/review-harness/src/workspace/diagram_modes.ts
|
|
3043
|
+
var MODES = [{
|
|
3044
|
+
mode: "map",
|
|
3045
|
+
title: "Change map",
|
|
3046
|
+
subtitle: "Understand this change"
|
|
3047
|
+
}, {
|
|
3048
|
+
mode: "diagram",
|
|
3049
|
+
title: "Implementation",
|
|
3050
|
+
subtitle: "How the code does it"
|
|
3051
|
+
}];
|
|
3052
|
+
/**
|
|
3053
|
+
* `ready` says which pictures exist, shown as a small check on the tab, so from either tab the
|
|
3054
|
+
* reviewer can tell whether the other one is already there or would cost a run. Always in the
|
|
3055
|
+
* markup and hidden when not ready, so the client can flip it when a drawing lands.
|
|
3056
|
+
*/
|
|
3057
|
+
function renderDiagramModes(selected, ready) {
|
|
3058
|
+
return `<div class="prb-diagram-modes" role="tablist" aria-label="Kind of diagram">${MODES.map(({ mode, title, subtitle }) => `<button type="button" role="tab" class="prb-diagram-mode" data-view-nav="${mode}" data-diagram-mode="${mode}" aria-selected="${mode === selected}" tabindex="${mode === selected ? 0 : -1}"${ready[mode] ? " data-ready" : ""}>
|
|
3059
|
+
<span class="prb-diagram-mode-title">${title}<span class="prb-diagram-mode-ready" data-mode-ready${ready[mode] ? "" : " hidden"}><span aria-hidden="true">✓</span><span class="prb-visually-hidden">, generated</span></span></span>
|
|
3060
|
+
<span class="prb-diagram-mode-subtitle">${subtitle}</span>
|
|
3061
|
+
</button>`).join("")}</div>`;
|
|
3062
|
+
}
|
|
3063
|
+
//#endregion
|
|
2563
3064
|
//#region ../../packages/review-harness/src/workspace/file_links.ts
|
|
2564
3065
|
/**
|
|
2565
3066
|
* Where a file lives on the pull request, when there is one.
|
|
@@ -2808,7 +3309,7 @@ function renderPrContextView(view, attribution, refresh, conversation) {
|
|
|
2808
3309
|
const { pullRequest, discussion } = view.prContext;
|
|
2809
3310
|
const { processed } = view;
|
|
2810
3311
|
const buckets = BUCKETS.map((bucket) => renderBucket(bucket.title, bucket.note, processed[bucket.key])).join("");
|
|
2811
|
-
return
|
|
3312
|
+
return `${renderDocHead("PR context", renderFocusToggle("prcontext", "PR context"))}
|
|
2812
3313
|
${renderProvenance$1(pullRequest, discussion.fetchedAt, processed)}
|
|
2813
3314
|
${renderRefresh(refresh)}
|
|
2814
3315
|
${renderAttribution(attribution, processed)}
|
|
@@ -2876,7 +3377,7 @@ function renderAttribution(attribution, processed) {
|
|
|
2876
3377
|
*/
|
|
2877
3378
|
function renderNothingConnected(conversation) {
|
|
2878
3379
|
const reason = conversation?.kind === "no-request" ? "This branch had no pull request open when the review was made, so there was no discussion to bring in." : conversation && conversation.kind !== "attached" ? conversationNote$1(conversation) : "";
|
|
2879
|
-
return
|
|
3380
|
+
return `${renderDocHead("PR context", renderFocusToggle("prcontext", "PR context"))}
|
|
2880
3381
|
<p class="prb-pr-empty">
|
|
2881
3382
|
No pull request discussion was brought into this review, so none of the findings have been
|
|
2882
3383
|
checked against what other reviewers have already said.
|
|
@@ -3009,6 +3510,9 @@ function verdictLabel(verdict) {
|
|
|
3009
3510
|
* difference between "the analysis had nothing to say" and "the analysis did not run" should be
|
|
3010
3511
|
* available to a test as a fact rather than as a substring of prose.
|
|
3011
3512
|
*/
|
|
3513
|
+
/** Both KISS panes can take the whole review page, in every state they are drawn in. */
|
|
3514
|
+
var KISS_TOGGLE = renderFocusToggle("kiss", "KISS review");
|
|
3515
|
+
var RECS_HEAD = renderDocHead("Recommendations", renderFocusToggle("kissrecs", "Recommendations"));
|
|
3012
3516
|
/**
|
|
3013
3517
|
* What each payoff level is called where a reader can see it.
|
|
3014
3518
|
*
|
|
@@ -3079,7 +3583,7 @@ function renderKissSummary(view) {
|
|
|
3079
3583
|
if (!run) return renderKissEmpty(view.canRun);
|
|
3080
3584
|
if (run.status === "generating") return renderRunning(run, view.job ?? null);
|
|
3081
3585
|
if (run.status === "failed" || !run.result) return renderFailed(run, view);
|
|
3082
|
-
return `${renderDocHead("KISS review", renderAnalysisDetails(provenanceFacts(run)))}
|
|
3586
|
+
return `${renderDocHead("KISS review", `${renderAnalysisDetails(provenanceFacts(run))}${KISS_TOGGLE}`)}
|
|
3083
3587
|
<div class="prb-kiss-fresh-region" data-kiss-fresh-region data-reviewed-sha="${escapeHtml$1(run.reviewedSha)}"${view.freshness ? ` data-freshness="${escapeHtml$1(JSON.stringify(view.freshness))}"` : ""}${view.dismissedFreshnessSha ? ` data-dismissed-sha="${escapeHtml$1(view.dismissedFreshnessSha)}"` : ""}>${renderKissFreshness(run.reviewedSha, view.freshness ?? null, {
|
|
3084
3588
|
dismissedSha: view.dismissedFreshnessSha,
|
|
3085
3589
|
actions: view.canRun
|
|
@@ -3128,14 +3632,14 @@ function renderKissFreshness(reviewedSha, freshness, options = {}) {
|
|
|
3128
3632
|
function renderKissRecommendations(view) {
|
|
3129
3633
|
const presented = presentedKissRun(view);
|
|
3130
3634
|
const result = presented?.result;
|
|
3131
|
-
if (!result) return
|
|
3635
|
+
if (!result) return `${RECS_HEAD}${renderNoRunYet(view)}`;
|
|
3132
3636
|
const earlier = earlierNote(view, presented);
|
|
3133
|
-
if (result.recommendations.length === 0) return
|
|
3637
|
+
if (result.recommendations.length === 0) return `${RECS_HEAD}${earlier}
|
|
3134
3638
|
<div class="prb-kiss-outcome" data-review-outcome="empty">
|
|
3135
3639
|
<h4 class="prb-kiss-outcome-title">Nothing to simplify</h4>
|
|
3136
3640
|
<p class="prb-kiss-outcome-body">The analysis completed and did not identify a materially simpler way to meet these requirements.</p>
|
|
3137
3641
|
</div>`;
|
|
3138
|
-
return
|
|
3642
|
+
return `${RECS_HEAD}${earlier}
|
|
3139
3643
|
${result.recommendations.map((recommendation) => renderRecommendation(recommendation)).join("")}`;
|
|
3140
3644
|
}
|
|
3141
3645
|
/**
|
|
@@ -3163,7 +3667,7 @@ function earlierNote(view, presented) {
|
|
|
3163
3667
|
* else's code for the person who wrote it.
|
|
3164
3668
|
*/
|
|
3165
3669
|
function renderKissEmpty(canRun) {
|
|
3166
|
-
return `${renderDocHead("KISS review")}
|
|
3670
|
+
return `${renderDocHead("KISS review", KISS_TOGGLE)}
|
|
3167
3671
|
<p class="prb-kiss-lede">${escapeHtml$1(KISS_DESCRIPTION)}</p>
|
|
3168
3672
|
<p class="prb-kiss-note">KISS looks at whether the complexity here is justified. It does not replace the review beside it, which is where correctness and risk are answered.</p>
|
|
3169
3673
|
${renderRunControl(canRun, "Run KISS review")}`;
|
|
@@ -3215,7 +3719,7 @@ function renderRunning(run, job) {
|
|
|
3215
3719
|
const phase = job?.phase ?? "analysing";
|
|
3216
3720
|
const copy = phaseSentence(job) || "Running KISS review. This takes about as long as a review.";
|
|
3217
3721
|
const progress = job?.progress ? ` <span class="prb-job-progress">${escapeHtml$1(job.progress)}</span>` : "";
|
|
3218
|
-
return `${renderDocHead("KISS review")}
|
|
3722
|
+
return `${renderDocHead("KISS review", KISS_TOGGLE)}
|
|
3219
3723
|
<div class="prb-kiss-status" data-kiss-phase="${escapeHtml$1(phase)}" role="status" aria-live="polite">
|
|
3220
3724
|
<p><span class="prb-spinner" aria-hidden="true"></span> ${escapeHtml$1(copy)}${progress}</p>
|
|
3221
3725
|
<p class="prb-kiss-status-note">Started ${escapeHtml$1(relativeTime$1(run.startedAt))}. The review beside this one is unaffected and can be read while it runs.</p>
|
|
@@ -3229,7 +3733,7 @@ function renderRunning(run, job) {
|
|
|
3229
3733
|
*/
|
|
3230
3734
|
function renderFailed(run, view) {
|
|
3231
3735
|
const standing = view.previous ?? null;
|
|
3232
|
-
return `${renderDocHead("KISS review")}
|
|
3736
|
+
return `${renderDocHead("KISS review", KISS_TOGGLE)}
|
|
3233
3737
|
<div class="prb-kiss-outcome" data-review-outcome="failed">
|
|
3234
3738
|
<h4 class="prb-kiss-outcome-title">KISS review could not be completed</h4>
|
|
3235
3739
|
<p class="prb-kiss-outcome-body">${escapeHtml$1(run.error ?? "The analysis did not finish.")}</p>
|
|
@@ -3999,11 +4503,12 @@ function renderAvailable(job, fileCount) {
|
|
|
3999
4503
|
* with the broken phase marked, so the answer is which step failed rather than merely that
|
|
4000
4504
|
* something did.
|
|
4001
4505
|
*/
|
|
4506
|
+
var PROGRESS_TOGGLE = renderFocusToggle("progress", "Review progress");
|
|
4002
4507
|
function renderProgressView(job, fileCount) {
|
|
4003
|
-
if (job.phase === "failed") return
|
|
4508
|
+
if (job.phase === "failed") return `${renderDocHead("Review stopped", PROGRESS_TOGGLE)}
|
|
4004
4509
|
${renderSteps(job)}
|
|
4005
4510
|
<div class="prb-progress-failure">${renderFailureBody(job, { lead: false })}</div>`;
|
|
4006
|
-
return
|
|
4511
|
+
return `${renderDocHead("Review in progress", PROGRESS_TOGGLE)}
|
|
4007
4512
|
<p class="prb-progress-lead">Building the context needed to review this change.</p>
|
|
4008
4513
|
${renderSteps(job)}
|
|
4009
4514
|
${renderAvailable(job, fileCount)}
|
|
@@ -4232,31 +4737,59 @@ function renderKissViews(view) {
|
|
|
4232
4737
|
function renderFilesView(view, sections) {
|
|
4233
4738
|
if (!(view.session.result !== null || (view.job ? changeIsRead(view.job) : false))) return "";
|
|
4234
4739
|
return `<section class="prb-view" data-view="files" hidden>
|
|
4235
|
-
|
|
4740
|
+
${renderDocHead("Files", renderFocusToggle("files", "Files"))}
|
|
4236
4741
|
${renderFiles(view, sections)}
|
|
4237
4742
|
</section>`;
|
|
4238
4743
|
}
|
|
4744
|
+
/**
|
|
4745
|
+
* What the implementation tab says before anything has been drawn on it.
|
|
4746
|
+
*
|
|
4747
|
+
* Hidden by the stylesheet the moment a card is in the pane, so the client can add the first
|
|
4748
|
+
* drawing without having to remember to take this away.
|
|
4749
|
+
*/
|
|
4750
|
+
function renderImplementationEmpty() {
|
|
4751
|
+
return `<div class="prb-map-empty prb-diagram-empty" data-diagram-empty>
|
|
4752
|
+
<h4 class="prb-map-empty-title">Trace how the code does it</h4>
|
|
4753
|
+
<p>Which parts of the change call which, and in what order. Draw the whole change here, or one theme from its Review Path card.</p>
|
|
4754
|
+
<button type="button" class="prb-map-generate" data-diagram-request="">Generate implementation diagram</button>
|
|
4755
|
+
</div>`;
|
|
4756
|
+
}
|
|
4239
4757
|
/** The standard review's panes, drawn only where there is a standard result to draw them from. */
|
|
4240
4758
|
function renderStandardViews(view, sections) {
|
|
4241
4759
|
const { session } = view;
|
|
4242
4760
|
const result = session.result;
|
|
4243
|
-
return `<section class="prb-view" data-view="overview">${renderDocHead("Review overview", renderAnalysisDetails(analysisDetailItems(session)))}${renderOverviewTab(session, "", {
|
|
4761
|
+
return `<section class="prb-view" data-view="overview">${renderDocHead("Review overview", `${renderAnalysisDetails(analysisDetailItems(session))}${renderFocusToggle("overview", "Overview")}`)}${renderOverviewTab(session, "", {
|
|
4244
4762
|
diagrams: true,
|
|
4245
4763
|
detailsInHeader: true
|
|
4246
4764
|
})}</section>
|
|
4247
4765
|
<section class="prb-view" data-view="guide" hidden>${renderWorkspaceGuide(session)}</section>
|
|
4248
|
-
<section class="prb-view" data-view="
|
|
4249
|
-
|
|
4766
|
+
<section class="prb-view" data-view="map" hidden data-map-pane data-map-register="${view.changeMapRegister ?? "simple"}"${view.pendingDiagram?.drawing === "map" ? " data-map-running" : ""}>
|
|
4767
|
+
${renderDocHead("Diagrams", renderFocusToggle("map", "Change map"))}
|
|
4768
|
+
${renderDiagramModes("map", diagramsReady(view))}
|
|
4769
|
+
<div class="prb-map-body" data-map-body>${renderChangeMapBody({
|
|
4770
|
+
stored: view.changeMap,
|
|
4771
|
+
sections,
|
|
4772
|
+
reviewedSha: view.reviewedSha ?? null,
|
|
4773
|
+
register: view.changeMapRegister ?? "simple"
|
|
4774
|
+
})}</div>
|
|
4775
|
+
</section>
|
|
4776
|
+
<section class="prb-view" data-view="diagram" hidden data-diagram-pane${view.pendingDiagram && view.pendingDiagram.drawing !== "map" ? ` data-diagram-running="${escapeHtml$1(view.pendingDiagram.sectionId ?? "")}"` : ""}>
|
|
4777
|
+
${renderDocHead("Diagrams", renderFocusToggle("diagram", "Implementation diagrams"))}
|
|
4778
|
+
${renderDiagramModes("diagram", diagramsReady(view))}
|
|
4779
|
+
${renderImplementationEmpty()}
|
|
4250
4780
|
${renderDiagrams(view.diagrams ?? [], sections, view.reviewedSha ?? null)}
|
|
4251
4781
|
</section>
|
|
4252
4782
|
<section class="prb-view" data-view="issues" hidden>
|
|
4253
|
-
|
|
4783
|
+
${renderDocHead("Issues", renderFocusToggle("issues", "Issues"))}
|
|
4254
4784
|
${renderAttributionAge(view)}
|
|
4255
|
-
${renderIssuesTab(session, null)}
|
|
4785
|
+
${renderIssuesTab(session, null, { askBuddy: true })}
|
|
4256
4786
|
</section>
|
|
4257
4787
|
<section class="prb-view" data-view="questions" hidden>
|
|
4258
|
-
|
|
4259
|
-
${renderQuestionsTab(session, null, {
|
|
4788
|
+
${renderDocHead("Questions", renderFocusToggle("questions", "Questions"))}
|
|
4789
|
+
${renderQuestionsTab(session, null, {
|
|
4790
|
+
suppressFileJump: true,
|
|
4791
|
+
askBuddy: true
|
|
4792
|
+
})}
|
|
4260
4793
|
</section>
|
|
4261
4794
|
<section class="prb-view" data-view="prcontext" hidden>
|
|
4262
4795
|
${renderPrContextView(view.prContext ?? null, {
|
|
@@ -4561,7 +5094,7 @@ function renderRail(view, sections, stale = false) {
|
|
|
4561
5094
|
<div class="prb-rail-path" id="prb-rail-path">
|
|
4562
5095
|
${themes || "<p class=\"prb-rail-heading\">No themes</p>"}
|
|
4563
5096
|
</div>
|
|
4564
|
-
${
|
|
5097
|
+
${railLink("map", "diagrams", "Diagrams", diagramsBadge(diagramCount(view)), void 0, "diagram")}
|
|
4565
5098
|
${railLink("issues", "issues", "Issues", findingsBadge(issuesOutstanding(issues, dispositions)))}
|
|
4566
5099
|
${railLink("questions", "questions", "Questions", remainingBadge(questionsOutstanding(questions, answered), "question", "answered"))}
|
|
4567
5100
|
${railLink("files", "files", "Files", totalBadge(view.changeSet.data.files.length, "files"))}
|
|
@@ -4669,7 +5202,24 @@ function recommendationsBadge(count) {
|
|
|
4669
5202
|
};
|
|
4670
5203
|
}
|
|
4671
5204
|
/** Drawings are facts too, and the label is a sentence, so it has to be a sentence about one. */
|
|
5205
|
+
/** The map and the implementation diagrams together: everything behind the one rail item. */
|
|
5206
|
+
function diagramCount(view) {
|
|
5207
|
+
return (mapShown(view) ? 1 : 0) + (view.diagrams?.length ?? 0);
|
|
5208
|
+
}
|
|
5209
|
+
/** Which of the two tabs has something on it, for the check each tab carries. */
|
|
5210
|
+
function diagramsReady(view) {
|
|
5211
|
+
return {
|
|
5212
|
+
map: mapShown(view),
|
|
5213
|
+
diagram: (view.diagrams?.length ?? 0) > 0
|
|
5214
|
+
};
|
|
5215
|
+
}
|
|
5216
|
+
/** A map from a commit the review no longer describes is not shown, so it is neither counted nor ready. */
|
|
5217
|
+
function mapShown(view) {
|
|
5218
|
+
const drawnFrom = view.changeMap?.commitSha;
|
|
5219
|
+
return Boolean(view.changeMap) && !(drawnFrom && view.reviewedSha && drawnFrom !== view.reviewedSha);
|
|
5220
|
+
}
|
|
4672
5221
|
function diagramsBadge(count) {
|
|
5222
|
+
if (count === 0) return null;
|
|
4673
5223
|
return {
|
|
4674
5224
|
text: String(count),
|
|
4675
5225
|
label: count === 1 ? "1 diagram" : `${count} diagrams`
|
|
@@ -4745,8 +5295,8 @@ function renderBadge(badge) {
|
|
|
4745
5295
|
return `<span class="prb-rail-count${badge.done ? " prb-rail-count-done" : ""}" aria-label="${escapeHtml$1(badge.label)}">${escapeHtml$1(badge.text)}</span>`;
|
|
4746
5296
|
}
|
|
4747
5297
|
/** A null badge renders nothing, for the case where zero would be a claim rather than a total. */
|
|
4748
|
-
function railLink(view, icon, label, badge, note) {
|
|
4749
|
-
return `<button type="button" class="prb-rail-item prb-rail-link" data-view-nav="${view}"${note ? ` title="${escapeHtml$1(note)}"` : ""}>
|
|
5298
|
+
function railLink(view, icon, label, badge, note, alsoCurrentOn) {
|
|
5299
|
+
return `<button type="button" class="prb-rail-item prb-rail-link" data-view-nav="${view}"${alsoCurrentOn ? ` data-view-also="${alsoCurrentOn}"` : ""}${note ? ` title="${escapeHtml$1(note)}"` : ""}>
|
|
4750
5300
|
${railIcon(icon)}
|
|
4751
5301
|
<span class="prb-rail-label"><span class="prb-rail-title">${escapeHtml$1(label)}</span></span>
|
|
4752
5302
|
${renderBadge(badge)}
|
|
@@ -4991,8 +5541,9 @@ function renderAsk(view, sections, paused = false) {
|
|
|
4991
5541
|
<div class="prb-ask-header">
|
|
4992
5542
|
<h2>Ask Buddy AI</h2>
|
|
4993
5543
|
<span class="prb-ask-scope" data-ask-scope>whole change</span>
|
|
5544
|
+
${renderFocusToggle("buddy", "Buddy AI")}
|
|
4994
5545
|
</div>
|
|
4995
|
-
${paused ? "" : renderPromptBank(view.prompts ?? [])}
|
|
5546
|
+
${paused ? "" : renderPromptBank(view.prompts ?? [], view.promptsCollapsed === true)}
|
|
4996
5547
|
<div class="prb-ask-log" data-ask-log>
|
|
4997
5548
|
${transcript.length > 0 || pending ? transcript.map((exchange) => `<div class="prb-msg prb-msg-user">${escapeHtml$1(exchange.question)}</div>
|
|
4998
5549
|
<div class="prb-msg prb-msg-assistant prb-md">${renderMarkdown(exchange.answer)}${renderMessageCopySource(exchange.answer)}</div>`).join("") + pendingTurn : `<div class="prb-ask-empty"${paused ? " inert" : ""}>
|
|
@@ -5017,13 +5568,16 @@ function renderAsk(view, sections, paused = false) {
|
|
|
5017
5568
|
* The list is always present, even when empty: an empty bank with an Add button explains what
|
|
5018
5569
|
* the feature is, where a hidden one leaves it undiscoverable until someone reads a changelog.
|
|
5019
5570
|
*/
|
|
5020
|
-
function renderPromptBank(prompts) {
|
|
5021
|
-
return `<div class="prb-bank" data-prompt-bank data-prompts="${escapeHtml$1(JSON.stringify(prompts))}">
|
|
5571
|
+
function renderPromptBank(prompts, collapsed = false) {
|
|
5572
|
+
return `<div class="prb-bank" data-prompt-bank data-prompts="${escapeHtml$1(JSON.stringify(prompts))}"${collapsed ? " data-collapsed" : ""}>
|
|
5022
5573
|
<div class="prb-bank-head">
|
|
5023
|
-
<
|
|
5574
|
+
<button type="button" class="prb-bank-toggle" data-prompt-toggle aria-expanded="${!collapsed}" aria-controls="prb-bank-list">
|
|
5575
|
+
<svg class="prb-bank-chevron" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="10" height="10" fill="currentColor" aria-hidden="true"><path d="M6 4l4 4-4 4z"/></svg>
|
|
5576
|
+
<span>Your prompts</span><span class="prb-bank-count" data-prompt-count>${prompts.length}</span>
|
|
5577
|
+
</button>
|
|
5024
5578
|
<button type="button" class="prb-bank-add" data-prompt-add>Add</button>
|
|
5025
5579
|
</div>
|
|
5026
|
-
<div class="prb-bank-list" data-prompt-list>${prompts.map(renderPromptChip).join("")}</div>
|
|
5580
|
+
<div class="prb-bank-list" id="prb-bank-list" data-prompt-list${collapsed ? " hidden" : ""}>${prompts.map(renderPromptChip).join("")}</div>
|
|
5027
5581
|
</div>`;
|
|
5028
5582
|
}
|
|
5029
5583
|
function renderPromptChip(prompt) {
|
|
@@ -6255,7 +6809,7 @@ function renderCard(group, now, analysis, queued, moved, prState) {
|
|
|
6255
6809
|
${renderStatus(status)}
|
|
6256
6810
|
${renderPrState(prState)}
|
|
6257
6811
|
</div>
|
|
6258
|
-
${review.summary ? `<p class="prb-card-summary">${
|
|
6812
|
+
${review.summary ? `<p class="prb-card-summary">${renderInlineText(review.summary)}</p>` : ""}
|
|
6259
6813
|
<p class="prb-card-where">
|
|
6260
6814
|
<span class="prb-card-repo">${escapeHtml$1(lastSegment(review.repoPath))}</span>
|
|
6261
6815
|
${review.prNumber === void 0 ? "" : `<span class="prb-card-pr">#${review.prNumber}</span>`}
|
|
@@ -7282,7 +7836,7 @@ async function askAboutChange(workspace, question, sectionId, signal) {
|
|
|
7282
7836
|
try {
|
|
7283
7837
|
return extractAnswer(await withUsageRecorded(workspace.id, "ask", env, (onUsage) => agentFor(env).run({
|
|
7284
7838
|
capability: "read-code",
|
|
7285
|
-
prompt: buildPrompt$
|
|
7839
|
+
prompt: buildPrompt$2(workspace, question, sectionId, cwd),
|
|
7286
7840
|
cwd,
|
|
7287
7841
|
originRepoPath: workspace.repoPath,
|
|
7288
7842
|
env,
|
|
@@ -7341,7 +7895,7 @@ function extractAnswer(raw) {
|
|
|
7341
7895
|
* where X is in another theme, and a prompt that had quietly dropped X would answer confidently
|
|
7342
7896
|
* and wrongly.
|
|
7343
7897
|
*/
|
|
7344
|
-
function buildPrompt$
|
|
7898
|
+
function buildPrompt$2(workspace, question, sectionId, cwd) {
|
|
7345
7899
|
const section = workspace.session.result?.sections.find((s) => s.id === sectionId);
|
|
7346
7900
|
const recent = workspace.transcript.slice(-4);
|
|
7347
7901
|
return [
|
|
@@ -7393,6 +7947,7 @@ function beginRun(workspaceId, kind, detail, now = Date.now()) {
|
|
|
7393
7947
|
kind,
|
|
7394
7948
|
question: detail.question ?? null,
|
|
7395
7949
|
sectionId: detail.sectionId,
|
|
7950
|
+
...detail.drawing ? { drawing: detail.drawing } : {},
|
|
7396
7951
|
startedAt: now,
|
|
7397
7952
|
signal: controller.signal,
|
|
7398
7953
|
state: "running",
|
|
@@ -7408,6 +7963,7 @@ function reportRun(workspaceId, kind) {
|
|
|
7408
7963
|
return {
|
|
7409
7964
|
question: run.question,
|
|
7410
7965
|
sectionId: run.sectionId,
|
|
7966
|
+
...run.drawing ? { drawing: run.drawing } : {},
|
|
7411
7967
|
startedAt: run.startedAt
|
|
7412
7968
|
};
|
|
7413
7969
|
}
|
|
@@ -7506,7 +8062,7 @@ async function drawDiagram(workspace, sectionId, signal) {
|
|
|
7506
8062
|
try {
|
|
7507
8063
|
raw = await withUsageRecorded(workspace.id, "diagram", env, (onUsage) => agentFor(env).run({
|
|
7508
8064
|
capability: "read-code",
|
|
7509
|
-
prompt: buildPrompt(workspace, sectionId, cwd),
|
|
8065
|
+
prompt: buildPrompt$1(workspace, sectionId, cwd),
|
|
7510
8066
|
cwd,
|
|
7511
8067
|
originRepoPath: workspace.repoPath,
|
|
7512
8068
|
env,
|
|
@@ -7562,7 +8118,7 @@ function extractDiagram(raw) {
|
|
|
7562
8118
|
* show what it calls into, and a prompt that had quietly dropped the rest would draw a component
|
|
7563
8119
|
* with no edges leaving it and present that as the shape of the code.
|
|
7564
8120
|
*/
|
|
7565
|
-
function buildPrompt(workspace, sectionId, cwd) {
|
|
8121
|
+
function buildPrompt$1(workspace, sectionId, cwd) {
|
|
7566
8122
|
const section = workspace.session.result?.sections.find((s) => s.id === sectionId);
|
|
7567
8123
|
return [
|
|
7568
8124
|
SYSTEM_PROMPT,
|
|
@@ -7578,6 +8134,183 @@ function buildPrompt(workspace, sectionId, cwd) {
|
|
|
7578
8134
|
section ? `DRAW: the "${section.title}" theme of this change. Centre the diagram on it, and show what it calls into and what calls it where that is needed to make sense of it.` : "DRAW: this change as a whole, at the level a reviewer needs to understand what it does."
|
|
7579
8135
|
].join("\n");
|
|
7580
8136
|
}
|
|
8137
|
+
//#endregion
|
|
8138
|
+
//#region ../../packages/review-harness/src/domain/change_map/prompt.ts
|
|
8139
|
+
/**
|
|
8140
|
+
* What a change map is asked to be.
|
|
8141
|
+
*
|
|
8142
|
+
* Not the implementation diagram made smaller. That one asks "how does the code do this?" and is
|
|
8143
|
+
* right to name functions; this one asks "what is this change about, and why does each part matter
|
|
8144
|
+
* to the next?", and a function is a node here only when it is the handle a reader needs to find
|
|
8145
|
+
* the idea. The two prompts are separate because a single one asked to be both at once produces a
|
|
8146
|
+
* call graph with friendlier labels, which is the failure this feature exists to fix.
|
|
8147
|
+
*
|
|
8148
|
+
* The map is built from the review rather than beside it. The Review Path already decided what the
|
|
8149
|
+
* themes of this change are; a map that rediscovered them would sooner or later disagree with the
|
|
8150
|
+
* guide it sits next to, and the reviewer would be left to work out which one was wrong.
|
|
8151
|
+
*/
|
|
8152
|
+
var CHANGE_MAP_PROMPT = `You are Buddy AI, PR Review Buddy's review assistant. A developer is trying to understand a code change before and while reviewing it, and learns best visually. Build a CHANGE MAP: a small diagram of the ideas in this change and how they bear on one another.
|
|
8153
|
+
|
|
8154
|
+
This is not a call graph. It answers "what is this change about, what are the important ideas, how do they depend on or affect each other, and where is the risk?" — not "which function calls which". A reviewer who has never seen this code should be able to read the map left to right and come away with a correct mental model of the change.
|
|
8155
|
+
|
|
8156
|
+
Build it from the REVIEW GUIDE below. Its themes are already the important ideas of this change; reuse them, keep their meaning, and link a node to its theme with "sectionId" wherever the node is one of those themes. You have the working tree too: open files to check a relationship is real before you draw it, and never draw what a change of this kind usually looks like.
|
|
8157
|
+
|
|
8158
|
+
What goes on the map:
|
|
8159
|
+
- One "intent" node: what the change is trying to accomplish for the person using it.
|
|
8160
|
+
- The main ideas ("concept"): the behaviour introduced or changed.
|
|
8161
|
+
- Actors and boundaries ("actor") only where they matter to the story: the CLI, a server, the browser, the filesystem, a worker, an external service.
|
|
8162
|
+
- Shared state or data ("state") that more than one part reads or writes.
|
|
8163
|
+
- Rules the change must keep ("constraint"), and what goes wrong if it does not ("risk").
|
|
8164
|
+
- One "outcome" node: what the person using it gets, or would lose.
|
|
8165
|
+
Leave out anything a reader can assume. Do not make a node for every file or function. 12 nodes is a hard maximum, and most changes need 5 to 9.
|
|
8166
|
+
|
|
8167
|
+
Reading order. Every node has a "stage" from 0 to 4, and the map is read by stage, left to right:
|
|
8168
|
+
0 intent · 1 behaviour · 2 shared state and actors · 3 risks and constraints · 4 outcome.
|
|
8169
|
+
List nodes in the order the story reaches them.
|
|
8170
|
+
|
|
8171
|
+
Importance. Mark the ideas a reviewer most needs to understand, at most 3 of them, as "weight": "primary", and every other node "supporting". Say why an idea matters in its "why" — never give a score, a rating or a number.
|
|
8172
|
+
|
|
8173
|
+
Edges carry the meaning. Every edge has a short verb phrase "label" read from → to, such as: creates, reads from, writes to, depends on, requires, protects, can race with, happens before, removes from, invalidates, preserves, changes the behaviour of. An edge that only says "uses" or "calls" or has no label is not allowed. Add a one-sentence "note" when the verb alone would not explain the relationship. At most 16 edges. Draw the relationships that explain causality, not every connection that exists.
|
|
8174
|
+
|
|
8175
|
+
Two registers. Every title, body, why, label and note is an object with "simple" and "technical" versions of the SAME claim. The graph is identical in both; only the words differ.
|
|
8176
|
+
- simple: everyday words for someone who does not know this codebase. Where it genuinely helps, one real-world analogy may carry through the whole map (a shared whiteboard, a queue at a counter), but do not force an analogy onto every node.
|
|
8177
|
+
- technical: the precise terms a developer would use, including the mechanism.
|
|
8178
|
+
Titles are plain-English names for ideas ("Only one process updates the queue at a time"), never identifiers. Put the identifier or file a reader could search for in "anchor" instead ("withWriteLock()").
|
|
8179
|
+
|
|
8180
|
+
Keep text short, because a node is read at a glance beside the others: a title of at most 6 words, a body of ONE sentence of at most 20 words, a why of at most 15 words, a label of two to four words. Leave detail to the Review Path; the map is for the shape. Most maps need 6 to 12 edges: draw the spine of the story, and give no node more than 3 or 4 edges.
|
|
8181
|
+
|
|
8182
|
+
Reply with one JSON object and nothing else, in exactly this shape:
|
|
8183
|
+
|
|
8184
|
+
{
|
|
8185
|
+
"title": "<short noun phrase naming the change>",
|
|
8186
|
+
"intent": { "simple": "<one sentence>", "technical": "<one sentence>" },
|
|
8187
|
+
"nodes": [
|
|
8188
|
+
{
|
|
8189
|
+
"id": "<short-kebab-id>",
|
|
8190
|
+
"role": "intent | actor | concept | state | constraint | risk | outcome",
|
|
8191
|
+
"stage": 0,
|
|
8192
|
+
"weight": "primary | supporting",
|
|
8193
|
+
"title": { "simple": "...", "technical": "..." },
|
|
8194
|
+
"body": { "simple": "...", "technical": "..." },
|
|
8195
|
+
"why": { "simple": "...", "technical": "..." },
|
|
8196
|
+
"anchor": "<optional identifier or path>",
|
|
8197
|
+
"sectionId": "<optional theme id from the review guide>"
|
|
8198
|
+
}
|
|
8199
|
+
],
|
|
8200
|
+
"edges": [
|
|
8201
|
+
{
|
|
8202
|
+
"from": "<node id>",
|
|
8203
|
+
"to": "<node id>",
|
|
8204
|
+
"label": { "simple": "...", "technical": "..." },
|
|
8205
|
+
"note": { "simple": "...", "technical": "..." }
|
|
8206
|
+
}
|
|
8207
|
+
]
|
|
8208
|
+
}
|
|
8209
|
+
|
|
8210
|
+
"why", "anchor", "sectionId" and "note" are optional. Every other field is required.
|
|
8211
|
+
|
|
8212
|
+
For example, for a change that caches user profiles, two of the nodes and the edge between them might be:
|
|
8213
|
+
|
|
8214
|
+
{ "id": "profile-cache", "role": "state", "stage": 2, "weight": "primary",
|
|
8215
|
+
"title": { "simple": "A saved copy of each profile", "technical": "In-memory profile cache" },
|
|
8216
|
+
"body": { "simple": "Pages read the saved copy instead of asking the database every time.", "technical": "getProfile() reads through an LRU keyed by user id; misses fall back to the database." },
|
|
8217
|
+
"why": { "simple": "If the copy is out of date, people see old details.", "technical": "Every read path now depends on invalidation being correct." },
|
|
8218
|
+
"anchor": "ProfileCache" }
|
|
8219
|
+
{ "id": "profile-edit", "role": "concept", "stage": 1, "weight": "supporting",
|
|
8220
|
+
"title": { "simple": "Editing your profile", "technical": "Profile update endpoint" },
|
|
8221
|
+
"body": { "simple": "Saving changes writes the new details.", "technical": "PUT /profile writes the row and must evict the cache entry." } }
|
|
8222
|
+
{ "from": "profile-edit", "to": "profile-cache",
|
|
8223
|
+
"label": { "simple": "must refresh", "technical": "invalidates" },
|
|
8224
|
+
"note": { "simple": "Otherwise the saved copy keeps showing the old details.", "technical": "A missed eviction serves stale data until the entry expires." } }`;
|
|
8225
|
+
//#endregion
|
|
8226
|
+
//#region ../../packages/review-harness/src/workspace/change_map.ts
|
|
8227
|
+
/** A diagram's budget. The map reads the same change and writes less code-shaped output. */
|
|
8228
|
+
var CHANGE_MAP_TIMEOUT_MS = 18e4;
|
|
8229
|
+
/** A change map needs something to map. Refused before any agent is asked. */
|
|
8230
|
+
var ChangeMapUnavailableError = class extends Error {};
|
|
8231
|
+
/**
|
|
8232
|
+
* Map the change, from the review the reviewer is already reading.
|
|
8233
|
+
*
|
|
8234
|
+
* The same isolation a diagram gets: a checkout pinned to the reviewed commit, the review's own
|
|
8235
|
+
* model, and its usage recorded against the review. Callable on its own, with nothing about the
|
|
8236
|
+
* request in it, so the day maps are drawn without being asked for is a new caller, not a new
|
|
8237
|
+
* function.
|
|
8238
|
+
*
|
|
8239
|
+
* Failures come back as the diagram's own errors, because to the reviewer this is the same act —
|
|
8240
|
+
* asking for a picture — and the pane already knows how to say either one.
|
|
8241
|
+
*/
|
|
8242
|
+
async function drawChangeMap(workspace, signal) {
|
|
8243
|
+
const result = workspace.session.result;
|
|
8244
|
+
if (!result) throw new ChangeMapUnavailableError("This review has no analysis yet, so there is nothing to map.");
|
|
8245
|
+
const cwd = await askCheckout(workspace);
|
|
8246
|
+
const env = agentEnvOf(workspace);
|
|
8247
|
+
const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
|
|
8248
|
+
let raw;
|
|
8249
|
+
try {
|
|
8250
|
+
raw = await withUsageRecorded(workspace.id, "change-map", env, (onUsage) => agentFor(env).run({
|
|
8251
|
+
capability: "read-code",
|
|
8252
|
+
prompt: buildPrompt(workspace, cwd),
|
|
8253
|
+
cwd,
|
|
8254
|
+
originRepoPath: workspace.repoPath,
|
|
8255
|
+
env,
|
|
8256
|
+
...requestedModel ? { requestedModel } : {},
|
|
8257
|
+
timeoutMs: CHANGE_MAP_TIMEOUT_MS,
|
|
8258
|
+
signal,
|
|
8259
|
+
onUsage
|
|
8260
|
+
}), void 0, requestedModel);
|
|
8261
|
+
} catch (error) {
|
|
8262
|
+
if (error instanceof AgentUnavailableError) throw new DiagramUnavailableError(`${error.message} It is what this review was made with, so nothing can be drawn.`);
|
|
8263
|
+
throw error;
|
|
8264
|
+
}
|
|
8265
|
+
let map;
|
|
8266
|
+
try {
|
|
8267
|
+
({map} = validateChangeMap(raw, result.sections.map((section) => section.id)));
|
|
8268
|
+
} catch (error) {
|
|
8269
|
+
if (error instanceof ChangeMapRejected) throw new DiagramMalformedError(`Buddy AI did not return a change map that could be drawn. ${error.message}`);
|
|
8270
|
+
throw error;
|
|
8271
|
+
}
|
|
8272
|
+
return {
|
|
8273
|
+
...map,
|
|
8274
|
+
commitSha: reviewedCommit(workspace),
|
|
8275
|
+
createdAt: Date.now(),
|
|
8276
|
+
promptVersion: "2"
|
|
8277
|
+
};
|
|
8278
|
+
}
|
|
8279
|
+
/**
|
|
8280
|
+
* The review, the change, and the simple explanation the reviewer has already read, if any.
|
|
8281
|
+
*
|
|
8282
|
+
* That last one is what keeps Simple mode and "Explain in simple terms" one explanation rather than
|
|
8283
|
+
* two. A reviewer who has just read that the queue is like a shared whiteboard should find the same
|
|
8284
|
+
* whiteboard on the map, not a second analogy that makes them wonder which one is right.
|
|
8285
|
+
*/
|
|
8286
|
+
function buildPrompt(workspace, cwd) {
|
|
8287
|
+
const simple = simpleExplanation(workspace);
|
|
8288
|
+
return [
|
|
8289
|
+
CHANGE_MAP_PROMPT,
|
|
8290
|
+
"---",
|
|
8291
|
+
`CHANGE: ${workspace.changeSet.headRef} compared against ${workspace.changeSet.baseRef}, in the checkout at ${cwd}`,
|
|
8292
|
+
"---",
|
|
8293
|
+
"REVIEW GUIDE (JSON). Its \"sections\" are the Review Path themes; their \"id\" values are the only valid \"sectionId\" values:",
|
|
8294
|
+
JSON.stringify(workspace.session.result),
|
|
8295
|
+
...simple ? [
|
|
8296
|
+
"---",
|
|
8297
|
+
"SIMPLE EXPLANATION THE REVIEWER HAS ALREADY READ. Keep to its analogy and its framing in the \"simple\" register, so the two explanations agree:",
|
|
8298
|
+
simple
|
|
8299
|
+
] : [],
|
|
8300
|
+
"---",
|
|
8301
|
+
"DIFF:",
|
|
8302
|
+
workspace.diff
|
|
8303
|
+
].join("\n");
|
|
8304
|
+
}
|
|
8305
|
+
/** The latest whole-change "Explain in simple terms" answer, which is the one the map sits beside. */
|
|
8306
|
+
function simpleExplanation(workspace) {
|
|
8307
|
+
const turns = workspace.transcript ?? [];
|
|
8308
|
+
for (let i = turns.length - 1; i >= 0; i -= 1) {
|
|
8309
|
+
const turn = turns[i];
|
|
8310
|
+
if (turn.question === "Explain this review in plain English for someone unfamiliar with this codebase. Avoid jargon, use a simple everyday analogy if it helps, and keep it skippable — a couple of short paragraphs is enough." && !turn.sectionId && turn.answer.trim()) return turn.answer.trim();
|
|
8311
|
+
}
|
|
8312
|
+
return null;
|
|
8313
|
+
}
|
|
7581
8314
|
var FileOutsideRepositoryError = class extends Error {};
|
|
7582
8315
|
/**
|
|
7583
8316
|
* Resolve a path against the reviewed repository, refusing anything outside it.
|
|
@@ -7934,10 +8667,12 @@ function readPreferences() {
|
|
|
7934
8667
|
try {
|
|
7935
8668
|
const parsed = JSON.parse(readFileSync(PREFERENCES_PATH, "utf8"));
|
|
7936
8669
|
if (!parsed || typeof parsed !== "object") return {};
|
|
7937
|
-
const { askWidth, dismissedNotices } = parsed;
|
|
8670
|
+
const { askWidth, dismissedNotices, promptsCollapsed, changeMapRegister } = parsed;
|
|
7938
8671
|
return {
|
|
7939
8672
|
...typeof askWidth === "number" && Number.isFinite(askWidth) ? { askWidth: clampAskWidth(askWidth) } : {},
|
|
7940
|
-
...Array.isArray(dismissedNotices) ? { dismissedNotices: dismissedNotices.filter((key) => typeof key === "string" && NOTICE_KEY.test(key)) } : {}
|
|
8673
|
+
...Array.isArray(dismissedNotices) ? { dismissedNotices: dismissedNotices.filter((key) => typeof key === "string" && NOTICE_KEY.test(key)) } : {},
|
|
8674
|
+
...typeof promptsCollapsed === "boolean" ? { promptsCollapsed } : {},
|
|
8675
|
+
...changeMapRegister === "simple" || changeMapRegister === "technical" ? { changeMapRegister } : {}
|
|
7941
8676
|
};
|
|
7942
8677
|
} catch {
|
|
7943
8678
|
return {};
|
|
@@ -7954,6 +8689,26 @@ function writeAskWidth(width) {
|
|
|
7954
8689
|
writeFileSync(PREFERENCES_PATH, `${JSON.stringify(next, null, 2)}\n`);
|
|
7955
8690
|
} catch {}
|
|
7956
8691
|
}
|
|
8692
|
+
/** Remember whether "Your prompts" is folded. Read-modify-write, like every other field here. */
|
|
8693
|
+
function writePromptsCollapsed(collapsed) {
|
|
8694
|
+
try {
|
|
8695
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
8696
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
|
|
8697
|
+
...readPreferences(),
|
|
8698
|
+
promptsCollapsed: collapsed
|
|
8699
|
+
}, null, 2)}\n`);
|
|
8700
|
+
} catch {}
|
|
8701
|
+
}
|
|
8702
|
+
/** Remember which words the change map is read in. Read-modify-write, like every other field here. */
|
|
8703
|
+
function writeChangeMapRegister(register) {
|
|
8704
|
+
try {
|
|
8705
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
8706
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
|
|
8707
|
+
...readPreferences(),
|
|
8708
|
+
changeMapRegister: register
|
|
8709
|
+
}, null, 2)}\n`);
|
|
8710
|
+
} catch {}
|
|
8711
|
+
}
|
|
7957
8712
|
/** Remember that the reviewer has read a notice. Refuses anything not shaped like a key. */
|
|
7958
8713
|
function dismissNotice(key) {
|
|
7959
8714
|
if (!NOTICE_KEY.test(key)) return false;
|
|
@@ -7989,6 +8744,44 @@ function clampAskWidth(width) {
|
|
|
7989
8744
|
/** Long enough for a paragraph of standing instructions, short enough not to crowd the question. */
|
|
7990
8745
|
var MAX_PROMPT_CHARS = 2e3;
|
|
7991
8746
|
/**
|
|
8747
|
+
* What a reviewer starts with, before they have saved anything.
|
|
8748
|
+
*
|
|
8749
|
+
* Drawn from what reviewers were typing by hand most often. Ordinary saved prompts in every
|
|
8750
|
+
* respect: no flag marks them as defaults, so they edit, delete and reorder exactly as a prompt the
|
|
8751
|
+
* reviewer wrote does. Whoever reads a bank decides when these apply. In the workspace that is
|
|
8752
|
+
* only when no bank has ever been written, so a deleted default stays deleted.
|
|
8753
|
+
*
|
|
8754
|
+
* The ids are fixed rather than generated, so reading an unwritten bank twice gives the same bank.
|
|
8755
|
+
* Nothing depends on their spelling beyond that.
|
|
8756
|
+
*/
|
|
8757
|
+
var DEFAULT_PROMPTS = Object.freeze([
|
|
8758
|
+
{
|
|
8759
|
+
id: "default-assumptions",
|
|
8760
|
+
name: "What does it assume?",
|
|
8761
|
+
prompt: "What assumptions does this change make? Say which are stated, which are implied, and what happens if one turns out to be wrong."
|
|
8762
|
+
},
|
|
8763
|
+
{
|
|
8764
|
+
id: "default-explain-educational",
|
|
8765
|
+
name: "Teach me this",
|
|
8766
|
+
prompt: "Explain this in an educational way: the concepts it relies on, why it is built this way, and what I should take away from it."
|
|
8767
|
+
},
|
|
8768
|
+
{
|
|
8769
|
+
id: "default-what-could-go-wrong",
|
|
8770
|
+
name: "What could go wrong?",
|
|
8771
|
+
prompt: "What could go wrong here? Think about edge cases, failure modes and the existing callers this could break."
|
|
8772
|
+
},
|
|
8773
|
+
{
|
|
8774
|
+
id: "default-walkthrough",
|
|
8775
|
+
name: "Walk me through it",
|
|
8776
|
+
prompt: "Walk me through the implementation step by step, in the order the code runs."
|
|
8777
|
+
},
|
|
8778
|
+
{
|
|
8779
|
+
id: "default-verify-manually",
|
|
8780
|
+
name: "What should I verify?",
|
|
8781
|
+
prompt: "What should I verify manually before approving this, and how would I check each one?"
|
|
8782
|
+
}
|
|
8783
|
+
].map((prompt) => Object.freeze(prompt)));
|
|
8784
|
+
/**
|
|
7992
8785
|
* Everything reaching this function came from a client, so nothing here is trusted.
|
|
7993
8786
|
*
|
|
7994
8787
|
* Entries are repaired where the repair is unambiguous — trimming, truncating — and dropped where
|
|
@@ -8030,9 +8823,29 @@ function sanitisePromptBank(value) {
|
|
|
8030
8823
|
* a workspace that will not open.
|
|
8031
8824
|
*/
|
|
8032
8825
|
var PROMPTS_PATH = join(STORE_ROOT, "prompts.json");
|
|
8826
|
+
/**
|
|
8827
|
+
* The bank as stored or, when nothing has ever been stored, the defaults.
|
|
8828
|
+
*
|
|
8829
|
+
* Only a file that does not exist counts as a new bank. Once the file exists it is the reviewer's,
|
|
8830
|
+
* even when it is empty: an empty list is a reviewer who deleted every prompt, and handing the
|
|
8831
|
+
* defaults back to them would be undoing what they did. A corrupt file is not "new" either. It
|
|
8832
|
+
* reads as empty, as it always has, rather than being covered over with prompts the reviewer
|
|
8833
|
+
* never chose.
|
|
8834
|
+
*
|
|
8835
|
+
* The defaults are returned, not written. The first add, edit or delete posts the whole bank,
|
|
8836
|
+
* defaults included, and that write is what turns them into the reviewer's own. A reader that
|
|
8837
|
+
* wrote here would create the file on a page load, which the offline preview must not do.
|
|
8838
|
+
*/
|
|
8033
8839
|
function readPromptBank() {
|
|
8840
|
+
let raw;
|
|
8841
|
+
try {
|
|
8842
|
+
raw = readFileSync(PROMPTS_PATH, "utf8");
|
|
8843
|
+
} catch (error) {
|
|
8844
|
+
if (error.code === "ENOENT") return DEFAULT_PROMPTS.map((prompt) => ({ ...prompt }));
|
|
8845
|
+
return [];
|
|
8846
|
+
}
|
|
8034
8847
|
try {
|
|
8035
|
-
return sanitisePromptBank(JSON.parse(
|
|
8848
|
+
return sanitisePromptBank(JSON.parse(raw));
|
|
8036
8849
|
} catch {
|
|
8037
8850
|
return [];
|
|
8038
8851
|
}
|
|
@@ -8897,6 +9710,8 @@ async function sendWorkspacePage(res, workspace) {
|
|
|
8897
9710
|
repoPath: workspace.repoPath,
|
|
8898
9711
|
askWidth: readPreferences().askWidth,
|
|
8899
9712
|
dismissedNotices: readPreferences().dismissedNotices,
|
|
9713
|
+
promptsCollapsed: readPreferences().promptsCollapsed === true,
|
|
9714
|
+
changeMapRegister: readPreferences().changeMapRegister ?? "simple",
|
|
8900
9715
|
transcript: workspace.transcript,
|
|
8901
9716
|
pendingAsk: reportRun(workspace.id, "ask"),
|
|
8902
9717
|
pendingDiagram: reportRun(workspace.id, "diagram"),
|
|
@@ -8917,6 +9732,7 @@ async function sendWorkspacePage(res, workspace) {
|
|
|
8917
9732
|
lastPrRefresh: workspace.lastPrRefresh,
|
|
8918
9733
|
lastUpdate: notice,
|
|
8919
9734
|
diagrams: workspace.diagrams,
|
|
9735
|
+
changeMap: workspace.changeMap,
|
|
8920
9736
|
reviewedSha: reviewedCommit(workspace),
|
|
8921
9737
|
revision: workspaceRevision(workspace),
|
|
8922
9738
|
lineage: lineagePosition(workspace),
|
|
@@ -9340,10 +10156,15 @@ async function handleStop(req, res, workspace) {
|
|
|
9340
10156
|
*/
|
|
9341
10157
|
async function handleDiagram(req, res, workspace) {
|
|
9342
10158
|
const body = await readJson(req);
|
|
9343
|
-
const
|
|
10159
|
+
const drawing = body?.kind === "map" ? "map" : "implementation";
|
|
10160
|
+
const sectionId = drawing === "implementation" && typeof body?.sectionId === "string" ? body.sectionId : null;
|
|
9344
10161
|
const scope = sectionId ?? "change";
|
|
9345
|
-
const redraw = (workspace.diagrams ?? []).some((diagram) => diagram.scope === scope);
|
|
9346
|
-
|
|
10162
|
+
const redraw = drawing === "map" ? workspace.changeMap !== void 0 : (workspace.diagrams ?? []).some((diagram) => diagram.scope === scope);
|
|
10163
|
+
if (drawing === "map" && !workspace.session.result) return send(res, 409, { error: "This review has no analysis yet, so there is nothing to map." });
|
|
10164
|
+
const started = beginRun(workspace.id, "diagram", {
|
|
10165
|
+
sectionId,
|
|
10166
|
+
drawing
|
|
10167
|
+
});
|
|
9347
10168
|
if ("alreadyRunning" in started) return send(res, 409, {
|
|
9348
10169
|
error: "A diagram of this review is already being drawn.",
|
|
9349
10170
|
running: true
|
|
@@ -9352,6 +10173,7 @@ async function handleDiagram(req, res, workspace) {
|
|
|
9352
10173
|
record({
|
|
9353
10174
|
event: "diagram_requested",
|
|
9354
10175
|
reviewId: workspace.id,
|
|
10176
|
+
drawing,
|
|
9355
10177
|
scoped: sectionId !== null,
|
|
9356
10178
|
redraw
|
|
9357
10179
|
});
|
|
@@ -9359,18 +10181,21 @@ async function handleDiagram(req, res, workspace) {
|
|
|
9359
10181
|
const failed = () => record({
|
|
9360
10182
|
event: "diagram_drawn",
|
|
9361
10183
|
reviewId: workspace.id,
|
|
10184
|
+
drawing,
|
|
9362
10185
|
durationMs: Date.now() - startedAt,
|
|
9363
10186
|
ok: false,
|
|
9364
10187
|
...run.state === "stopped" ? { cancelled: true } : {}
|
|
9365
10188
|
});
|
|
9366
10189
|
try {
|
|
9367
10190
|
let diagram;
|
|
10191
|
+
let changeMap;
|
|
9368
10192
|
try {
|
|
9369
|
-
|
|
10193
|
+
if (drawing === "map") changeMap = await drawChangeMap(workspace, run.signal);
|
|
10194
|
+
else diagram = await drawDiagram(workspace, sectionId, run.signal);
|
|
9370
10195
|
} catch (error) {
|
|
9371
10196
|
failed();
|
|
9372
10197
|
if (run.state === "stopped") return sendIfOpen(res, 200, { stopped: true });
|
|
9373
|
-
if (error instanceof DiagramUnavailableError || error instanceof DiagramMalformedError) return sendIfOpen(res, 502, { error: error.message });
|
|
10198
|
+
if (error instanceof DiagramUnavailableError || error instanceof DiagramMalformedError || error instanceof ChangeMapUnavailableError) return sendIfOpen(res, 502, { error: error.message });
|
|
9374
10199
|
throw error;
|
|
9375
10200
|
}
|
|
9376
10201
|
if (!completeRun(run)) {
|
|
@@ -9380,9 +10205,21 @@ async function handleDiagram(req, res, workspace) {
|
|
|
9380
10205
|
record({
|
|
9381
10206
|
event: "diagram_drawn",
|
|
9382
10207
|
reviewId: workspace.id,
|
|
10208
|
+
drawing,
|
|
9383
10209
|
durationMs: Date.now() - startedAt,
|
|
9384
10210
|
ok: true
|
|
9385
10211
|
});
|
|
10212
|
+
if (changeMap) {
|
|
10213
|
+
let kept = false;
|
|
10214
|
+
mutateWorkspace(workspace.id, (fresh) => {
|
|
10215
|
+
if (changeMap.commitSha && reviewedCommit(fresh) && changeMap.commitSha !== reviewedCommit(fresh) || fresh.jobId !== workspace.jobId || !fresh.session.result) return;
|
|
10216
|
+
fresh.changeMap = changeMap;
|
|
10217
|
+
kept = true;
|
|
10218
|
+
});
|
|
10219
|
+
if (!kept) return sendIfOpen(res, 409, { error: "The review moved to a newer commit while this map was being drawn. Generate it again to map the review as it is now." });
|
|
10220
|
+
return sendIfOpen(res, 200, { changeMap });
|
|
10221
|
+
}
|
|
10222
|
+
if (!diagram) return sendIfOpen(res, 502, { error: "Buddy AI did not return a diagram." });
|
|
9386
10223
|
mutateWorkspace(workspace.id, (fresh) => {
|
|
9387
10224
|
const diagrams = [...fresh.diagrams ?? []];
|
|
9388
10225
|
const existing = diagrams.findIndex((one) => one.scope === scope);
|
|
@@ -9481,6 +10318,8 @@ async function handlePrefs(req, res) {
|
|
|
9481
10318
|
const askWidth = body?.askWidth;
|
|
9482
10319
|
if (typeof askWidth === "number") writeAskWidth(askWidth);
|
|
9483
10320
|
else if (askWidth === null) clearAskWidth();
|
|
10321
|
+
if (typeof body?.promptsCollapsed === "boolean") writePromptsCollapsed(body.promptsCollapsed);
|
|
10322
|
+
if (body?.changeMapRegister === "simple" || body?.changeMapRegister === "technical") writeChangeMapRegister(body.changeMapRegister);
|
|
9484
10323
|
if (typeof body?.dismissNotice === "string" && !dismissNotice(body.dismissNotice)) return send(res, 400, { error: "Not a notice this page can dismiss." });
|
|
9485
10324
|
send(res, 200, { ok: true });
|
|
9486
10325
|
}
|
|
@@ -9542,7 +10381,8 @@ var ASK_SOURCES = [
|
|
|
9542
10381
|
"suggestion",
|
|
9543
10382
|
"free_text",
|
|
9544
10383
|
"explain_simply",
|
|
9545
|
-
"selection"
|
|
10384
|
+
"selection",
|
|
10385
|
+
"finding"
|
|
9546
10386
|
];
|
|
9547
10387
|
function askSource(value) {
|
|
9548
10388
|
return ASK_SOURCES.includes(value) ? value : "free_text";
|
|
@@ -9577,6 +10417,7 @@ var VIEWS = [
|
|
|
9577
10417
|
"files",
|
|
9578
10418
|
"prcontext",
|
|
9579
10419
|
"diagram",
|
|
10420
|
+
"map",
|
|
9580
10421
|
"kiss",
|
|
9581
10422
|
"kissrecs"
|
|
9582
10423
|
];
|