prreviewbuddy 0.45.0 → 0.48.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/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $t as recentWorkspaces, A as createPrState, An as STORE_ROOT, At as fail, B as toneOf, C as readEvents, Cn as git, Ct as fillFileUrlTemplate, D as announceJob, Dn as readAgentPreference, Dt as reviewedShaOf, E as telemetryUploadConsent, En as clearAgentPreference, Et as followedBranch, F as markAllRead, Ft as isLiveJob, G as MANAGED_ROOT, Gt as latestKissRun, H as withUsageRecorded, Ht as followedRefName, I as markRead, In as AgentCancelledError, It as phasesFor, Jt as loadWorkspace, K as readMarker, L as pruneNotifications, Ln as AgentUnavailableError, Lt as progressSteps, M as eventName, Mt as loadJob, N as headline, Nt as saveJob, O as branchOf, On as writeAgentPreference, Ot as allJobs, P as listNotifications, Pn as agentFor, Q as readIndexToken, Qt as previousKissResult, R as readNotification, Rt as purposeOf, Sn as isCommitReview, St as remoteHost, T as recordTelemetryUploadConsent, Tn as resolveModel, Tt as checkReview, U as EXPLAIN_SIMPLY_PROMPT, V as unreadCount$1, Vt as isClaimed, W as ReviewBeingDeletedError, Wt as indexReviewGroups, X as compactQueue, Xt as mutateWorkspace, Y as relativeTime$1, _ as agentEnvOf, _n as reviewerDispositions, _t as statedWorkspacePort, a as wasBlocked, an as workspaceStamps, at as spawnServer, c as RefreshUnavailableError, cn as queueAddMany, ct as writeServerRecord, d as refreshPrContext, dn as readQueue, dt as feedbackUrl, en as reviewedCommit, et as clearServerRecord, f as isUnchanged, fn as doneVerb, ft as DEFAULT_WORKSPACE_PORT, gn as questionsOutstanding, gt as resolveWorkspacePort, h as liveUpdateFor, hn as issuesOutstanding, ht as parseWorkspacePort, i as liveJobsFor, in as workspaceRevision, it as serverEntryExists, j as createPrUpdate, jn as AGENT_IDS, jt as isTerminal, k as subjectOf, kt as endedAt, l as mergeUpdateInto, ln as queueRemove, lt as BUILD_VERSION, m as UpdateAlreadyRunningError, mn as isQuestionOutstanding, mt as MIN_WORKSPACE_PORT, n as deleteReview, nt as readServerRecord, o as discardJob, on as dequeueOnComplete, p as askCheckout, pn as isIssueOutstanding, pt as MAX_WORKSPACE_PORT, q as removeWorktree, qt as lineagePosition, r as lineageIds, rn as touchWorkspace, s as FETCH_TIMEOUT_MS, sn as queueAdd, t as deleteLineage, u as updateReview, un as queueStamp, vn as processDiscussion, vt as writePortPreference, w as record, wt as checkCodeFreshness, x as runJob, xt as forgeFor, y as startKissJob, yn as describeAuthorship, yt as claimRequestUrl, z as removeNotification } from "./delete_review-BWMmO81Q.js";
1
+ import { $t as mutateWorkspace, A as createPrState, An as describeAuthorship, At as allJobs, B as toneOf, Bt as purposeOf, C as readEvents, Cn as doneVerb, Ct as forgeFor, D as announceJob, Dn as questionsOutstanding, Dt as checkReview, E as telemetryUploadConsent, En as issuesOutstanding, Et as checkCodeFreshness, F as markAllRead, Ft as saveJob, G as extractJsonText, Gn as agentFor, Gt as groupByLineage, H as withUsageRecorded, Hn as AGENT_IDS, I as markRead, In as resolveModel, J as readMarker, Jn as AgentUnavailableError, Jt as latestKissRun, K as ReviewBeingDeletedError, Kt as indexKeptIds, L as pruneNotifications, Ln as clearAgentPreference, Lt as isLiveJob, M as eventName, Mt as fail, N as headline, Nn as isCommitReview, Nt as isTerminal, O as branchOf, On as reviewerDispositions, Ot as followedBranch, P as listNotifications, Pn as git, Pt as loadJob, Q as compactQueue, R as readNotification, Rn as readAgentPreference, Rt as phasesFor, Sn as readQueue, T as recordTelemetryUploadConsent, Tn as isQuestionOutstanding, Tt as fillFileUrlTemplate, U as EXPLAIN_SIMPLY_PROMPT, Ut as isClaimed, V as unreadCount$1, Vn as STORE_ROOT, W as asString, Wt as followedRefName, Xt as lineagePosition, Y as removeWorktree, Z as relativeTime$1, Zt as loadWorkspace, _ as agentEnvOf, _n as dequeueOnComplete, _t as parseWorkspacePort, a as wasBlocked, bn as queueRemove, bt as writePortPreference, c as RefreshUnavailableError, cn as workspaceStamps, d as refreshPrContext, dn as isLifecycle, dt as BUILD_VERSION, et as readIndexToken, f as isUnchanged, fn as lifecycleAction, gn as sectionOf, gt as MIN_WORKSPACE_PORT, h as liveUpdateFor, hn as parseLifecycle, ht as MAX_WORKSPACE_PORT, i as liveJobsFor, it as readServerRecord, j as createPrUpdate, jt as endedAt, k as subjectOf, kn as processDiscussion, kt as reviewedShaOf, l as mergeUpdateInto, ln as LIFECYCLES, m as UpdateAlreadyRunningError, mn as offersLifecycle, mt as DEFAULT_WORKSPACE_PORT, n as deleteReview, nn as recentWorkspaces, nt as clearServerRecord, o as discardJob, on as touchWorkspace, ot as serverEntryExists, p as askCheckout, pn as lifecycleChip, pt as feedbackUrl, q as MANAGED_ROOT, qn as AgentCancelledError, qt as indexReviewGroups, r as lineageIds, rn as reviewedCommit, s as FETCH_TIMEOUT_MS, sn as workspaceRevision, st as spawnServer, t as deleteLineage, tn as previousKissResult, u as updateReview, un as activityLabel, ut as writeServerRecord, vn as queueAdd, vt as resolveWorkspacePort, w as record, wn as isIssueOutstanding, wt as remoteHost, x as runJob, xn as queueStamp, xt as claimRequestUrl, y as startKissJob, yn as queueAddMany, yt as statedWorkspacePort, z as removeNotification, zn as writeAgentPreference, zt as progressSteps } from "./delete_review-D52wkt66.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";
@@ -1657,7 +1657,7 @@ function renderProvisionalReason(session) {
1657
1657
  function renderOverviewActions(session, options) {
1658
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>`;
1659
1659
  if (!options?.diagrams) return explain;
1660
- return `<div class="pr-helper-overview-actions">${explain}<button type="button" class="pr-helper-diagram-button" data-diagram-request="">Show implementation diagram</button></div>`;
1660
+ return `<div class="pr-helper-overview-actions">${explain}<button type="button" class="pr-helper-map-button" data-view-nav="map">Diagrams</button></div>`;
1661
1661
  }
1662
1662
  function renderOverviewTab(session, iconUrl = "", options) {
1663
1663
  if (!session) return renderNoReviewState(iconUrl);
@@ -1901,7 +1901,7 @@ function renderGuideSectionCard(section, counts, activeGuidedSectionId, status,
1901
1901
  <button type="button" class="pr-helper-guide-start-cta" data-guide-start="${escapeHtml$1(section.id)}">
1902
1902
  ${isActive ? "Resume guided review →" : isCompleted ? "Review again →" : "Start guided review →"}
1903
1903
  </button>
1904
- ${options?.diagrams ? `<button type="button" class="pr-helper-diagram-button" data-diagram-request="${escapeHtml$1(section.id)}">Diagram this theme</button>` : ""}
1904
+ ${options?.diagrams ? `<button type="button" class="pr-helper-diagram-button" data-diagram-request="${escapeHtml$1(section.id)}">Generate theme diagram</button>` : ""}
1905
1905
  ${undo}
1906
1906
  </div>
1907
1907
  </div>
@@ -2609,6 +2609,458 @@ function renderDrawnFrom(diagram, reviewedSha) {
2609
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>`;
2610
2610
  }
2611
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
2612
3064
  //#region ../../packages/review-harness/src/workspace/file_links.ts
2613
3065
  /**
2614
3066
  * Where a file lives on the pull request, when there is one.
@@ -2857,7 +3309,7 @@ function renderPrContextView(view, attribution, refresh, conversation) {
2857
3309
  const { pullRequest, discussion } = view.prContext;
2858
3310
  const { processed } = view;
2859
3311
  const buckets = BUCKETS.map((bucket) => renderBucket(bucket.title, bucket.note, processed[bucket.key])).join("");
2860
- return `<h3 class="prb-doc-title">PR context</h3>
3312
+ return `${renderDocHead("PR context", renderFocusToggle("prcontext", "PR context"))}
2861
3313
  ${renderProvenance$1(pullRequest, discussion.fetchedAt, processed)}
2862
3314
  ${renderRefresh(refresh)}
2863
3315
  ${renderAttribution(attribution, processed)}
@@ -2925,7 +3377,7 @@ function renderAttribution(attribution, processed) {
2925
3377
  */
2926
3378
  function renderNothingConnected(conversation) {
2927
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) : "";
2928
- return `<h3 class="prb-doc-title">PR context</h3>
3380
+ return `${renderDocHead("PR context", renderFocusToggle("prcontext", "PR context"))}
2929
3381
  <p class="prb-pr-empty">
2930
3382
  No pull request discussion was brought into this review, so none of the findings have been
2931
3383
  checked against what other reviewers have already said.
@@ -3058,6 +3510,9 @@ function verdictLabel(verdict) {
3058
3510
  * difference between "the analysis had nothing to say" and "the analysis did not run" should be
3059
3511
  * available to a test as a fact rather than as a substring of prose.
3060
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"));
3061
3516
  /**
3062
3517
  * What each payoff level is called where a reader can see it.
3063
3518
  *
@@ -3128,7 +3583,7 @@ function renderKissSummary(view) {
3128
3583
  if (!run) return renderKissEmpty(view.canRun);
3129
3584
  if (run.status === "generating") return renderRunning(run, view.job ?? null);
3130
3585
  if (run.status === "failed" || !run.result) return renderFailed(run, view);
3131
- return `${renderDocHead("KISS review", renderAnalysisDetails(provenanceFacts(run)))}
3586
+ return `${renderDocHead("KISS review", `${renderAnalysisDetails(provenanceFacts(run))}${KISS_TOGGLE}`)}
3132
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, {
3133
3588
  dismissedSha: view.dismissedFreshnessSha,
3134
3589
  actions: view.canRun
@@ -3177,14 +3632,14 @@ function renderKissFreshness(reviewedSha, freshness, options = {}) {
3177
3632
  function renderKissRecommendations(view) {
3178
3633
  const presented = presentedKissRun(view);
3179
3634
  const result = presented?.result;
3180
- if (!result) return `<h3 class="prb-doc-title">Recommendations</h3>${renderNoRunYet(view)}`;
3635
+ if (!result) return `${RECS_HEAD}${renderNoRunYet(view)}`;
3181
3636
  const earlier = earlierNote(view, presented);
3182
- if (result.recommendations.length === 0) return `<h3 class="prb-doc-title">Recommendations</h3>${earlier}
3637
+ if (result.recommendations.length === 0) return `${RECS_HEAD}${earlier}
3183
3638
  <div class="prb-kiss-outcome" data-review-outcome="empty">
3184
3639
  <h4 class="prb-kiss-outcome-title">Nothing to simplify</h4>
3185
3640
  <p class="prb-kiss-outcome-body">The analysis completed and did not identify a materially simpler way to meet these requirements.</p>
3186
3641
  </div>`;
3187
- return `<h3 class="prb-doc-title">Recommendations</h3>${earlier}
3642
+ return `${RECS_HEAD}${earlier}
3188
3643
  ${result.recommendations.map((recommendation) => renderRecommendation(recommendation)).join("")}`;
3189
3644
  }
3190
3645
  /**
@@ -3212,7 +3667,7 @@ function earlierNote(view, presented) {
3212
3667
  * else's code for the person who wrote it.
3213
3668
  */
3214
3669
  function renderKissEmpty(canRun) {
3215
- return `${renderDocHead("KISS review")}
3670
+ return `${renderDocHead("KISS review", KISS_TOGGLE)}
3216
3671
  <p class="prb-kiss-lede">${escapeHtml$1(KISS_DESCRIPTION)}</p>
3217
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>
3218
3673
  ${renderRunControl(canRun, "Run KISS review")}`;
@@ -3264,7 +3719,7 @@ function renderRunning(run, job) {
3264
3719
  const phase = job?.phase ?? "analysing";
3265
3720
  const copy = phaseSentence(job) || "Running KISS review. This takes about as long as a review.";
3266
3721
  const progress = job?.progress ? ` <span class="prb-job-progress">${escapeHtml$1(job.progress)}</span>` : "";
3267
- return `${renderDocHead("KISS review")}
3722
+ return `${renderDocHead("KISS review", KISS_TOGGLE)}
3268
3723
  <div class="prb-kiss-status" data-kiss-phase="${escapeHtml$1(phase)}" role="status" aria-live="polite">
3269
3724
  <p><span class="prb-spinner" aria-hidden="true"></span> ${escapeHtml$1(copy)}${progress}</p>
3270
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>
@@ -3278,7 +3733,7 @@ function renderRunning(run, job) {
3278
3733
  */
3279
3734
  function renderFailed(run, view) {
3280
3735
  const standing = view.previous ?? null;
3281
- return `${renderDocHead("KISS review")}
3736
+ return `${renderDocHead("KISS review", KISS_TOGGLE)}
3282
3737
  <div class="prb-kiss-outcome" data-review-outcome="failed">
3283
3738
  <h4 class="prb-kiss-outcome-title">KISS review could not be completed</h4>
3284
3739
  <p class="prb-kiss-outcome-body">${escapeHtml$1(run.error ?? "The analysis did not finish.")}</p>
@@ -4048,11 +4503,12 @@ function renderAvailable(job, fileCount) {
4048
4503
  * with the broken phase marked, so the answer is which step failed rather than merely that
4049
4504
  * something did.
4050
4505
  */
4506
+ var PROGRESS_TOGGLE = renderFocusToggle("progress", "Review progress");
4051
4507
  function renderProgressView(job, fileCount) {
4052
- if (job.phase === "failed") return `<h3 class="prb-doc-title">Review stopped</h3>
4508
+ if (job.phase === "failed") return `${renderDocHead("Review stopped", PROGRESS_TOGGLE)}
4053
4509
  ${renderSteps(job)}
4054
4510
  <div class="prb-progress-failure">${renderFailureBody(job, { lead: false })}</div>`;
4055
- return `<h3 class="prb-doc-title">Review in progress</h3>
4511
+ return `${renderDocHead("Review in progress", PROGRESS_TOGGLE)}
4056
4512
  <p class="prb-progress-lead">Building the context needed to review this change.</p>
4057
4513
  ${renderSteps(job)}
4058
4514
  ${renderAvailable(job, fileCount)}
@@ -4094,6 +4550,46 @@ function renderPrState(state, besideNumber = false) {
4094
4550
  return `<span class="prb-card-pr-state" data-pr-state="${state}">${state === "merged" ? "Merged" : besideNumber ? "Closed" : "PR closed"}</span>`;
4095
4551
  }
4096
4552
  //#endregion
4553
+ //#region ../../packages/review-harness/src/workspace/theme.ts
4554
+ /**
4555
+ * Which palette the workspace's furniture is drawn in: the reviewer's device, or a fixed choice.
4556
+ *
4557
+ * One axis with a registry rather than a flag per value, so the stored type, the settings page's
4558
+ * radios and the attribute on `<html>` all derive from the same list and cannot disagree about
4559
+ * what the choices are.
4560
+ *
4561
+ * Pure on purpose: the settings page's script imports this, and the client bundle must not reach
4562
+ * anything that pulls in a node builtin.
4563
+ *
4564
+ * The attribute is the whole mechanism. shell.css sets `color-scheme` from it, and every shell
4565
+ * token is a `light-dark()` pair that the browser resolves from `color-scheme`, so System follows
4566
+ * the device live, with no listener and no reload.
4567
+ */
4568
+ var THEMES = [
4569
+ {
4570
+ id: "system",
4571
+ label: "System",
4572
+ help: "Follow your device setting"
4573
+ },
4574
+ {
4575
+ id: "light",
4576
+ label: "Light",
4577
+ help: "Always use light mode"
4578
+ },
4579
+ {
4580
+ id: "dark",
4581
+ label: "Dark",
4582
+ help: "Always use dark mode"
4583
+ }
4584
+ ];
4585
+ function isTheme(value) {
4586
+ return THEMES.some((theme) => theme.id === value);
4587
+ }
4588
+ /** The `<html>` attribute, leading space included, so a root reads `<html lang="en"${…}>`. */
4589
+ function themeAttribute(theme) {
4590
+ return ` data-theme="${theme ?? "system"}"`;
4591
+ }
4592
+ //#endregion
4097
4593
  //#region ../../packages/review-harness/src/workspace/page.ts
4098
4594
  function renderWorkspace(view) {
4099
4595
  const { session, changeSet } = view;
@@ -4113,7 +4609,7 @@ function renderWorkspace(view) {
4113
4609
  */
4114
4610
  const freshness = result ? view.freshness : void 0;
4115
4611
  return `<!doctype html>
4116
- <html lang="en">
4612
+ <html lang="en"${themeAttribute(view.theme)}>
4117
4613
  <head>
4118
4614
  <meta charset="utf-8">
4119
4615
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -4285,17 +4781,42 @@ function renderFilesView(view, sections) {
4285
4781
  ${renderFiles(view, sections)}
4286
4782
  </section>`;
4287
4783
  }
4784
+ /**
4785
+ * What the implementation tab says before anything has been drawn on it.
4786
+ *
4787
+ * Hidden by the stylesheet the moment a card is in the pane, so the client can add the first
4788
+ * drawing without having to remember to take this away.
4789
+ */
4790
+ function renderImplementationEmpty() {
4791
+ return `<div class="prb-map-empty prb-diagram-empty" data-diagram-empty>
4792
+ <h4 class="prb-map-empty-title">Trace how the code does it</h4>
4793
+ <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>
4794
+ <button type="button" class="prb-map-generate" data-diagram-request="">Generate implementation diagram</button>
4795
+ </div>`;
4796
+ }
4288
4797
  /** The standard review's panes, drawn only where there is a standard result to draw them from. */
4289
4798
  function renderStandardViews(view, sections) {
4290
4799
  const { session } = view;
4291
4800
  const result = session.result;
4292
- return `<section class="prb-view" data-view="overview">${renderDocHead("Review overview", renderAnalysisDetails(analysisDetailItems(session)))}${renderOverviewTab(session, "", {
4801
+ return `<section class="prb-view" data-view="overview">${renderDocHead("Review overview", `${renderAnalysisDetails(analysisDetailItems(session))}${renderFocusToggle("overview", "Overview")}`)}${renderOverviewTab(session, "", {
4293
4802
  diagrams: true,
4294
4803
  detailsInHeader: true
4295
4804
  })}</section>
4296
4805
  <section class="prb-view" data-view="guide" hidden>${renderWorkspaceGuide(session)}</section>
4297
- <section class="prb-view" data-view="diagram" hidden data-diagram-pane${view.pendingDiagram ? ` data-diagram-running="${escapeHtml$1(view.pendingDiagram.sectionId ?? "")}"` : ""}>
4298
- <h3 class="prb-doc-title">Diagrams</h3>
4806
+ <section class="prb-view" data-view="map" hidden data-map-pane data-map-register="${view.changeMapRegister ?? "simple"}"${view.pendingDiagram?.drawing === "map" ? " data-map-running" : ""}>
4807
+ ${renderDocHead("Diagrams", renderFocusToggle("map", "Change map"))}
4808
+ ${renderDiagramModes("map", diagramsReady(view))}
4809
+ <div class="prb-map-body" data-map-body>${renderChangeMapBody({
4810
+ stored: view.changeMap,
4811
+ sections,
4812
+ reviewedSha: view.reviewedSha ?? null,
4813
+ register: view.changeMapRegister ?? "simple"
4814
+ })}</div>
4815
+ </section>
4816
+ <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 ?? "")}"` : ""}>
4817
+ ${renderDocHead("Diagrams", renderFocusToggle("diagram", "Implementation diagrams"))}
4818
+ ${renderDiagramModes("diagram", diagramsReady(view))}
4819
+ ${renderImplementationEmpty()}
4299
4820
  ${renderDiagrams(view.diagrams ?? [], sections, view.reviewedSha ?? null)}
4300
4821
  </section>
4301
4822
  <section class="prb-view" data-view="issues" hidden>
@@ -4613,7 +5134,7 @@ function renderRail(view, sections, stale = false) {
4613
5134
  <div class="prb-rail-path" id="prb-rail-path">
4614
5135
  ${themes || "<p class=\"prb-rail-heading\">No themes</p>"}
4615
5136
  </div>
4616
- ${(view.diagrams?.length ?? 0) > 0 ? railLink("diagram", "diagrams", "Diagrams", diagramsBadge(view.diagrams?.length ?? 0)) : ""}
5137
+ ${railLink("map", "diagrams", "Diagrams", diagramsBadge(diagramCount(view)), void 0, "diagram")}
4617
5138
  ${railLink("issues", "issues", "Issues", findingsBadge(issuesOutstanding(issues, dispositions)))}
4618
5139
  ${railLink("questions", "questions", "Questions", remainingBadge(questionsOutstanding(questions, answered), "question", "answered"))}
4619
5140
  ${railLink("files", "files", "Files", totalBadge(view.changeSet.data.files.length, "files"))}
@@ -4721,7 +5242,24 @@ function recommendationsBadge(count) {
4721
5242
  };
4722
5243
  }
4723
5244
  /** Drawings are facts too, and the label is a sentence, so it has to be a sentence about one. */
5245
+ /** The map and the implementation diagrams together: everything behind the one rail item. */
5246
+ function diagramCount(view) {
5247
+ return (mapShown(view) ? 1 : 0) + (view.diagrams?.length ?? 0);
5248
+ }
5249
+ /** Which of the two tabs has something on it, for the check each tab carries. */
5250
+ function diagramsReady(view) {
5251
+ return {
5252
+ map: mapShown(view),
5253
+ diagram: (view.diagrams?.length ?? 0) > 0
5254
+ };
5255
+ }
5256
+ /** A map from a commit the review no longer describes is not shown, so it is neither counted nor ready. */
5257
+ function mapShown(view) {
5258
+ const drawnFrom = view.changeMap?.commitSha;
5259
+ return Boolean(view.changeMap) && !(drawnFrom && view.reviewedSha && drawnFrom !== view.reviewedSha);
5260
+ }
4724
5261
  function diagramsBadge(count) {
5262
+ if (count === 0) return null;
4725
5263
  return {
4726
5264
  text: String(count),
4727
5265
  label: count === 1 ? "1 diagram" : `${count} diagrams`
@@ -4797,8 +5335,8 @@ function renderBadge(badge) {
4797
5335
  return `<span class="prb-rail-count${badge.done ? " prb-rail-count-done" : ""}" aria-label="${escapeHtml$1(badge.label)}">${escapeHtml$1(badge.text)}</span>`;
4798
5336
  }
4799
5337
  /** A null badge renders nothing, for the case where zero would be a claim rather than a total. */
4800
- function railLink(view, icon, label, badge, note) {
4801
- return `<button type="button" class="prb-rail-item prb-rail-link" data-view-nav="${view}"${note ? ` title="${escapeHtml$1(note)}"` : ""}>
5338
+ function railLink(view, icon, label, badge, note, alsoCurrentOn) {
5339
+ 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)}"` : ""}>
4802
5340
  ${railIcon(icon)}
4803
5341
  <span class="prb-rail-label"><span class="prb-rail-title">${escapeHtml$1(label)}</span></span>
4804
5342
  ${renderBadge(badge)}
@@ -5220,7 +5758,8 @@ function normalise$1(entries) {
5220
5758
  if (typeof raw.observedAt !== "number") continue;
5221
5759
  states[key] = {
5222
5760
  state: raw.state,
5223
- observedAt: raw.observedAt
5761
+ observedAt: raw.observedAt,
5762
+ ...typeof raw.changedAt === "number" ? { changedAt: raw.changedAt } : {}
5224
5763
  };
5225
5764
  }
5226
5765
  return states;
@@ -5336,6 +5875,7 @@ function normalise(entries) {
5336
5875
  if (typeof raw.polledAt === "number") entry.polledAt = raw.polledAt;
5337
5876
  if (typeof raw.observedSha === "string") entry.observedSha = raw.observedSha;
5338
5877
  if (typeof raw.observedAt === "number") entry.observedAt = raw.observedAt;
5878
+ if (typeof raw.changedAt === "number") entry.changedAt = raw.changedAt;
5339
5879
  if (typeof raw.notifiedSha === "string") entry.notifiedSha = raw.notifiedSha;
5340
5880
  if (typeof raw.notificationId === "string") entry.notificationId = raw.notificationId;
5341
5881
  if (typeof raw.failures === "number") entry.failures = raw.failures;
@@ -5674,10 +6214,12 @@ async function watchCycle(deps = defaultDeps(), options = {}) {
5674
6214
  }
5675
6215
  if (observed.kind === "unresolved") continue;
5676
6216
  if (observed.prState !== void 0 && !isBranchCandidate(candidate)) recordPrState(candidate, key, observed.sha, observed.prState, prStates, now);
6217
+ const lastSeen = state[key]?.observedSha;
5677
6218
  state[key] = {
5678
6219
  ...state[key],
5679
6220
  observedSha: observed.sha,
5680
6221
  observedAt: seenAt,
6222
+ ...lastSeen === void 0 || observed.sha === lastSeen ? {} : { changedAt: seenAt },
5681
6223
  failures: 0
5682
6224
  };
5683
6225
  if (observed.sha === candidate.analysedSha) {
@@ -5722,7 +6264,8 @@ function prunedPrStates(prStates, deps) {
5722
6264
  */
5723
6265
  function recordPrState(candidate, key, headSha, observed, prStates, now) {
5724
6266
  if (observed === null) return;
5725
- if (prStates[key]?.state === "open" && observed !== "open") {
6267
+ const previous = prStates[key]?.state;
6268
+ if (previous === "open" && observed !== "open") {
5726
6269
  const id = `pr-${observed}-${candidate.reviewId}-${headSha}`;
5727
6270
  if (createPrState({
5728
6271
  id,
@@ -5734,9 +6277,11 @@ function recordPrState(candidate, key, headSha, observed, prStates, now) {
5734
6277
  headSha
5735
6278
  }, now) === null && readNotification(id) === null) return;
5736
6279
  }
6280
+ const changedAt = previous !== void 0 && previous !== observed ? now : prStates[key]?.changedAt;
5737
6281
  prStates[key] = {
5738
6282
  state: observed,
5739
- observedAt: now
6283
+ observedAt: now,
6284
+ ...changedAt === void 0 ? {} : { changedAt }
5740
6285
  };
5741
6286
  }
5742
6287
  async function observeRequest(candidate, deps, unreachable) {
@@ -5902,6 +6447,12 @@ async function announce(candidate, key, observed, state, deps, now) {
5902
6447
  * and a review with no `completedAt` is one they have not finished. Anything outside those is a
5903
6448
  * review they are done with, and polling it would spend a request on an answer nobody wants.
5904
6449
  *
6450
+ * **Waiting and Done are watched whatever else is true of them.** Parking a review takes it out of
6451
+ * the queue and is usually done after marking it reviewed, so without this a Waiting review would
6452
+ * fall outside all three sets at the moment it starts waiting for a push, and the push that should
6453
+ * bring it back to Active would never be seen. Done is watched for "Changed since done": the reviewer
6454
+ * asked not to be interrupted, not to be kept uninformed.
6455
+ *
5905
6456
  * The one exclusion is structural rather than editorial: a commit review is of one fixed commit
5906
6457
  * that cannot move, and `freshness.ts` calls that state `fixed` for the same reason. A review with
5907
6458
  * no pull request used to be excluded too, having nothing to ask a forge about. It is watched
@@ -5909,7 +6460,7 @@ async function announce(candidate, key, observed, state, deps, now) {
5909
6460
  */
5910
6461
  function isWatchable(workspace, queued) {
5911
6462
  if (isCommitReview(workspace.changeSet)) return false;
5912
- return queued.has(workspace.id) || workspace.pinned === true || workspace.completedAt === void 0;
6463
+ return queued.has(workspace.id) || workspace.pinned === true || workspace.completedAt === void 0 || parseLifecycle(workspace.lifecycle) !== void 0;
5913
6464
  }
5914
6465
  /** The request a review is attached to, by URL where there is one and by number otherwise. */
5915
6466
  function requestOf(workspace) {
@@ -6000,6 +6551,50 @@ function prStatesByRequest(states = readPrStates()) {
6000
6551
  return new Map(Object.entries(states).map(([key, entry]) => [key, entry.state]));
6001
6552
  }
6002
6553
  /**
6554
+ * What last happened to each branch's change, by the id of its newest review: the list's order and
6555
+ * the time on each card.
6556
+ *
6557
+ * The latest of three things, each of which is the change moving rather than anybody looking at it:
6558
+ * the newest analysis being made, its head moving, and its pull request changing state. Read from
6559
+ * the two `changedAt`s, never the `observedAt`s, which every poll rewrites.
6560
+ *
6561
+ * A watch entry follows the newest analysis of its request, so it is looked for across the whole
6562
+ * history, the same way `moved` is: an entry still filed against an older analysis is the same
6563
+ * change. A head move or state change from before the newest analysis is already in it, so only a
6564
+ * later one counts.
6565
+ */
6566
+ function reviewActivity(groups, watch = readWatch(), prStates = readPrStates()) {
6567
+ const headChanges = /* @__PURE__ */ new Map();
6568
+ for (const entry of Object.values(watch)) {
6569
+ if (entry.reviewId === void 0 || entry.changedAt === void 0) continue;
6570
+ headChanges.set(entry.reviewId, Math.max(entry.changedAt, headChanges.get(entry.reviewId) ?? 0));
6571
+ }
6572
+ const activity = /* @__PURE__ */ new Map();
6573
+ for (const group of groups) {
6574
+ const { latest } = group;
6575
+ let current = {
6576
+ at: latest.createdAt,
6577
+ kind: "created"
6578
+ };
6579
+ for (const review of group.history) {
6580
+ const at = headChanges.get(review.id);
6581
+ if (at !== void 0 && at > current.at) current = {
6582
+ at,
6583
+ kind: "updated"
6584
+ };
6585
+ }
6586
+ if (latest.prNumber !== void 0) {
6587
+ const entry = prStates[requestKey(latest.repoPath, latest.prNumber)];
6588
+ if (entry?.changedAt !== void 0 && entry.changedAt > current.at) current = {
6589
+ at: entry.changedAt,
6590
+ kind: entry.state === "open" ? "reopened" : entry.state
6591
+ };
6592
+ }
6593
+ activity.set(latest.id, current);
6594
+ }
6595
+ return activity;
6596
+ }
6597
+ /**
6003
6598
  * One review's pull request state, for its page, filed under the same key the list looks up. Null
6004
6599
  * for a review with no request or one never observed: unknown, not open. Only ever what the watcher
6005
6600
  * last saw; nothing here reaches the forge.
@@ -6063,6 +6658,23 @@ function watchNow(scope, makeDeps = defaultDeps) {
6063
6658
  });
6064
6659
  }
6065
6660
  /**
6661
+ * A first look at every one of these reviews, however many there are.
6662
+ *
6663
+ * For parking a selection as Waiting. `watchNow` keeps the request budget of an ordinary cycle, so
6664
+ * handed thirty reviews it would look at twenty and leave ten to the timer, and a push to one of
6665
+ * those before its turn would be taken as its first observation and never bring it back. So the
6666
+ * selection goes through in batches of that budget, one scoped cycle after another: every review is
6667
+ * looked at, and no single cycle asks more of the forge than an ordinary one does. The whole
6668
+ * selection is the reviewer's own act, which is what bounds how many batches there can be.
6669
+ */
6670
+ async function watchAllNow(reviewIds, makeDeps = defaultDeps) {
6671
+ const ids = [...reviewIds];
6672
+ for (let start = 0; start < ids.length; start += 20) {
6673
+ const batch = new Set(ids.slice(start, start + 20));
6674
+ await watchNow((candidate) => batch.has(candidate.reviewId), makeDeps);
6675
+ }
6676
+ }
6677
+ /**
6066
6678
  * Run the pass on a timer, and return the stop.
6067
6679
  *
6068
6680
  * The same shape as `startSweeping`, and tied into `cli/server.ts` the same way, so a restart
@@ -6102,14 +6714,26 @@ var FETCH_SELECTED_LABEL = "Fetch selected";
6102
6714
  //#region ../../packages/review-harness/src/workspace/reviews_page.ts
6103
6715
  function renderReviewsPage(view) {
6104
6716
  const now = view.now ?? Date.now();
6105
- const ordered = [...view.groups].sort((a, b) => Number(b.latest.pinned) - Number(a.latest.pinned) || touchedAt(b.latest) - touchedAt(a.latest));
6717
+ const activityOf = (group) => view.activity?.get(group.latest.id) ?? {
6718
+ at: group.latest.createdAt,
6719
+ kind: "created"
6720
+ };
6721
+ const ordered = [...view.groups].sort((a, b) => Number(b.latest.pinned) - Number(a.latest.pinned) || activityOf(b).at - activityOf(a).at);
6722
+ const sections = {
6723
+ active: [],
6724
+ waiting: [],
6725
+ done: []
6726
+ };
6727
+ for (const group of ordered) sections[sectionOf(group.latest.lifecycle, activityOf(group))].push(group);
6728
+ const rank = new Map(ordered.map((group, index) => [group, index]));
6729
+ const renderCards = (groups) => groups.map((group) => renderCard(group, now, activityOf(group), rank.get(group), view.analyses?.get(group.latest.id), positions.get(group.latest.id), group.history.map((review) => view.moved?.get(review.id)).find((kind) => kind !== void 0), prStateOf(group.latest, view.prStates))).join("");
6106
6730
  const storedReviews = view.groups.reduce((total, group) => total + group.history.length, 0);
6107
6731
  const queue = view.queue ?? [];
6108
6732
  const positions = new Map(queue.map((id, position) => [id, position]));
6109
6733
  const continues = continueTarget(queue);
6110
6734
  const front = continues === null ? void 0 : findReview(view.groups, continues);
6111
6735
  return `<!doctype html>
6112
- <html lang="en">
6736
+ <html lang="en"${themeAttribute(view.theme)}>
6113
6737
  <head>
6114
6738
  <meta charset="utf-8">
6115
6739
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -6136,13 +6760,15 @@ ${renderWorkspaceRail({
6136
6760
  <p class="prb-index-count" data-count>${escapeHtml$1(countLabel(storedReviews, ordered.length))}</p>
6137
6761
  <button type="button" class="prb-index-select" data-select-mode aria-pressed="false">Select</button>
6138
6762
  </div>`}
6139
- <div class="prb-cards" data-cards>
6140
- ${ordered.map((group) => renderCard(group, now, view.analyses?.get(group.latest.id), positions.get(group.latest.id), group.history.map((review) => view.moved?.get(review.id)).find((kind) => kind !== void 0), prStateOf(group.latest, view.prStates))).join("")}
6763
+ <div class="prb-cards" data-cards="active">
6764
+ ${renderCards(sections.active)}
6141
6765
  </div>
6766
+ ${renderLoadMore("active")}
6142
6767
  ${ordered.length === 0 ? `<p class="prb-index-empty">No reviews stored yet. Ask Claude Code to review a change and one will appear here.</p>` : `<p class="prb-index-none" data-no-matches hidden>Nothing matches that.</p>`}
6143
- ${ordered.length > 15 ? renderPager() : ""}
6768
+ ${renderSection("waiting", "Waiting", sections.waiting, true, renderCards)}
6769
+ ${renderSection("done", "Done", sections.done, false, renderCards)}
6144
6770
  ${ordered.length === 0 ? "" : renderBulkBar()}
6145
- <p class="prb-index-note">Reviews are kept for thirty days, on this machine only. Saved reviews and anything in your review queue are kept for ninety days instead, counting from the last time you opened them. Every review has a fixed address, so a link to one is worth keeping and still opens after a restart. Stopping the server deletes nothing; it only stops anything being served until the next review starts it again.</p>
6771
+ <p class="prb-index-note">Reviews are kept for thirty days, on this machine only. Saved reviews, Done reviews and anything in your review queue are kept for ninety days instead, counting from the last time you opened or marked them. Every review has a fixed address, so a link to one is worth keeping and still opens after a restart. Stopping the server deletes nothing; it only stops anything being served until the next review starts it again.</p>
6146
6772
  </main>
6147
6773
  </div>
6148
6774
  <script src="/assets/reviews.js" type="module"><\/script>
@@ -6223,22 +6849,31 @@ function findReview(groups, id) {
6223
6849
  for (const group of groups) for (const review of group.history) if (review.id === id) return review;
6224
6850
  }
6225
6851
  /**
6226
- * The pager, with its numbers left out.
6852
+ * One section's Load more, which the browser shows once that section holds more than it is showing.
6227
6853
  *
6228
- * How many pages there are depends on what the search matched, and matching happens in the browser
6229
- * against the whole list — so the numbers are the one part of this page the server cannot know.
6230
- * Every word of it is still written here, where the house style tests read it; only the digits are
6231
- * filled in later.
6854
+ * One per section rather than one pager for the page, so revealing older Done reviews never costs
6855
+ * the reviewer their place in Active. Hidden until the client has counted, so a browser with no
6856
+ * JavaScript gets every card and no control that does nothing.
6857
+ */
6858
+ function renderLoadMore(section) {
6859
+ return `<button type="button" class="prb-index-more" data-load-more="${section}" hidden>Load more</button>`;
6860
+ }
6861
+ /**
6862
+ * Waiting or Done, as a disclosure under the working list.
6232
6863
  *
6233
- * Hidden until the client has counted, so a browser with no JavaScript gets the whole list and no
6234
- * controls that do nothing, rather than an empty pager under it.
6864
+ * Active has no heading: it is what the page has always been, and the count line above it already
6865
+ * says what it holds. Waiting starts open, because it is work that will come back; Done starts
6866
+ * closed, because it is the archive. Rendered empty and hidden rather than left out, so a card the
6867
+ * browser moves here after a mark has somewhere to go.
6235
6868
  */
6236
- function renderPager() {
6237
- return `<nav class="prb-index-pager" data-pager aria-label="Review pages" hidden>
6238
- <button type="button" class="prb-index-page" data-page-step="-1">&lsaquo; Previous</button>
6239
- <span class="prb-index-pages" data-pages></span>
6240
- <button type="button" class="prb-index-page" data-page-step="1">Next &rsaquo;</button>
6241
- </nav>`;
6869
+ function renderSection(section, title, groups, open, renderCards) {
6870
+ return `<details class="prb-index-section" data-section="${section}"${open ? " open" : ""}${groups.length === 0 ? " hidden" : ""}>
6871
+ <summary class="prb-index-section-head">${title} · <span data-section-count>${groups.length}</span></summary>
6872
+ <div class="prb-cards" data-cards="${section}">
6873
+ ${renderCards(groups)}
6874
+ </div>
6875
+ ${renderLoadMore(section)}
6876
+ </details>`;
6242
6877
  }
6243
6878
  /**
6244
6879
  * The bulk bar, with its counts left out.
@@ -6263,6 +6898,7 @@ function renderBulkBar() {
6263
6898
  <button type="button" class="prb-bulk-button" data-bulk-queue disabled>Add to review queue</button>
6264
6899
  <button type="button" class="prb-bulk-button" data-bulk-mark="complete">Mark reviewed</button>
6265
6900
  <button type="button" class="prb-bulk-button" data-bulk-mark="in-progress">Mark in progress</button>
6901
+ ${LIFECYCLES.map((lifecycle) => `<button type="button" class="prb-bulk-button" data-bulk-lifecycle="${lifecycle.id}">${escapeHtml$1(lifecycle.action)}</button>`).join("\n ")}
6266
6902
  <button type="button" class="prb-bulk-button prb-bulk-button-danger" data-bulk-delete></button>
6267
6903
  <button type="button" class="prb-bulk-button prb-bulk-cancel" data-bulk-cancel>Cancel</button>
6268
6904
  </div>
@@ -6296,20 +6932,22 @@ function renderBulkBar() {
6296
6932
  * card and is shown only in selection mode, so entering that mode is a stylesheet change rather
6297
6933
  * than a rebuild of the list.
6298
6934
  */
6299
- function renderCard(group, now, analysis, queued, moved, prState) {
6935
+ function renderCard(group, now, activity, rank, analysis, queued, moved, prState) {
6300
6936
  const review = group.latest;
6301
6937
  const status = statusOf(review);
6938
+ const chip = lifecycleChip(review.lifecycle, activity);
6302
6939
  const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
6303
6940
  const followed = review.targetType === "commit" ? `commit ${(review.commitSha ?? review.branch).slice(0, 7)}` : followedRefName(review.branch, review.headResolvedRef) ?? review.branch;
6304
- return `<article class="prb-card" data-card data-id="${escapeHtml$1(review.id)}" data-status="${status}" data-pinned="${review.pinned}"${queued === void 0 ? "" : ` data-queue="${queued}"`}${prState === void 0 ? "" : ` data-pr-state="${prState}"`}${review.authorHandle ? ` data-author="${escapeHtml$1(review.authorHandle)}"` : ""} data-reviews="${group.history.length}" data-lineage="${escapeHtml$1(group.history.map((entry) => entry.id).join(" "))}" data-search-text="${escapeHtml$1(groupSearchText(group))}">
6941
+ return `<article class="prb-card" data-card data-id="${escapeHtml$1(review.id)}" data-status="${status}" data-pinned="${review.pinned}"${queued === void 0 ? "" : ` data-queue="${queued}"`}${prState === void 0 ? "" : ` data-pr-state="${prState}"`}${review.authorHandle ? ` data-author="${escapeHtml$1(review.authorHandle)}"` : ""} data-lifecycle="${review.lifecycle?.state ?? "active"}"${review.lifecycle ? ` data-lifecycle-at="${review.lifecycle.at}"` : ""} data-activity-at="${activity.at}" data-activity-kind="${activity.kind}" data-rank="${rank}" data-reviews="${group.history.length}" data-lineage="${escapeHtml$1(group.history.map((entry) => entry.id).join(" "))}" data-search-text="${escapeHtml$1(groupSearchText(group))}">
6305
6942
  <input type="checkbox" class="prb-card-select" data-select="${escapeHtml$1(review.id)}" aria-label="Select ${escapeHtml$1(followed)}">
6306
6943
  <button type="button" class="prb-card-pin" data-pin="${escapeHtml$1(review.id)}" aria-pressed="${review.pinned}" aria-label="${review.pinned ? "Unsave" : "Save"} this review">${review.pinned ? "&#9733;" : "&#9734;"}</button>
6307
- ${renderCardMenu(group, followed, queued !== void 0, status)}
6944
+ ${renderCardMenu(group, followed, queued !== void 0, status, activity)}
6308
6945
  <a class="prb-card-link" href="${escapeHtml$1(href)}">
6309
6946
  <div class="prb-card-head">
6310
6947
  <h2>${escapeHtml$1(review.title)}</h2>
6311
6948
  ${renderStatus(status)}
6312
6949
  ${renderPrState(prState)}
6950
+ ${`<span class="prb-card-lifecycle" data-lifecycle-chip${chip === void 0 ? " hidden" : ""}>${escapeHtml$1(chip ?? "")}</span>`}
6313
6951
  </div>
6314
6952
  ${review.summary ? `<p class="prb-card-summary">${renderInlineText(review.summary)}</p>` : ""}
6315
6953
  <p class="prb-card-where">
@@ -6326,7 +6964,7 @@ function renderCard(group, now, analysis, queued, moved, prState) {
6326
6964
  <span class="prb-card-diffstat"><span class="prb-add">+${review.additions}</span> <span class="prb-del">&minus;${review.deletions}</span></span>
6327
6965
  ${analysis !== void 0 && !review.hasStandardReview ? "" : `<span class="prb-card-left">${escapeHtml$1(whatIsLeft(review))}</span>`}
6328
6966
  ${renderAnalysisState(analysis)}
6329
- <span class="prb-card-when"><time datetime="${new Date(touchedAt(review)).toISOString()}" title="${escapeHtml$1(new Date(touchedAt(review)).toLocaleString())}">${escapeHtml$1(whenLabel(review, now))}</time></span>
6967
+ <span class="prb-card-when"><time datetime="${new Date(activity.at).toISOString()}" title="${escapeHtml$1(new Date(activity.at).toLocaleString())}">${escapeHtml$1(`${activityLabel(activity.kind)} ${relativeTime(activity.at, now)}`)}</time></span>
6330
6968
  </p>
6331
6969
  </a>
6332
6970
  ${renderHistory(group, now)}
@@ -6349,9 +6987,13 @@ function renderCard(group, now, analysis, queued, moved, prState) {
6349
6987
  *
6350
6988
  * The rows underneath carry the per-review menus, and they only exist once there is more than one.
6351
6989
  */
6352
- function renderCardMenu(group, followed, queued, status) {
6990
+ function renderCardMenu(group, followed, queued, status, activity) {
6353
6991
  const count = group.history.length;
6354
6992
  const mark = status === "complete" ? "in-progress" : "complete";
6993
+ const lifecycle = {
6994
+ stored: group.latest.lifecycle,
6995
+ activity
6996
+ };
6355
6997
  if (count < 2) return renderMenu({
6356
6998
  id: group.latest.id,
6357
6999
  scope: "review",
@@ -6360,7 +7002,8 @@ function renderCardMenu(group, followed, queued, status) {
6360
7002
  question: "Delete this review?",
6361
7003
  copy: "Copy review ID",
6362
7004
  queue: queued ? "remove" : "add",
6363
- mark
7005
+ mark,
7006
+ lifecycle
6364
7007
  });
6365
7008
  const all = `all ${count} reviews following ${followed}`;
6366
7009
  return renderMenu({
@@ -6371,6 +7014,7 @@ function renderCardMenu(group, followed, queued, status) {
6371
7014
  question: `Delete ${all}?`,
6372
7015
  queue: queued ? "remove" : "add",
6373
7016
  mark,
7017
+ lifecycle,
6374
7018
  copy: "Copy latest review ID"
6375
7019
  });
6376
7020
  }
@@ -6391,11 +7035,13 @@ function renderMenu(item) {
6391
7035
  const id = escapeHtml$1(item.id);
6392
7036
  const markItem = item.mark === void 0 ? "" : `<button type="button" class="prb-menu-item" data-mark-status="${item.mark}" data-mark-id="${id}">${markItemLabel(item.mark)}</button>`;
6393
7037
  const queueItem = item.queue === void 0 ? "" : `<button type="button" class="prb-menu-item" data-queue-action="${item.queue}" data-queue-id="${id}">${item.queue === "remove" ? "Remove from review queue" : "Add to review queue"}</button>`;
7038
+ const lifecycleItems = item.lifecycle === void 0 ? "" : LIFECYCLES.map(({ id: target }) => `<button type="button" class="prb-menu-item" data-lifecycle-set="${target}" data-lifecycle-id="${id}"${offersLifecycle(target, item.lifecycle.stored, item.lifecycle.activity) ? "" : " hidden"}>${escapeHtml$1(lifecycleAction(target))}</button>`).join("\n ");
6394
7039
  return `<div class="prb-menu" data-menu>
6395
7040
  <button type="button" class="prb-menu-trigger" data-menu-trigger aria-expanded="false" aria-haspopup="true" aria-label="${escapeHtml$1(item.label)}">&#8943;</button>
6396
7041
  <div class="prb-menu-panel" data-menu-panel hidden>
6397
7042
  <div data-menu-items>
6398
7043
  ${markItem}
7044
+ ${lifecycleItems}
6399
7045
  ${queueItem}
6400
7046
  <button type="button" class="prb-menu-item" data-copy-id="${id}">${escapeHtml$1(item.copy)}</button>
6401
7047
  <button type="button" class="prb-menu-item prb-menu-item-danger" data-delete="${id}" data-scope="${item.scope}">${escapeHtml$1(item.action)}</button>
@@ -6542,13 +7188,6 @@ function renderRisk(risk) {
6542
7188
  if (risk === "unknown") return "";
6543
7189
  return `<span class="prb-card-risk" data-risk="${risk}">${risk} risk</span>`;
6544
7190
  }
6545
- /** Last opened where that is known, created otherwise. */
6546
- function touchedAt(review) {
6547
- return review.lastViewedAt ?? review.createdAt;
6548
- }
6549
- function whenLabel(review, now) {
6550
- return review.lastViewedAt ? `Opened ${relativeTime(review.lastViewedAt, now)}` : `Created ${relativeTime(review.createdAt, now)}`;
6551
- }
6552
7191
  /**
6553
7192
  * Everything the search field matches, flattened into one string on the card.
6554
7193
  *
@@ -6638,6 +7277,115 @@ function escapeHtml(value) {
6638
7277
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
6639
7278
  }
6640
7279
  //#endregion
7280
+ //#region ../../packages/review-harness/src/workspace/preferences.ts
7281
+ /**
7282
+ * Workspace preferences that belong to the reviewer rather than to a review.
7283
+ *
7284
+ * Kept server-side, in their own file. The original reason was that `localStorage` is keyed by
7285
+ * origin and this server took whatever port the OS offered on each start, so a restart would
7286
+ * silently discard a width the reviewer had deliberately chosen. That reason has gone: the port is
7287
+ * fixed, so the origin holds still and `localStorage` would now work. Moving this is a change worth
7288
+ * making on its own merits rather than in passing, and there is a second reason it stays for now:
7289
+ * `config.json` next door is hand-edited, and writing to a file someone maintains by hand reformats
7290
+ * it under them the first time they save, which is why this is not simply a key in there.
7291
+ *
7292
+ * Everything here is best-effort. A workspace that will not open because a preferences file is
7293
+ * unreadable would be a preference costing more than it is worth.
7294
+ */
7295
+ var PREFERENCES_PATH = join(STORE_ROOT, "ui.json");
7296
+ var MAX_ASK_WIDTH = 1200;
7297
+ /** A key is a short identifier, never prose. Anything else in the file is not one of ours. */
7298
+ var NOTICE_KEY = /^[a-z-]+:[^\s]{1,200}$/;
7299
+ function readPreferences() {
7300
+ try {
7301
+ const parsed = JSON.parse(readFileSync(PREFERENCES_PATH, "utf8"));
7302
+ if (!parsed || typeof parsed !== "object") return {};
7303
+ const { askWidth, dismissedNotices, promptsCollapsed, changeMapRegister, theme } = parsed;
7304
+ return {
7305
+ ...typeof askWidth === "number" && Number.isFinite(askWidth) ? { askWidth: clampAskWidth(askWidth) } : {},
7306
+ ...Array.isArray(dismissedNotices) ? { dismissedNotices: dismissedNotices.filter((key) => typeof key === "string" && NOTICE_KEY.test(key)) } : {},
7307
+ ...typeof promptsCollapsed === "boolean" ? { promptsCollapsed } : {},
7308
+ ...changeMapRegister === "simple" || changeMapRegister === "technical" ? { changeMapRegister } : {},
7309
+ ...isTheme(theme) ? { theme } : {}
7310
+ };
7311
+ } catch {
7312
+ return {};
7313
+ }
7314
+ }
7315
+ function writeAskWidth(width) {
7316
+ if (!Number.isFinite(width)) return;
7317
+ try {
7318
+ mkdirSync(STORE_ROOT, { recursive: true });
7319
+ const next = {
7320
+ ...readPreferences(),
7321
+ askWidth: clampAskWidth(width)
7322
+ };
7323
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify(next, null, 2)}\n`);
7324
+ } catch {}
7325
+ }
7326
+ /** Remember whether "Your prompts" is folded. Read-modify-write, like every other field here. */
7327
+ function writePromptsCollapsed(collapsed) {
7328
+ try {
7329
+ mkdirSync(STORE_ROOT, { recursive: true });
7330
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
7331
+ ...readPreferences(),
7332
+ promptsCollapsed: collapsed
7333
+ }, null, 2)}\n`);
7334
+ } catch {}
7335
+ }
7336
+ /** Remember which words the change map is read in. Read-modify-write, like every other field here. */
7337
+ function writeChangeMapRegister(register) {
7338
+ try {
7339
+ mkdirSync(STORE_ROOT, { recursive: true });
7340
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
7341
+ ...readPreferences(),
7342
+ changeMapRegister: register
7343
+ }, null, 2)}\n`);
7344
+ } catch {}
7345
+ }
7346
+ /** Remember which palette the workspace is drawn in. Read-modify-write, like every other field here. */
7347
+ function writeTheme(theme) {
7348
+ try {
7349
+ mkdirSync(STORE_ROOT, { recursive: true });
7350
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
7351
+ ...readPreferences(),
7352
+ theme
7353
+ }, null, 2)}\n`);
7354
+ } catch {}
7355
+ }
7356
+ /** Remember that the reviewer has read a notice. Refuses anything not shaped like a key. */
7357
+ function dismissNotice(key) {
7358
+ if (!NOTICE_KEY.test(key)) return false;
7359
+ try {
7360
+ const current = readPreferences();
7361
+ const dismissedNotices = [.../* @__PURE__ */ new Set([...current.dismissedNotices ?? [], key])];
7362
+ mkdirSync(STORE_ROOT, { recursive: true });
7363
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
7364
+ ...current,
7365
+ dismissedNotices
7366
+ }, null, 2)}\n`);
7367
+ return true;
7368
+ } catch {
7369
+ return false;
7370
+ }
7371
+ }
7372
+ /** Forget a stated width, so the stylesheet's proportional default applies again. */
7373
+ function clearAskWidth() {
7374
+ try {
7375
+ const { askWidth: _dropped, ...rest } = readPreferences();
7376
+ mkdirSync(STORE_ROOT, { recursive: true });
7377
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify(rest, null, 2)}\n`);
7378
+ } catch {}
7379
+ }
7380
+ /**
7381
+ * Absolute bounds only. The upper limit the reviewer actually feels is half the viewport, which
7382
+ * the page enforces because it is the only party that knows how wide the window is — this is the
7383
+ * far cruder job of keeping a hand-edited or stale number from producing an unusable layout.
7384
+ */
7385
+ function clampAskWidth(width) {
7386
+ return Math.round(Math.min(MAX_ASK_WIDTH, Math.max(320, width)));
7387
+ }
7388
+ //#endregion
6641
7389
  //#region ../../packages/review-harness/src/workspace/settings.ts
6642
7390
  /**
6643
7391
  * The settings the workspace can show and change, as one object rather than as three files.
@@ -6669,7 +7417,8 @@ function readSettings() {
6669
7417
  },
6670
7418
  agent: readAgentPreference(),
6671
7419
  agents: AGENT_IDS,
6672
- usageCounts: telemetryUploadConsent()
7420
+ usageCounts: telemetryUploadConsent(),
7421
+ theme: readPreferences().theme ?? "system"
6673
7422
  };
6674
7423
  }
6675
7424
  /** True when what the reader is looking at is served on a port they have since changed. */
@@ -6696,10 +7445,19 @@ function saveSettings(change, minPort, maxPort) {
6696
7445
  settings: readSettings(),
6697
7446
  error: `There is no agent called \`${change.agent}\`.`
6698
7447
  };
7448
+ if (change.theme !== void 0 && !isTheme(change.theme)) {
7449
+ const labels = THEMES.map((theme) => theme.label);
7450
+ const choices = `${labels.slice(0, -1).join(", ")} or ${labels.at(-1)}`;
7451
+ return {
7452
+ settings: readSettings(),
7453
+ error: `There is no theme called \`${change.theme}\`. Choose ${choices}.`
7454
+ };
7455
+ }
6699
7456
  if (port !== null) writePortPreference(port);
6700
7457
  if (change.agent === "") clearAgentPreference();
6701
7458
  else if (change.agent !== void 0) writeAgentPreference(change.agent);
6702
7459
  if (change.usageCounts !== void 0) recordTelemetryUploadConsent(change.usageCounts);
7460
+ if (change.theme !== void 0 && isTheme(change.theme)) writeTheme(change.theme);
6703
7461
  return {
6704
7462
  settings: readSettings(),
6705
7463
  error: null
@@ -6711,7 +7469,7 @@ function renderSettingsPage(view) {
6711
7469
  const { settings } = view;
6712
7470
  const address = `http://localhost:${settings.port.configured}`;
6713
7471
  return `<!doctype html>
6714
- <html lang="en">
7472
+ <html lang="en"${themeAttribute(settings.theme)}>
6715
7473
  <head>
6716
7474
  <meta charset="utf-8">
6717
7475
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -6733,6 +7491,7 @@ ${renderWorkspaceRail({
6733
7491
  unread: view.unreadNotifications ?? 0
6734
7492
  })}
6735
7493
  <main class="prb-index prb-settings">
7494
+ ${renderAppearanceSection(settings)}
6736
7495
  ${renderWorkspaceSection(settings, address)}
6737
7496
  ${renderReviewsSection(settings)}
6738
7497
  ${renderPrivacySection(settings)}
@@ -6744,7 +7503,8 @@ ${renderWorkspaceRail({
6744
7503
 
6745
7504
  <p class="prb-index-note">These are kept in <code>~/.prreviewbuddy/config.json</code>, which you can
6746
7505
  edit by hand, and they are the same settings <code>prreviewbuddy config</code> reads and writes.
6747
- Run <code>prreviewbuddy config path</code> to be told where that file is.</p>
7506
+ Run <code>prreviewbuddy config path</code> to be told where that file is. Appearance is kept with
7507
+ the workspace's other display choices in <code>~/.prreviewbuddy/ui.json</code>.</p>
6748
7508
 
6749
7509
  ${renderServerSection()}
6750
7510
  </main>
@@ -6812,6 +7572,23 @@ function renderWorkspaceSection(settings, address) {
6812
7572
  working tomorrow.${moved ? ` <strong>You are reading this on port ${settings.port.current}.</strong> The port above is used the next time the workspace starts.` : ""}</p>
6813
7573
  </section>`;
6814
7574
  }
7575
+ /**
7576
+ * The theme, as three radios over the registry in theme.ts.
7577
+ *
7578
+ * Choosing one repaints this page at once (see `client/settings.ts`), so the reader can see what
7579
+ * they are picking; Save is still what keeps it, like every other field here.
7580
+ */
7581
+ function renderAppearanceSection(settings) {
7582
+ return `<section class="prb-settings-section">
7583
+ <h2>Appearance</h2>
7584
+ <div class="prb-field prb-settings-field" role="group" aria-labelledby="prb-theme-label">
7585
+ <span id="prb-theme-label">Theme</span>
7586
+ <div class="prb-settings-choice">
7587
+ ${THEMES.map((theme) => `<label><input type="radio" name="prb-theme" value="${theme.id}" data-settings-theme${settings.theme === theme.id ? " checked" : ""}> ${theme.label} <small class="prb-settings-choice-help">${theme.help}</small></label>`).join("\n ")}
7588
+ </div>
7589
+ </div>
7590
+ </section>`;
7591
+ }
6815
7592
  function renderReviewsSection(settings) {
6816
7593
  return `<section class="prb-settings-section">
6817
7594
  <h2>Reviews</h2>
@@ -7070,7 +7847,7 @@ function defaultFetchDeps() {
7070
7847
  function renderNotificationsPage(view) {
7071
7848
  const unread = view.notifications.filter((notification) => notification.readAt === void 0).length;
7072
7849
  return `<!doctype html>
7073
- <html lang="en">
7850
+ <html lang="en"${themeAttribute(view.theme)}>
7074
7851
  <head>
7075
7852
  <meta charset="utf-8">
7076
7853
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -7267,6 +8044,8 @@ function reviewsRevision(live = analysisStates()) {
7267
8044
  hash.update(`queue:${queueStamp()}\n`);
7268
8045
  for (const [id, kind] of [...movedReviews()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`moved:${id}:${kind}\n`);
7269
8046
  for (const [key, state] of [...prStatesByRequest()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`pr:${key}:${state}\n`);
8047
+ for (const [key, entry] of Object.entries(readWatch()).sort(([a], [b]) => a.localeCompare(b))) if (entry.changedAt !== void 0) hash.update(`head:${key}:${entry.changedAt}\n`);
8048
+ for (const [key, entry] of Object.entries(readPrStates()).sort(([a], [b]) => a.localeCompare(b))) if (entry.changedAt !== void 0) hash.update(`pr-changed:${key}:${entry.changedAt}\n`);
7270
8049
  return hash.digest("base64url");
7271
8050
  }
7272
8051
  //#endregion
@@ -7338,7 +8117,7 @@ async function askAboutChange(workspace, question, sectionId, signal) {
7338
8117
  try {
7339
8118
  return extractAnswer(await withUsageRecorded(workspace.id, "ask", env, (onUsage) => agentFor(env).run({
7340
8119
  capability: "read-code",
7341
- prompt: buildPrompt$1(workspace, question, sectionId, cwd),
8120
+ prompt: buildPrompt$2(workspace, question, sectionId, cwd),
7342
8121
  cwd,
7343
8122
  originRepoPath: workspace.repoPath,
7344
8123
  env,
@@ -7397,7 +8176,7 @@ function extractAnswer(raw) {
7397
8176
  * where X is in another theme, and a prompt that had quietly dropped X would answer confidently
7398
8177
  * and wrongly.
7399
8178
  */
7400
- function buildPrompt$1(workspace, question, sectionId, cwd) {
8179
+ function buildPrompt$2(workspace, question, sectionId, cwd) {
7401
8180
  const section = workspace.session.result?.sections.find((s) => s.id === sectionId);
7402
8181
  const recent = workspace.transcript.slice(-4);
7403
8182
  return [
@@ -7449,6 +8228,7 @@ function beginRun(workspaceId, kind, detail, now = Date.now()) {
7449
8228
  kind,
7450
8229
  question: detail.question ?? null,
7451
8230
  sectionId: detail.sectionId,
8231
+ ...detail.drawing ? { drawing: detail.drawing } : {},
7452
8232
  startedAt: now,
7453
8233
  signal: controller.signal,
7454
8234
  state: "running",
@@ -7464,6 +8244,7 @@ function reportRun(workspaceId, kind) {
7464
8244
  return {
7465
8245
  question: run.question,
7466
8246
  sectionId: run.sectionId,
8247
+ ...run.drawing ? { drawing: run.drawing } : {},
7467
8248
  startedAt: run.startedAt
7468
8249
  };
7469
8250
  }
@@ -7562,7 +8343,7 @@ async function drawDiagram(workspace, sectionId, signal) {
7562
8343
  try {
7563
8344
  raw = await withUsageRecorded(workspace.id, "diagram", env, (onUsage) => agentFor(env).run({
7564
8345
  capability: "read-code",
7565
- prompt: buildPrompt(workspace, sectionId, cwd),
8346
+ prompt: buildPrompt$1(workspace, sectionId, cwd),
7566
8347
  cwd,
7567
8348
  originRepoPath: workspace.repoPath,
7568
8349
  env,
@@ -7618,7 +8399,7 @@ function extractDiagram(raw) {
7618
8399
  * show what it calls into, and a prompt that had quietly dropped the rest would draw a component
7619
8400
  * with no edges leaving it and present that as the shape of the code.
7620
8401
  */
7621
- function buildPrompt(workspace, sectionId, cwd) {
8402
+ function buildPrompt$1(workspace, sectionId, cwd) {
7622
8403
  const section = workspace.session.result?.sections.find((s) => s.id === sectionId);
7623
8404
  return [
7624
8405
  SYSTEM_PROMPT,
@@ -7634,6 +8415,183 @@ function buildPrompt(workspace, sectionId, cwd) {
7634
8415
  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."
7635
8416
  ].join("\n");
7636
8417
  }
8418
+ //#endregion
8419
+ //#region ../../packages/review-harness/src/domain/change_map/prompt.ts
8420
+ /**
8421
+ * What a change map is asked to be.
8422
+ *
8423
+ * Not the implementation diagram made smaller. That one asks "how does the code do this?" and is
8424
+ * right to name functions; this one asks "what is this change about, and why does each part matter
8425
+ * to the next?", and a function is a node here only when it is the handle a reader needs to find
8426
+ * the idea. The two prompts are separate because a single one asked to be both at once produces a
8427
+ * call graph with friendlier labels, which is the failure this feature exists to fix.
8428
+ *
8429
+ * The map is built from the review rather than beside it. The Review Path already decided what the
8430
+ * themes of this change are; a map that rediscovered them would sooner or later disagree with the
8431
+ * guide it sits next to, and the reviewer would be left to work out which one was wrong.
8432
+ */
8433
+ 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.
8434
+
8435
+ 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.
8436
+
8437
+ 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.
8438
+
8439
+ What goes on the map:
8440
+ - One "intent" node: what the change is trying to accomplish for the person using it.
8441
+ - The main ideas ("concept"): the behaviour introduced or changed.
8442
+ - Actors and boundaries ("actor") only where they matter to the story: the CLI, a server, the browser, the filesystem, a worker, an external service.
8443
+ - Shared state or data ("state") that more than one part reads or writes.
8444
+ - Rules the change must keep ("constraint"), and what goes wrong if it does not ("risk").
8445
+ - One "outcome" node: what the person using it gets, or would lose.
8446
+ 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.
8447
+
8448
+ Reading order. Every node has a "stage" from 0 to 4, and the map is read by stage, left to right:
8449
+ 0 intent · 1 behaviour · 2 shared state and actors · 3 risks and constraints · 4 outcome.
8450
+ List nodes in the order the story reaches them.
8451
+
8452
+ 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.
8453
+
8454
+ 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.
8455
+
8456
+ 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.
8457
+ - 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.
8458
+ - technical: the precise terms a developer would use, including the mechanism.
8459
+ 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()").
8460
+
8461
+ 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.
8462
+
8463
+ Reply with one JSON object and nothing else, in exactly this shape:
8464
+
8465
+ {
8466
+ "title": "<short noun phrase naming the change>",
8467
+ "intent": { "simple": "<one sentence>", "technical": "<one sentence>" },
8468
+ "nodes": [
8469
+ {
8470
+ "id": "<short-kebab-id>",
8471
+ "role": "intent | actor | concept | state | constraint | risk | outcome",
8472
+ "stage": 0,
8473
+ "weight": "primary | supporting",
8474
+ "title": { "simple": "...", "technical": "..." },
8475
+ "body": { "simple": "...", "technical": "..." },
8476
+ "why": { "simple": "...", "technical": "..." },
8477
+ "anchor": "<optional identifier or path>",
8478
+ "sectionId": "<optional theme id from the review guide>"
8479
+ }
8480
+ ],
8481
+ "edges": [
8482
+ {
8483
+ "from": "<node id>",
8484
+ "to": "<node id>",
8485
+ "label": { "simple": "...", "technical": "..." },
8486
+ "note": { "simple": "...", "technical": "..." }
8487
+ }
8488
+ ]
8489
+ }
8490
+
8491
+ "why", "anchor", "sectionId" and "note" are optional. Every other field is required.
8492
+
8493
+ For example, for a change that caches user profiles, two of the nodes and the edge between them might be:
8494
+
8495
+ { "id": "profile-cache", "role": "state", "stage": 2, "weight": "primary",
8496
+ "title": { "simple": "A saved copy of each profile", "technical": "In-memory profile cache" },
8497
+ "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." },
8498
+ "why": { "simple": "If the copy is out of date, people see old details.", "technical": "Every read path now depends on invalidation being correct." },
8499
+ "anchor": "ProfileCache" }
8500
+ { "id": "profile-edit", "role": "concept", "stage": 1, "weight": "supporting",
8501
+ "title": { "simple": "Editing your profile", "technical": "Profile update endpoint" },
8502
+ "body": { "simple": "Saving changes writes the new details.", "technical": "PUT /profile writes the row and must evict the cache entry." } }
8503
+ { "from": "profile-edit", "to": "profile-cache",
8504
+ "label": { "simple": "must refresh", "technical": "invalidates" },
8505
+ "note": { "simple": "Otherwise the saved copy keeps showing the old details.", "technical": "A missed eviction serves stale data until the entry expires." } }`;
8506
+ //#endregion
8507
+ //#region ../../packages/review-harness/src/workspace/change_map.ts
8508
+ /** A diagram's budget. The map reads the same change and writes less code-shaped output. */
8509
+ var CHANGE_MAP_TIMEOUT_MS = 18e4;
8510
+ /** A change map needs something to map. Refused before any agent is asked. */
8511
+ var ChangeMapUnavailableError = class extends Error {};
8512
+ /**
8513
+ * Map the change, from the review the reviewer is already reading.
8514
+ *
8515
+ * The same isolation a diagram gets: a checkout pinned to the reviewed commit, the review's own
8516
+ * model, and its usage recorded against the review. Callable on its own, with nothing about the
8517
+ * request in it, so the day maps are drawn without being asked for is a new caller, not a new
8518
+ * function.
8519
+ *
8520
+ * Failures come back as the diagram's own errors, because to the reviewer this is the same act —
8521
+ * asking for a picture — and the pane already knows how to say either one.
8522
+ */
8523
+ async function drawChangeMap(workspace, signal) {
8524
+ const result = workspace.session.result;
8525
+ if (!result) throw new ChangeMapUnavailableError("This review has no analysis yet, so there is nothing to map.");
8526
+ const cwd = await askCheckout(workspace);
8527
+ const env = agentEnvOf(workspace);
8528
+ const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
8529
+ let raw;
8530
+ try {
8531
+ raw = await withUsageRecorded(workspace.id, "change-map", env, (onUsage) => agentFor(env).run({
8532
+ capability: "read-code",
8533
+ prompt: buildPrompt(workspace, cwd),
8534
+ cwd,
8535
+ originRepoPath: workspace.repoPath,
8536
+ env,
8537
+ ...requestedModel ? { requestedModel } : {},
8538
+ timeoutMs: CHANGE_MAP_TIMEOUT_MS,
8539
+ signal,
8540
+ onUsage
8541
+ }), void 0, requestedModel);
8542
+ } catch (error) {
8543
+ if (error instanceof AgentUnavailableError) throw new DiagramUnavailableError(`${error.message} It is what this review was made with, so nothing can be drawn.`);
8544
+ throw error;
8545
+ }
8546
+ let map;
8547
+ try {
8548
+ ({map} = validateChangeMap(raw, result.sections.map((section) => section.id)));
8549
+ } catch (error) {
8550
+ if (error instanceof ChangeMapRejected) throw new DiagramMalformedError(`Buddy AI did not return a change map that could be drawn. ${error.message}`);
8551
+ throw error;
8552
+ }
8553
+ return {
8554
+ ...map,
8555
+ commitSha: reviewedCommit(workspace),
8556
+ createdAt: Date.now(),
8557
+ promptVersion: "2"
8558
+ };
8559
+ }
8560
+ /**
8561
+ * The review, the change, and the simple explanation the reviewer has already read, if any.
8562
+ *
8563
+ * That last one is what keeps Simple mode and "Explain in simple terms" one explanation rather than
8564
+ * two. A reviewer who has just read that the queue is like a shared whiteboard should find the same
8565
+ * whiteboard on the map, not a second analogy that makes them wonder which one is right.
8566
+ */
8567
+ function buildPrompt(workspace, cwd) {
8568
+ const simple = simpleExplanation(workspace);
8569
+ return [
8570
+ CHANGE_MAP_PROMPT,
8571
+ "---",
8572
+ `CHANGE: ${workspace.changeSet.headRef} compared against ${workspace.changeSet.baseRef}, in the checkout at ${cwd}`,
8573
+ "---",
8574
+ "REVIEW GUIDE (JSON). Its \"sections\" are the Review Path themes; their \"id\" values are the only valid \"sectionId\" values:",
8575
+ JSON.stringify(workspace.session.result),
8576
+ ...simple ? [
8577
+ "---",
8578
+ "SIMPLE EXPLANATION THE REVIEWER HAS ALREADY READ. Keep to its analogy and its framing in the \"simple\" register, so the two explanations agree:",
8579
+ simple
8580
+ ] : [],
8581
+ "---",
8582
+ "DIFF:",
8583
+ workspace.diff
8584
+ ].join("\n");
8585
+ }
8586
+ /** The latest whole-change "Explain in simple terms" answer, which is the one the map sits beside. */
8587
+ function simpleExplanation(workspace) {
8588
+ const turns = workspace.transcript ?? [];
8589
+ for (let i = turns.length - 1; i >= 0; i -= 1) {
8590
+ const turn = turns[i];
8591
+ 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();
8592
+ }
8593
+ return null;
8594
+ }
7637
8595
  var FileOutsideRepositoryError = class extends Error {};
7638
8596
  /**
7639
8597
  * Resolve a path against the reviewed repository, refusing anything outside it.
@@ -7966,93 +8924,6 @@ function readConfiguredEditor() {
7966
8924
  return null;
7967
8925
  }
7968
8926
  }
7969
- //#endregion
7970
- //#region ../../packages/review-harness/src/workspace/preferences.ts
7971
- /**
7972
- * Workspace preferences that belong to the reviewer rather than to a review.
7973
- *
7974
- * Kept server-side, in their own file. The original reason was that `localStorage` is keyed by
7975
- * origin and this server took whatever port the OS offered on each start, so a restart would
7976
- * silently discard a width the reviewer had deliberately chosen. That reason has gone: the port is
7977
- * fixed, so the origin holds still and `localStorage` would now work. Moving this is a change worth
7978
- * making on its own merits rather than in passing, and there is a second reason it stays for now:
7979
- * `config.json` next door is hand-edited, and writing to a file someone maintains by hand reformats
7980
- * it under them the first time they save, which is why this is not simply a key in there.
7981
- *
7982
- * Everything here is best-effort. A workspace that will not open because a preferences file is
7983
- * unreadable would be a preference costing more than it is worth.
7984
- */
7985
- var PREFERENCES_PATH = join(STORE_ROOT, "ui.json");
7986
- var MAX_ASK_WIDTH = 1200;
7987
- /** A key is a short identifier, never prose. Anything else in the file is not one of ours. */
7988
- var NOTICE_KEY = /^[a-z-]+:[^\s]{1,200}$/;
7989
- function readPreferences() {
7990
- try {
7991
- const parsed = JSON.parse(readFileSync(PREFERENCES_PATH, "utf8"));
7992
- if (!parsed || typeof parsed !== "object") return {};
7993
- const { askWidth, dismissedNotices, promptsCollapsed } = parsed;
7994
- return {
7995
- ...typeof askWidth === "number" && Number.isFinite(askWidth) ? { askWidth: clampAskWidth(askWidth) } : {},
7996
- ...Array.isArray(dismissedNotices) ? { dismissedNotices: dismissedNotices.filter((key) => typeof key === "string" && NOTICE_KEY.test(key)) } : {},
7997
- ...typeof promptsCollapsed === "boolean" ? { promptsCollapsed } : {}
7998
- };
7999
- } catch {
8000
- return {};
8001
- }
8002
- }
8003
- function writeAskWidth(width) {
8004
- if (!Number.isFinite(width)) return;
8005
- try {
8006
- mkdirSync(STORE_ROOT, { recursive: true });
8007
- const next = {
8008
- ...readPreferences(),
8009
- askWidth: clampAskWidth(width)
8010
- };
8011
- writeFileSync(PREFERENCES_PATH, `${JSON.stringify(next, null, 2)}\n`);
8012
- } catch {}
8013
- }
8014
- /** Remember whether "Your prompts" is folded. Read-modify-write, like every other field here. */
8015
- function writePromptsCollapsed(collapsed) {
8016
- try {
8017
- mkdirSync(STORE_ROOT, { recursive: true });
8018
- writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
8019
- ...readPreferences(),
8020
- promptsCollapsed: collapsed
8021
- }, null, 2)}\n`);
8022
- } catch {}
8023
- }
8024
- /** Remember that the reviewer has read a notice. Refuses anything not shaped like a key. */
8025
- function dismissNotice(key) {
8026
- if (!NOTICE_KEY.test(key)) return false;
8027
- try {
8028
- const current = readPreferences();
8029
- const dismissedNotices = [.../* @__PURE__ */ new Set([...current.dismissedNotices ?? [], key])];
8030
- mkdirSync(STORE_ROOT, { recursive: true });
8031
- writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
8032
- ...current,
8033
- dismissedNotices
8034
- }, null, 2)}\n`);
8035
- return true;
8036
- } catch {
8037
- return false;
8038
- }
8039
- }
8040
- /** Forget a stated width, so the stylesheet's proportional default applies again. */
8041
- function clearAskWidth() {
8042
- try {
8043
- const { askWidth: _dropped, ...rest } = readPreferences();
8044
- mkdirSync(STORE_ROOT, { recursive: true });
8045
- writeFileSync(PREFERENCES_PATH, `${JSON.stringify(rest, null, 2)}\n`);
8046
- } catch {}
8047
- }
8048
- /**
8049
- * Absolute bounds only. The upper limit the reviewer actually feels is half the viewport, which
8050
- * the page enforces because it is the only party that knows how wide the window is — this is the
8051
- * far cruder job of keeping a hand-edited or stale number from producing an unusable layout.
8052
- */
8053
- function clampAskWidth(width) {
8054
- return Math.round(Math.min(MAX_ASK_WIDTH, Math.max(320, width)));
8055
- }
8056
8927
  /** Long enough for a paragraph of standing instructions, short enough not to crowd the question. */
8057
8928
  var MAX_PROMPT_CHARS = 2e3;
8058
8929
  /**
@@ -8341,6 +9212,7 @@ async function handle(req, res, options) {
8341
9212
  if (url.pathname === "/queue") return handleQueue(req, res, options.indexToken);
8342
9213
  if (url.pathname === "/delete") return handleDelete(req, res, options.indexToken);
8343
9214
  if (url.pathname === "/status") return handleStatus(req, res, options.indexToken);
9215
+ if (url.pathname === "/lifecycle") return handleLifecycle(req, res, options.indexToken, options.establishBaseline ?? establishBaseline);
8344
9216
  if (url.pathname === "/revision") return handleRevision(req, res, options.indexToken);
8345
9217
  if (url.pathname === "/fetch-updates") return handleFetchUpdates(req, res, options.indexToken);
8346
9218
  if (url.pathname === "/shutdown") return handleIndexShutdown(req, res, options);
@@ -8431,9 +9303,11 @@ function handleIndex(req, res, params, indexToken) {
8431
9303
  if (onward) return redirect(res, onward, cookie);
8432
9304
  const states = analysisStates();
8433
9305
  const queue = readQueue();
8434
- const kept = new Set(queue);
8435
- for (const review of recentWorkspaces(Number.MAX_SAFE_INTEGER)) if (review.pinned) kept.add(review.id);
8436
- const groups = indexReviewGroups(MAX_LISTED_BRANCHES, MAX_KEPT_BRANCHES, kept);
9306
+ const reviews = recentWorkspaces(Number.MAX_SAFE_INTEGER);
9307
+ const kept = indexKeptIds(reviews, queue);
9308
+ const everyGroup = groupByLineage(reviews);
9309
+ const activity = reviewActivity(everyGroup);
9310
+ const groups = indexReviewGroups(MAX_LISTED_BRANCHES, MAX_KEPT_BRANCHES, kept, (group) => activity.get(group.latest.id)?.at ?? group.latest.createdAt, everyGroup);
8437
9311
  const rendered = new Set(groups.flatMap((group) => group.history.map((review) => review.id)));
8438
9312
  sendHtml(res, 200, renderReviewsPage({
8439
9313
  groups,
@@ -8441,9 +9315,11 @@ function handleIndex(req, res, params, indexToken) {
8441
9315
  analyses: states,
8442
9316
  moved: movedReviews(),
8443
9317
  prStates: prStatesByRequest(),
9318
+ activity,
8444
9319
  revision: reviewsRevision(states),
8445
9320
  unreadNotifications: unreadCount$1(),
8446
- driving: isDriving()
9321
+ driving: isDriving(),
9322
+ theme: readPreferences().theme
8447
9323
  }), cookie);
8448
9324
  }
8449
9325
  /**
@@ -8515,7 +9391,8 @@ async function handleSettings(req, res, params, indexToken) {
8515
9391
  const { settings, error } = saveSettings({
8516
9392
  ...typeof body?.port === "string" ? { port: body.port } : {},
8517
9393
  ...typeof body?.agent === "string" ? { agent: body.agent } : {},
8518
- ...typeof body?.usageCounts === "boolean" ? { usageCounts: body.usageCounts } : {}
9394
+ ...typeof body?.usageCounts === "boolean" ? { usageCounts: body.usageCounts } : {},
9395
+ ...typeof body?.theme === "string" ? { theme: body.theme } : {}
8519
9396
  }, MIN_WORKSPACE_PORT, MAX_WORKSPACE_PORT);
8520
9397
  return send(res, 200, {
8521
9398
  port: {
@@ -8524,6 +9401,7 @@ async function handleSettings(req, res, params, indexToken) {
8524
9401
  },
8525
9402
  agent: settings.agent,
8526
9403
  usageCounts: settings.usageCounts,
9404
+ theme: settings.theme,
8527
9405
  restartRequired: restartRequired(settings),
8528
9406
  error
8529
9407
  });
@@ -8615,7 +9493,8 @@ async function handleNotifications(req, res, params, indexToken) {
8615
9493
  notifications,
8616
9494
  gone: new Set(notifications.filter((entry) => !loadWorkspace(entry.reviewId)).map((entry) => entry.id)),
8617
9495
  unreadNotifications: unreadCount$1(),
8618
- driving: isDriving()
9496
+ driving: isDriving(),
9497
+ theme: readPreferences().theme
8619
9498
  }), indexCookieHeader(indexToken));
8620
9499
  }
8621
9500
  /**
@@ -8675,6 +9554,74 @@ async function handleStatus(req, res, indexToken) {
8675
9554
  });
8676
9555
  }
8677
9556
  /**
9557
+ * Put a selection in Active, Waiting or Done.
9558
+ *
9559
+ * The same shape and the same credential as `handleStatus` beside it, for its reasons: this is the
9560
+ * list acting on itself, and a lifecycle is a note about what happens next, not a change to the
9561
+ * analysis, so nothing in flight is a reason to refuse it.
9562
+ *
9563
+ * **A state that is stated and unknown is refused whole**, naming it and what would have worked.
9564
+ * Guessing (reading `archived` as Done, say) would put cards where the reviewer did not ask for
9565
+ * them, and skipping it would answer 200 to a request that did nothing.
9566
+ *
9567
+ * Waiting and Done both leave the queue: it holds work somebody means to do next, and either mark
9568
+ * says there is none. Active does not put anything back in, as un-marking a review does not.
9569
+ *
9570
+ * Marking Waiting again writes a new `at`, which is what settles a Waiting review that changed: the
9571
+ * reviewer has seen the change and parked it again.
9572
+ */
9573
+ async function handleLifecycle(req, res, indexToken, baseline) {
9574
+ if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
9575
+ const body = await readJson(req);
9576
+ const state = body?.state;
9577
+ if (!isLifecycle(state)) {
9578
+ const choices = LIFECYCLES.map((lifecycle) => lifecycle.id).join(", ");
9579
+ return send(res, 400, { error: `There is no lifecycle state called \`${String(state)}\`. Choose one of ${choices}.` });
9580
+ }
9581
+ const ids = Array.isArray(body?.ids) ? body.ids.filter((id) => typeof id === "string") : [];
9582
+ const now = Date.now();
9583
+ let changed = 0;
9584
+ for (const id of ids) {
9585
+ if (!mutateWorkspace(id, (current) => {
9586
+ current.lifecycle = state === "active" ? void 0 : {
9587
+ state,
9588
+ at: now
9589
+ };
9590
+ })) continue;
9591
+ if (state !== "active") queueRemove(id);
9592
+ changed += 1;
9593
+ }
9594
+ send(res, 200, {
9595
+ ok: true,
9596
+ changed,
9597
+ at: now,
9598
+ queue: readQueue()
9599
+ });
9600
+ if (state === "waiting") {
9601
+ const unseen = unobserved(ids);
9602
+ if (unseen.size > 0) baseline(unseen);
9603
+ }
9604
+ }
9605
+ /**
9606
+ * Which of these reviews the watcher has never seen the head of.
9607
+ *
9608
+ * Those are the ones Waiting cannot yet work for: a first observation is not a change (it says
9609
+ * nothing about when the head moved), so a push before it would be absorbed as the baseline. A
9610
+ * review already observed has its baseline, and polling it again now would only spend a request.
9611
+ */
9612
+ function unobserved(ids) {
9613
+ const seen = new Set(Object.values(readWatch()).filter((entry) => entry.observedSha !== void 0).map((entry) => entry.reviewId));
9614
+ return new Set(ids.filter((id) => !seen.has(id)));
9615
+ }
9616
+ /**
9617
+ * The first look itself, by the watcher's own rules. A review that cannot be observed (the forge is
9618
+ * unreachable, or it is a commit review with nothing to watch) stays Waiting with no baseline, and
9619
+ * no change is invented for it: the next successful poll sets one.
9620
+ */
9621
+ function establishBaseline(reviewIds) {
9622
+ watchAllNow(reviewIds).catch(() => {});
9623
+ }
9624
+ /**
8678
9625
  * Has anything the reviews index draws changed, and is anything still being made.
8679
9626
  *
8680
9627
  * The whole of the index's liveness, in one request that costs the same whether the machine holds
@@ -8769,9 +9716,27 @@ async function handleQueue(req, res, indexToken) {
8769
9716
  if (action === "add") compactQueue(readQueue());
8770
9717
  const stored = action === "add" ? queueAdd(id) : queueRemove(id);
8771
9718
  if (stored === null) return send(res, 500, { error: "The queue could not be saved." });
9719
+ if (action === "add") activateQueued([id], stored);
8772
9720
  send(res, 200, { queue: compactQueue(stored) });
8773
9721
  }
8774
9722
  /**
9723
+ * Queueing a review puts it back in Active.
9724
+ *
9725
+ * The queue is work the reviewer wants surfaced next, and Waiting and Done both say the opposite; a
9726
+ * queued review folded away in Done would be a contradiction the page then has to draw. Parking
9727
+ * takes a review out of the queue, and this is the same rule read the other way. Only what actually
9728
+ * went in: a review refused because the queue was full keeps the lifecycle it had.
9729
+ */
9730
+ function activateQueued(ids, queue) {
9731
+ const queued = new Set(queue);
9732
+ for (const one of ids) {
9733
+ if (!queued.has(one) || loadWorkspace(one)?.lifecycle === void 0) continue;
9734
+ mutateWorkspace(one, (current) => {
9735
+ current.lifecycle = void 0;
9736
+ });
9737
+ }
9738
+ }
9739
+ /**
8775
9740
  * Adding a selection, and saying which of it did not go in.
8776
9741
  *
8777
9742
  * `refused` has the shape bulk deletion's `blocked` has, a sentence per id, so the bar treats both
@@ -8785,6 +9750,7 @@ function addMany(res, ids) {
8785
9750
  compactQueue(readQueue());
8786
9751
  const outcome = queueAddMany(known);
8787
9752
  if (outcome === null) return send(res, 500, { error: "The queue could not be saved." });
9753
+ activateQueued(known, outcome.queue);
8788
9754
  const refused = [...outcome.full.map((one) => ({
8789
9755
  id: one,
8790
9756
  error: `The review queue is full. It holds 50 reviews at most.`
@@ -9021,8 +9987,10 @@ async function sendWorkspacePage(res, workspace) {
9021
9987
  editor: editorChoice(),
9022
9988
  repoPath: workspace.repoPath,
9023
9989
  askWidth: readPreferences().askWidth,
9990
+ theme: readPreferences().theme,
9024
9991
  dismissedNotices: readPreferences().dismissedNotices,
9025
9992
  promptsCollapsed: readPreferences().promptsCollapsed === true,
9993
+ changeMapRegister: readPreferences().changeMapRegister ?? "simple",
9026
9994
  transcript: workspace.transcript,
9027
9995
  pendingAsk: reportRun(workspace.id, "ask"),
9028
9996
  pendingDiagram: reportRun(workspace.id, "diagram"),
@@ -9043,6 +10011,7 @@ async function sendWorkspacePage(res, workspace) {
9043
10011
  lastPrRefresh: workspace.lastPrRefresh,
9044
10012
  lastUpdate: notice,
9045
10013
  diagrams: workspace.diagrams,
10014
+ changeMap: workspace.changeMap,
9046
10015
  reviewedSha: reviewedCommit(workspace),
9047
10016
  revision: workspaceRevision(workspace),
9048
10017
  lineage: lineagePosition(workspace),
@@ -9466,10 +10435,15 @@ async function handleStop(req, res, workspace) {
9466
10435
  */
9467
10436
  async function handleDiagram(req, res, workspace) {
9468
10437
  const body = await readJson(req);
9469
- const sectionId = typeof body?.sectionId === "string" ? body.sectionId : null;
10438
+ const drawing = body?.kind === "map" ? "map" : "implementation";
10439
+ const sectionId = drawing === "implementation" && typeof body?.sectionId === "string" ? body.sectionId : null;
9470
10440
  const scope = sectionId ?? "change";
9471
- const redraw = (workspace.diagrams ?? []).some((diagram) => diagram.scope === scope);
9472
- const started = beginRun(workspace.id, "diagram", { sectionId });
10441
+ const redraw = drawing === "map" ? workspace.changeMap !== void 0 : (workspace.diagrams ?? []).some((diagram) => diagram.scope === scope);
10442
+ if (drawing === "map" && !workspace.session.result) return send(res, 409, { error: "This review has no analysis yet, so there is nothing to map." });
10443
+ const started = beginRun(workspace.id, "diagram", {
10444
+ sectionId,
10445
+ drawing
10446
+ });
9473
10447
  if ("alreadyRunning" in started) return send(res, 409, {
9474
10448
  error: "A diagram of this review is already being drawn.",
9475
10449
  running: true
@@ -9478,6 +10452,7 @@ async function handleDiagram(req, res, workspace) {
9478
10452
  record({
9479
10453
  event: "diagram_requested",
9480
10454
  reviewId: workspace.id,
10455
+ drawing,
9481
10456
  scoped: sectionId !== null,
9482
10457
  redraw
9483
10458
  });
@@ -9485,18 +10460,21 @@ async function handleDiagram(req, res, workspace) {
9485
10460
  const failed = () => record({
9486
10461
  event: "diagram_drawn",
9487
10462
  reviewId: workspace.id,
10463
+ drawing,
9488
10464
  durationMs: Date.now() - startedAt,
9489
10465
  ok: false,
9490
10466
  ...run.state === "stopped" ? { cancelled: true } : {}
9491
10467
  });
9492
10468
  try {
9493
10469
  let diagram;
10470
+ let changeMap;
9494
10471
  try {
9495
- diagram = await drawDiagram(workspace, sectionId, run.signal);
10472
+ if (drawing === "map") changeMap = await drawChangeMap(workspace, run.signal);
10473
+ else diagram = await drawDiagram(workspace, sectionId, run.signal);
9496
10474
  } catch (error) {
9497
10475
  failed();
9498
10476
  if (run.state === "stopped") return sendIfOpen(res, 200, { stopped: true });
9499
- if (error instanceof DiagramUnavailableError || error instanceof DiagramMalformedError) return sendIfOpen(res, 502, { error: error.message });
10477
+ if (error instanceof DiagramUnavailableError || error instanceof DiagramMalformedError || error instanceof ChangeMapUnavailableError) return sendIfOpen(res, 502, { error: error.message });
9500
10478
  throw error;
9501
10479
  }
9502
10480
  if (!completeRun(run)) {
@@ -9506,9 +10484,21 @@ async function handleDiagram(req, res, workspace) {
9506
10484
  record({
9507
10485
  event: "diagram_drawn",
9508
10486
  reviewId: workspace.id,
10487
+ drawing,
9509
10488
  durationMs: Date.now() - startedAt,
9510
10489
  ok: true
9511
10490
  });
10491
+ if (changeMap) {
10492
+ let kept = false;
10493
+ mutateWorkspace(workspace.id, (fresh) => {
10494
+ if (changeMap.commitSha && reviewedCommit(fresh) && changeMap.commitSha !== reviewedCommit(fresh) || fresh.jobId !== workspace.jobId || !fresh.session.result) return;
10495
+ fresh.changeMap = changeMap;
10496
+ kept = true;
10497
+ });
10498
+ 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." });
10499
+ return sendIfOpen(res, 200, { changeMap });
10500
+ }
10501
+ if (!diagram) return sendIfOpen(res, 502, { error: "Buddy AI did not return a diagram." });
9512
10502
  mutateWorkspace(workspace.id, (fresh) => {
9513
10503
  const diagrams = [...fresh.diagrams ?? []];
9514
10504
  const existing = diagrams.findIndex((one) => one.scope === scope);
@@ -9608,6 +10598,7 @@ async function handlePrefs(req, res) {
9608
10598
  if (typeof askWidth === "number") writeAskWidth(askWidth);
9609
10599
  else if (askWidth === null) clearAskWidth();
9610
10600
  if (typeof body?.promptsCollapsed === "boolean") writePromptsCollapsed(body.promptsCollapsed);
10601
+ if (body?.changeMapRegister === "simple" || body?.changeMapRegister === "technical") writeChangeMapRegister(body.changeMapRegister);
9611
10602
  if (typeof body?.dismissNotice === "string" && !dismissNotice(body.dismissNotice)) return send(res, 400, { error: "Not a notice this page can dismiss." });
9612
10603
  send(res, 200, { ok: true });
9613
10604
  }
@@ -9705,6 +10696,7 @@ var VIEWS = [
9705
10696
  "files",
9706
10697
  "prcontext",
9707
10698
  "diagram",
10699
+ "map",
9708
10700
  "kiss",
9709
10701
  "kissrecs"
9710
10702
  ];