@shiyuphan/follow-me 0.2.0 → 0.4.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/README.md +44 -55
- package/dist/src/cli.js +740 -194
- package/dist/src/comments.js +109 -6
- package/dist/src/diff.js +11 -0
- package/dist/src/evolve.js +123 -0
- package/dist/src/git.js +12 -0
- package/dist/src/host.js +39 -8
- package/dist/src/install.js +21 -0
- package/dist/src/plan.js +1 -1
- package/dist/src/prompt.js +384 -51
- package/dist/src/server.js +195 -67
- package/dist/src/session.js +53 -12
- package/dist/ui/dist/assets/abnfDiagram-WEYOIA3C-byISxePP.js +1 -0
- package/dist/ui/dist/assets/arc-CBzsAsP2.js +1 -0
- package/dist/ui/dist/assets/architecture-WOLXFQ4H-0OSkIwdB.js +1 -0
- package/dist/ui/dist/assets/architectureDiagram-ZHVYKT53-ixHybNqO.js +36 -0
- package/dist/ui/dist/assets/array-BifhSqXX.js +1 -0
- package/dist/ui/dist/assets/blockDiagram-RJAE7QA3-1F8XL_Ou.js +140 -0
- package/dist/ui/dist/assets/c4Diagram-XDPPPE54-D9PFBXnc.js +38 -0
- package/dist/ui/dist/assets/channel-B-fwsHf1.js +1 -0
- package/dist/ui/dist/assets/chunk-2E7WHVMD-zZdNfMkj.js +88 -0
- package/dist/ui/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/ui/dist/assets/chunk-4EMNKR2T-CTKoxSSj.js +1 -0
- package/dist/ui/dist/assets/chunk-55XCU76K-ifFnDlcS.js +1 -0
- package/dist/ui/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/ui/dist/assets/chunk-7S6XSTFO-BCMVdi_1.js +2 -0
- package/dist/ui/dist/assets/chunk-AQDTSKZB-DC-5Whju.js +72 -0
- package/dist/ui/dist/assets/chunk-DSNLSHJZ-DASbe_gb.js +127 -0
- package/dist/ui/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
- package/dist/ui/dist/assets/chunk-HAGFOTED-CArytDjj.js +62 -0
- package/dist/ui/dist/assets/chunk-HNE4Q36P-Dnl-Wtud.js +1 -0
- package/dist/ui/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/dist/ui/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/ui/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
- package/dist/ui/dist/assets/chunk-NQOKRVIF-wvSLaeaK.js +213 -0
- package/dist/ui/dist/assets/chunk-UHW7PFBP-CckaOGiQ.js +1 -0
- package/dist/ui/dist/assets/chunk-VQ5W62QI-kQBMDJ7V.js +1 -0
- package/dist/ui/dist/assets/chunk-WQQL4CDK-CMg6bbLU.js +10 -0
- package/dist/ui/dist/assets/chunk-XXDRQBXY-CYlFr19K.js +1 -0
- package/dist/ui/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/ui/dist/assets/chunk-YZO67HFG-CqkG1vVk.js +1 -0
- package/dist/ui/dist/assets/chunk-ZAUX2LTX-BUNEdx-e.js +1 -0
- package/dist/ui/dist/assets/chunk-ZCHMQSOR-CFTbvcrm.js +2 -0
- package/dist/ui/dist/assets/classDiagram-v2-4MYWR3UA-t2OdJ0LA.js +217 -0
- package/dist/ui/dist/assets/cose-bilkent-JH36ORCC-FGm030gc.js +1 -0
- package/dist/ui/dist/assets/cynefin-EF2NZ3EQ-BtU33Lsu.js +1 -0
- package/dist/ui/dist/assets/cynefinDiagram-WF24UDRP-DUr-I7hS.js +62 -0
- package/dist/ui/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/dist/ui/dist/assets/dagre-NK4GIZVX-BWTHwJKt.js +4 -0
- package/dist/ui/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/ui/dist/assets/diagram-CBK3MWTC-PcHrsEBn.js +24 -0
- package/dist/ui/dist/assets/diagram-I5XIIV2D-CkGtKjYj.js +24 -0
- package/dist/ui/dist/assets/diagram-ODIL2UCW-B_HdBB1w.js +30 -0
- package/dist/ui/dist/assets/diagram-PFBEJUXE-DL9zayE6.js +3 -0
- package/dist/ui/dist/assets/diagram-RJA3I4VV-By5wl8nK.js +41 -0
- package/dist/ui/dist/assets/diagram-TYUNFSNR-ziWPVayL.js +200 -0
- package/dist/ui/dist/assets/dist-NDVqCM7k.js +1 -0
- package/dist/ui/dist/assets/ebnfDiagram-V4P3TTPT-DH4YGkPc.js +1 -0
- package/dist/ui/dist/assets/elk-QONTBCTQ-BL2RlCEv.js +27 -0
- package/dist/ui/dist/assets/erDiagram-KYQIGQAA-BKbTMaRp.js +99 -0
- package/dist/ui/dist/assets/eventmodeling-K75KTNOO-Dk5RCWc-.js +1 -0
- package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-Dkjb3PCK.js +1 -0
- package/dist/ui/dist/assets/ganttDiagram-R7GPNTJY-3vM9gxeT.js +292 -0
- package/dist/ui/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/ui/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/ui/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/ui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/ui/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/ui/dist/assets/gitGraph-VSP46ZUC-CXGFhz54.js +1 -0
- package/dist/ui/dist/assets/gitGraphDiagram-PDYL7XHY-Cl4fwUuA.js +106 -0
- package/dist/ui/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/ui/dist/assets/index-B8TYcxF8.css +1 -0
- package/dist/ui/dist/assets/index-CBbx_pJk.js +134 -0
- package/dist/ui/dist/assets/info-OHQRW6UA-DfSsZ8ib.js +1 -0
- package/dist/ui/dist/assets/infoDiagram-MVDRKAX5-B_7tyrL5.js +2 -0
- package/dist/ui/dist/assets/init-C-OQMol4.js +1 -0
- package/dist/ui/dist/assets/ishikawaDiagram-HPEHV2GE-DUvHljpg.js +70 -0
- package/dist/ui/dist/assets/journeyDiagram-BOSHW7O7-BCgqzrb8.js +139 -0
- package/dist/ui/dist/assets/kanban-definition-FNSA4HC5-DOzZ3NmE.js +89 -0
- package/dist/ui/dist/assets/katex-B_JcIwqj.js +257 -0
- package/dist/ui/dist/assets/line-B-mPFVll.js +1 -0
- package/dist/ui/dist/assets/linear-k7o0jout.js +1 -0
- package/dist/ui/dist/assets/mermaid-parser.core-CAyLSQpH.js +7 -0
- package/dist/ui/dist/assets/mermaid.core-vfVMjgx2.js +44 -0
- package/dist/ui/dist/assets/mindmap-definition-6OY57UG3-B6y-B_r4.js +98 -0
- package/dist/ui/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/ui/dist/assets/packet-JDAUHWVQ-C_r4ed30.js +1 -0
- package/dist/ui/dist/assets/path-fybaL0A-.js +1 -0
- package/dist/ui/dist/assets/pegDiagram-ECIUJNBX-B8VUm3Bh.js +1 -0
- package/dist/ui/dist/assets/pie-XZMESJXO-DcwtbH9y.js +1 -0
- package/dist/ui/dist/assets/pieDiagram-GNMPY74T-CvUNBi2F.js +39 -0
- package/dist/ui/dist/assets/prism-bash-D6zCJ74D.js +1 -0
- package/dist/ui/dist/assets/prism-c-04YixN25.js +1 -0
- package/dist/ui/dist/assets/prism-clike-DapgWyxx.js +1 -0
- package/dist/ui/dist/assets/prism-core-_2ju85Ya.js +1 -0
- package/dist/ui/dist/assets/prism-cpp-C-lJbC-6.js +1 -0
- package/dist/ui/dist/assets/prism-css-CPgOzxXp.js +1 -0
- package/dist/ui/dist/assets/prism-go-C_8qAH5n.js +1 -0
- package/dist/ui/dist/assets/prism-java-6wtusvIl.js +1 -0
- package/dist/ui/dist/assets/prism-javascript-BnO-swvr.js +1 -0
- package/dist/ui/dist/assets/prism-json-Dp_-W-Hv.js +1 -0
- package/dist/ui/dist/assets/prism-jsx-D-D0NWtC.js +1 -0
- package/dist/ui/dist/assets/prism-kotlin-BUlJgC-o.js +1 -0
- package/dist/ui/dist/assets/prism-markdown-Bc9-36OC.js +1 -0
- package/dist/ui/dist/assets/prism-markup-Cp04rIa1.js +1 -0
- package/dist/ui/dist/assets/prism-markup-templating-KkSMBj6Y.js +1 -0
- package/dist/ui/dist/assets/prism-php-Co4fM40K.js +1 -0
- package/dist/ui/dist/assets/prism-python-CfiSVtM6.js +1 -0
- package/dist/ui/dist/assets/prism-ruby-DFCASkuD.js +1 -0
- package/dist/ui/dist/assets/prism-rust-Bu9N8X3N.js +1 -0
- package/dist/ui/dist/assets/prism-sql-CYhVDMEE.js +1 -0
- package/dist/ui/dist/assets/prism-swift-B0kWc-ne.js +1 -0
- package/dist/ui/dist/assets/prism-toml-DKb54GAT.js +1 -0
- package/dist/ui/dist/assets/prism-tsx-p6J2Kc_4.js +1 -0
- package/dist/ui/dist/assets/prism-typescript-BMqONzxf.js +1 -0
- package/dist/ui/dist/assets/prism-yaml-C0JHu8gr.js +1 -0
- package/dist/ui/dist/assets/quadrantDiagram-D57K4MC3-Dg2eVS3j.js +7 -0
- package/dist/ui/dist/assets/radar-ABXABTNO-xCNTcZE9.js +1 -0
- package/dist/ui/dist/assets/railroad-I3PHUGI6-BI-sIL2Q.js +1 -0
- package/dist/ui/dist/assets/railroad-abnf-LNEFI6M7-Ddvk40Wx.js +1 -0
- package/dist/ui/dist/assets/railroad-ebnf-DWYD2UWJ-h35gE183.js +1 -0
- package/dist/ui/dist/assets/railroad-peg-JR7BVNR7-Zim9iPdp.js +1 -0
- package/dist/ui/dist/assets/railroadDiagram-Y72NRBL7-Cz6fdmOQ.js +1 -0
- package/dist/ui/dist/assets/requirementDiagram-EQYSW5RA-DTJkCrcD.js +84 -0
- package/dist/ui/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/ui/dist/assets/sankeyDiagram-SY24JHO5-Bg1iiVla.js +40 -0
- package/dist/ui/dist/assets/sequenceDiagram-BLEHNHQU-9XfetsRA.js +169 -0
- package/dist/ui/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/dist/ui/dist/assets/src-Dym5afQ5.js +1 -0
- package/dist/ui/dist/assets/stateDiagram-v2-A6IESKWZ-CMmcMV8z.js +291 -0
- package/dist/ui/dist/assets/swimlanes-B3ENRKFQ-B2PcshTY.js +1 -0
- package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DfB3XTTn.js +8 -0
- package/dist/ui/dist/assets/timeline-definition-JXYKGLGB-Cr1hCNtW.js +120 -0
- package/dist/ui/dist/assets/treeView-D4JQ5SDB-jH5ksKnh.js +1 -0
- package/dist/ui/dist/assets/treemap-SAJKECNS-BV90e8QL.js +1 -0
- package/dist/ui/dist/assets/usecaseDiagram-WPOLBNNX-BNwq5aJV.js +328 -0
- package/dist/ui/dist/assets/vennDiagram-Q3Y3IDCW-D-lLyZCL.js +34 -0
- package/dist/ui/dist/assets/wardley-7MLQ67FV-CWfHQUGt.js +1 -0
- package/dist/ui/dist/assets/wardleyDiagram-IVAIDKWA-BL_qNKVd.js +78 -0
- package/dist/ui/dist/assets/xychartDiagram-4URNHDU4-BHPK5Snp.js +7 -0
- package/dist/ui/dist/index.html +15 -0
- package/dist/ui/dist/style.css +121 -0
- package/package.json +31 -10
- package/skills/follow-me/SKILL.md +19 -89
- package/dist/src/anthropic.js +0 -133
- package/dist/ui/app.js +0 -2201
- package/dist/ui/concerns.js +0 -165
- package/dist/ui/highlight.js +0 -221
- package/dist/ui/index.html +0 -94
- package/dist/ui/live.js +0 -184
- package/dist/ui/playback.js +0 -579
- package/dist/ui/questions.js +0 -189
- package/dist/ui/style.css +0 -1331
- package/dist/ui/summary.js +0 -45
package/dist/ui/app.js
DELETED
|
@@ -1,2201 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
allConcerns,
|
|
3
|
-
anchorLine,
|
|
4
|
-
blockersToHold,
|
|
5
|
-
bodyIndexes,
|
|
6
|
-
concernsInChunk,
|
|
7
|
-
dwellExtra,
|
|
8
|
-
pickConcern,
|
|
9
|
-
SEVERITIES,
|
|
10
|
-
severityOf,
|
|
11
|
-
toDecide,
|
|
12
|
-
topSeverity,
|
|
13
|
-
undecided,
|
|
14
|
-
undecidedPrompt,
|
|
15
|
-
} from "/concerns.js";
|
|
16
|
-
import { componentsForAll, highlightBody, languageFor } from "/highlight.js";
|
|
17
|
-
import { applyEvent, backoffMs, bannerText, decisionsById, keepCommit, nextSince } from "/live.js";
|
|
18
|
-
import {
|
|
19
|
-
beatAt,
|
|
20
|
-
beatStart,
|
|
21
|
-
buildScript,
|
|
22
|
-
chunkOf,
|
|
23
|
-
commitOf,
|
|
24
|
-
describeLines,
|
|
25
|
-
diagramAt,
|
|
26
|
-
duration,
|
|
27
|
-
hasDiagrams,
|
|
28
|
-
hasNarration,
|
|
29
|
-
introOf,
|
|
30
|
-
lineKey,
|
|
31
|
-
lineNumbers,
|
|
32
|
-
lineStates,
|
|
33
|
-
parseSpeed,
|
|
34
|
-
pickVoice,
|
|
35
|
-
progress,
|
|
36
|
-
rowsOf,
|
|
37
|
-
SPEEDS,
|
|
38
|
-
schedule,
|
|
39
|
-
scopeOf,
|
|
40
|
-
speechRate,
|
|
41
|
-
speechTimeout,
|
|
42
|
-
} from "/playback.js";
|
|
43
|
-
import {
|
|
44
|
-
answerCards,
|
|
45
|
-
answeredCount,
|
|
46
|
-
askAgain,
|
|
47
|
-
cardsAt,
|
|
48
|
-
holdAt,
|
|
49
|
-
inferKind,
|
|
50
|
-
kindCounts,
|
|
51
|
-
statusLine,
|
|
52
|
-
} from "/questions.js";
|
|
53
|
-
import { endSummary, reportPath } from "/summary.js";
|
|
54
|
-
|
|
55
|
-
const $ = (id) => document.getElementById(id);
|
|
56
|
-
|
|
57
|
-
/** "/s/<id>": this page's session on the shared server. Every session API sits under it. */
|
|
58
|
-
const sessionPrefix = /^\/s\/[^/]+/.exec(location.pathname)?.[0] ?? "";
|
|
59
|
-
|
|
60
|
-
/** Builds an element. Text always goes through textContent, never innerHTML. */
|
|
61
|
-
function el(tag, props = {}, ...children) {
|
|
62
|
-
const node = document.createElement(tag);
|
|
63
|
-
for (const [key, value] of Object.entries(props)) {
|
|
64
|
-
if (value === undefined || value === false) continue;
|
|
65
|
-
if (key === "class") node.className = value;
|
|
66
|
-
else if (key === "text") node.textContent = value;
|
|
67
|
-
else if (key.startsWith("on")) node.addEventListener(key.slice(2), value);
|
|
68
|
-
else node.setAttribute(key, value === true ? "" : value);
|
|
69
|
-
}
|
|
70
|
-
for (const child of children) if (child) node.append(child);
|
|
71
|
-
return node;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
let state = null;
|
|
75
|
-
let selected = 0;
|
|
76
|
-
let unitsById = new Map();
|
|
77
|
-
/** unit id -> per hunk line, its { old, new } file line numbers */
|
|
78
|
-
let numbers = new Map();
|
|
79
|
-
/** unit id -> (change index -> index of the commit that owns the line) */
|
|
80
|
-
let owners = new Map();
|
|
81
|
-
/**
|
|
82
|
-
* The lines picked for a comment: a contiguous run of one commit's own lines in one unit, as hunk
|
|
83
|
-
* body indices. from is where a shift-click range starts.
|
|
84
|
-
*/
|
|
85
|
-
let selection = null;
|
|
86
|
-
/** The comment box to focus after the next render. */
|
|
87
|
-
let focusKey = null;
|
|
88
|
-
/** Unsent textarea contents survive re-renders, keyed like the boxes. */
|
|
89
|
-
const drafts = new Map();
|
|
90
|
-
const openBoxes = new Set();
|
|
91
|
-
const editing = new Set();
|
|
92
|
-
/** key -> the kind picked for the box, and whether the reviewer chose it or the text implied it. */
|
|
93
|
-
const kindDrafts = new Map();
|
|
94
|
-
/** Answers shown or paused on, so a replay does not stop again. */
|
|
95
|
-
const seenAnswers = new Set();
|
|
96
|
-
/** Blockers playback already stopped on. */
|
|
97
|
-
const seenBlockers = new Set();
|
|
98
|
-
/** The previous version's questions with where they now sit, refreshed on every render. */
|
|
99
|
-
let cards = [];
|
|
100
|
-
|
|
101
|
-
/** Component scripts already requested, so a language shared by two files loads once. */
|
|
102
|
-
const scripts = new Map();
|
|
103
|
-
|
|
104
|
-
function loadScript(name) {
|
|
105
|
-
if (!scripts.has(name)) {
|
|
106
|
-
scripts.set(
|
|
107
|
-
name,
|
|
108
|
-
new Promise((resolve, reject) => {
|
|
109
|
-
const node = el("script", { src: `/vendor/prism/${name}.js` });
|
|
110
|
-
node.addEventListener("load", resolve);
|
|
111
|
-
node.addEventListener("error", () => reject(new Error(`Prism ${name} did not load`)));
|
|
112
|
-
document.head.append(node);
|
|
113
|
-
}),
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
return scripts.get(name);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Loads Prism and the grammars for the plan's files, and only those. Without a known language
|
|
121
|
-
* nothing is fetched. A failed load leaves the code plain, which is still a complete review.
|
|
122
|
-
*/
|
|
123
|
-
async function loadHighlighting(units) {
|
|
124
|
-
const languages = new Set(
|
|
125
|
-
units.flatMap((u) => (u.kind === "hunk" ? [languageFor(u.path)] : [])).filter(Boolean),
|
|
126
|
-
);
|
|
127
|
-
const names = componentsForAll(languages);
|
|
128
|
-
if (names.length === 0) return;
|
|
129
|
-
try {
|
|
130
|
-
// Prism reads this before it runs, and then waits to be called instead of scanning the page.
|
|
131
|
-
window.Prism ??= { manual: true };
|
|
132
|
-
await loadScript("core");
|
|
133
|
-
for (const name of names) await loadScript(name);
|
|
134
|
-
} catch {
|
|
135
|
-
// Plain code is the fallback.
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** unit id -> its lines as coloured spans, or null when the file stays plain. */
|
|
140
|
-
const highlighted = new Map();
|
|
141
|
-
|
|
142
|
-
function spansOf(unit) {
|
|
143
|
-
if (!highlighted.has(unit.id)) {
|
|
144
|
-
const grammar = window.Prism?.languages?.[languageFor(unit.path)];
|
|
145
|
-
highlighted.set(
|
|
146
|
-
unit.id,
|
|
147
|
-
grammar ? highlightBody(unit.body, (text) => window.Prism.tokenize(text, grammar)) : null,
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
return highlighted.get(unit.id);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const RETRY_MS = 400;
|
|
154
|
-
|
|
155
|
-
/** A write during a plan swap or an end answers 409; it is safe to repeat once a moment later. */
|
|
156
|
-
async function api(method, path, body, retried = false) {
|
|
157
|
-
const res = await fetch(`${sessionPrefix}${path}`, {
|
|
158
|
-
method,
|
|
159
|
-
headers: { "content-type": "application/json" },
|
|
160
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
161
|
-
});
|
|
162
|
-
const payload = await res.json().catch(() => ({}));
|
|
163
|
-
if (res.status === 409 && method !== "GET" && !retried) {
|
|
164
|
-
await new Promise((resolve) => setTimeout(resolve, RETRY_MS));
|
|
165
|
-
return api(method, path, body, true);
|
|
166
|
-
}
|
|
167
|
-
if (!res.ok) throw new Error(payload.error ?? `${method} ${path} failed with ${res.status}`);
|
|
168
|
-
return payload;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** Links to every session the server hosts, so one tab can move between workstreams. */
|
|
172
|
-
async function renderSwitcher() {
|
|
173
|
-
const res = await fetch("/api/sessions").catch(() => null);
|
|
174
|
-
if (!res?.ok) return;
|
|
175
|
-
const sessions = await res.json();
|
|
176
|
-
const here = `${sessionPrefix}/`;
|
|
177
|
-
const nav = $("sessions");
|
|
178
|
-
nav.hidden = sessions.length < 2;
|
|
179
|
-
nav.replaceChildren(
|
|
180
|
-
...sessions.map((s) =>
|
|
181
|
-
el("a", {
|
|
182
|
-
href: s.url,
|
|
183
|
-
title: s.repo,
|
|
184
|
-
"aria-current": s.url === here ? "page" : undefined,
|
|
185
|
-
text: s.mode === "walk" ? `${s.branch} (walk)` : `${s.branch} v${s.version}`,
|
|
186
|
-
}),
|
|
187
|
-
),
|
|
188
|
-
el("a", { href: "/", text: "All sessions" }),
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function report(error) {
|
|
193
|
-
const status = $("status");
|
|
194
|
-
status.textContent = error ? error.message : "";
|
|
195
|
-
status.classList.toggle("error", Boolean(error));
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
async function guarded(action) {
|
|
199
|
-
try {
|
|
200
|
-
report(null);
|
|
201
|
-
await action();
|
|
202
|
-
} catch (error) {
|
|
203
|
-
report(error);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function indexOwnership() {
|
|
208
|
-
unitsById = new Map(state.units.map((unit) => [unit.id, unit]));
|
|
209
|
-
numbers = lineNumbers(state.units);
|
|
210
|
-
owners = new Map();
|
|
211
|
-
for (const commit of state.commits) {
|
|
212
|
-
for (const ref of commit.refs) {
|
|
213
|
-
if (ref.select.kind !== "lines") continue;
|
|
214
|
-
const byChange = owners.get(ref.unit) ?? new Map();
|
|
215
|
-
for (const change of ref.select.changes) byChange.set(change, commit.index);
|
|
216
|
-
owners.set(ref.unit, byChange);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
async function reloadComments() {
|
|
222
|
-
const fresh = await api("GET", "/api/state");
|
|
223
|
-
state.comments = fresh.comments;
|
|
224
|
-
render();
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function sameAnchor(a, b) {
|
|
228
|
-
if (a.kind !== b.kind) return false;
|
|
229
|
-
if (a.kind === "plan") return true;
|
|
230
|
-
if (a.commit !== b.commit) return false;
|
|
231
|
-
return a.kind !== "hunk" || a.unit === b.unit;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function commentsAt(anchor) {
|
|
235
|
-
return state.comments.filter((comment) => sameAnchor(comment.anchor, anchor));
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function firstLine(message) {
|
|
239
|
-
return message.split("\n")[0] ?? "";
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/** Where a hunk comment points, by file line numbers; the stored anchor keeps change indices. */
|
|
243
|
-
function describeAnchor(anchor) {
|
|
244
|
-
const unit = unitsById.get(anchor.unit);
|
|
245
|
-
if (anchor.changes === null || unit?.kind !== "hunk") return "whole hunk";
|
|
246
|
-
return describeLines(unit.body, numbers.get(unit.id) ?? [], anchor.changes);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* A comment textarea with its buttons. Cmd or Ctrl+Enter saves and Escape cancels; neither
|
|
251
|
-
* resumes playback.
|
|
252
|
-
*/
|
|
253
|
-
function commentForm({
|
|
254
|
-
key,
|
|
255
|
-
placeholder,
|
|
256
|
-
action = "Comment",
|
|
257
|
-
initial = "",
|
|
258
|
-
submit,
|
|
259
|
-
close,
|
|
260
|
-
kinds = false,
|
|
261
|
-
}) {
|
|
262
|
-
const choice = kindDrafts.get(key) ?? {
|
|
263
|
-
kind: inferKind(drafts.get(key) ?? ""),
|
|
264
|
-
touched: false,
|
|
265
|
-
severity: "",
|
|
266
|
-
};
|
|
267
|
-
if (kinds) kindDrafts.set(key, choice);
|
|
268
|
-
const toggles = new Map();
|
|
269
|
-
const paint = () => {
|
|
270
|
-
for (const [kind, button] of toggles) {
|
|
271
|
-
button.setAttribute("aria-pressed", String(choice.kind === kind));
|
|
272
|
-
}
|
|
273
|
-
};
|
|
274
|
-
const area = el("textarea", {
|
|
275
|
-
placeholder,
|
|
276
|
-
"aria-label": placeholder,
|
|
277
|
-
oninput: () => {
|
|
278
|
-
drafts.set(key, area.value);
|
|
279
|
-
if (kinds && !choice.touched) {
|
|
280
|
-
choice.kind = inferKind(area.value);
|
|
281
|
-
paint();
|
|
282
|
-
}
|
|
283
|
-
},
|
|
284
|
-
});
|
|
285
|
-
area.value = drafts.get(key) ?? initial;
|
|
286
|
-
const done = () => {
|
|
287
|
-
drafts.delete(key);
|
|
288
|
-
kindDrafts.delete(key);
|
|
289
|
-
};
|
|
290
|
-
const cancel = () => {
|
|
291
|
-
done();
|
|
292
|
-
close();
|
|
293
|
-
render();
|
|
294
|
-
};
|
|
295
|
-
const save = () =>
|
|
296
|
-
guarded(async () => {
|
|
297
|
-
const body = area.value.trim();
|
|
298
|
-
if (!body) return;
|
|
299
|
-
await submit(body, choice.kind, choice.severity);
|
|
300
|
-
done();
|
|
301
|
-
close();
|
|
302
|
-
if (player.view === "play") notice = "Comment saved. Space to continue.";
|
|
303
|
-
await reloadComments();
|
|
304
|
-
});
|
|
305
|
-
area.addEventListener("keydown", (event) => {
|
|
306
|
-
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
|
307
|
-
event.preventDefault();
|
|
308
|
-
save();
|
|
309
|
-
} else if (event.key === "Escape") {
|
|
310
|
-
event.preventDefault();
|
|
311
|
-
cancel();
|
|
312
|
-
}
|
|
313
|
-
});
|
|
314
|
-
const toggle = el("div", { class: "kind", role: "group", "aria-label": "Kind of comment" });
|
|
315
|
-
for (const [kind, text] of [
|
|
316
|
-
["question", "Ask a question"],
|
|
317
|
-
["change", isWalk() ? "Code comment" : "Request a change"],
|
|
318
|
-
]) {
|
|
319
|
-
const button = el("button", {
|
|
320
|
-
type: "button",
|
|
321
|
-
text,
|
|
322
|
-
onclick: () => {
|
|
323
|
-
choice.kind = kind;
|
|
324
|
-
choice.touched = true;
|
|
325
|
-
paint();
|
|
326
|
-
area.focus();
|
|
327
|
-
},
|
|
328
|
-
});
|
|
329
|
-
toggles.set(kind, button);
|
|
330
|
-
toggle.append(button);
|
|
331
|
-
}
|
|
332
|
-
paint();
|
|
333
|
-
const severity = el("select", {
|
|
334
|
-
"aria-label": "Severity, optional",
|
|
335
|
-
onchange: () => {
|
|
336
|
-
choice.severity = severity.value;
|
|
337
|
-
},
|
|
338
|
-
});
|
|
339
|
-
severity.append(el("option", { value: "", text: "No severity" }));
|
|
340
|
-
for (const level of [...SEVERITIES].reverse()) {
|
|
341
|
-
severity.append(el("option", { value: level, text: level }));
|
|
342
|
-
}
|
|
343
|
-
severity.value = choice.severity;
|
|
344
|
-
if (kinds) toggle.append(severity);
|
|
345
|
-
return el(
|
|
346
|
-
"div",
|
|
347
|
-
{ class: "box", "data-box": key },
|
|
348
|
-
kinds ? toggle : null,
|
|
349
|
-
area,
|
|
350
|
-
el(
|
|
351
|
-
"div",
|
|
352
|
-
{ class: "row" },
|
|
353
|
-
el("button", { class: "primary", text: action, onclick: save }),
|
|
354
|
-
el("button", { text: "Cancel", onclick: cancel }),
|
|
355
|
-
el("span", { class: "hint", text: "⌘/Ctrl+Enter to save, Esc to cancel" }),
|
|
356
|
-
),
|
|
357
|
-
);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** Mermaid sources a reply carried, drawn once each, so a re-render only copies the picture. */
|
|
361
|
-
const replyDiagrams = new Map();
|
|
362
|
-
|
|
363
|
-
function fillDiagram(node, drawn) {
|
|
364
|
-
if (drawn.svg !== undefined) {
|
|
365
|
-
// Mermaid sanitizes its own output under securityLevel "strict".
|
|
366
|
-
node.innerHTML = drawn.svg;
|
|
367
|
-
} else {
|
|
368
|
-
node.replaceChildren(el("pre", { class: "diagram-error", text: drawn.error }));
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
async function drawReplyDiagram(source) {
|
|
373
|
-
if (replyDiagrams.has(source)) return;
|
|
374
|
-
replyDiagrams.set(source, null);
|
|
375
|
-
drawing += 1;
|
|
376
|
-
let drawn;
|
|
377
|
-
try {
|
|
378
|
-
const { svg } = await (await loadMermaid()).render(`reply-diagram-${drawing}`, source);
|
|
379
|
-
drawn = { svg };
|
|
380
|
-
} catch (error) {
|
|
381
|
-
drawn = { error: `Mermaid could not draw this diagram: ${error?.message ?? String(error)}` };
|
|
382
|
-
}
|
|
383
|
-
replyDiagrams.set(source, drawn);
|
|
384
|
-
for (const node of document.querySelectorAll(".reply-diagram")) {
|
|
385
|
-
if (node.getAttribute("data-source") === source) fillDiagram(node, drawn);
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
function diagramSlot(source) {
|
|
390
|
-
const node = el("div", { class: "reply-diagram", "data-source": source });
|
|
391
|
-
const drawn = replyDiagrams.get(source);
|
|
392
|
-
if (drawn) fillDiagram(node, drawn);
|
|
393
|
-
else drawReplyDiagram(source);
|
|
394
|
-
return node;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function replyView(reply) {
|
|
398
|
-
return el(
|
|
399
|
-
"div",
|
|
400
|
-
{ class: `reply ${reply.author}` },
|
|
401
|
-
el("div", { class: "meta", text: reply.author === "agent" ? "Agent" : "You" }),
|
|
402
|
-
el("div", { class: "said", text: reply.body }),
|
|
403
|
-
reply.diagram ? diagramSlot(reply.diagram) : null,
|
|
404
|
-
);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function commentLabel(comment) {
|
|
408
|
-
if (comment.kind === "question") return "Question";
|
|
409
|
-
return isWalk() ? "Code comment" : "Change request";
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
function commentView(comment) {
|
|
413
|
-
if (editing.has(comment.id)) {
|
|
414
|
-
return el(
|
|
415
|
-
"div",
|
|
416
|
-
{ class: "comment" },
|
|
417
|
-
commentForm({
|
|
418
|
-
key: `edit:${comment.id}`,
|
|
419
|
-
placeholder: "Edit your comment",
|
|
420
|
-
action: "Save",
|
|
421
|
-
initial: comment.body,
|
|
422
|
-
submit: (body) => api("PUT", `/api/comments/${comment.id}`, { ...comment, body }),
|
|
423
|
-
close: () => editing.delete(comment.id),
|
|
424
|
-
}),
|
|
425
|
-
);
|
|
426
|
-
}
|
|
427
|
-
const question = comment.kind === "question";
|
|
428
|
-
const where = comment.anchor.kind === "hunk" ? ` · ${describeAnchor(comment.anchor)}` : "";
|
|
429
|
-
const again = comment.follows ? " · asked again" : "";
|
|
430
|
-
const rating = comment.severity ? ` · ${comment.severity}` : "";
|
|
431
|
-
const replyKey = `reply:${comment.id}`;
|
|
432
|
-
return el(
|
|
433
|
-
"div",
|
|
434
|
-
{ class: question ? "comment question" : "comment" },
|
|
435
|
-
el(
|
|
436
|
-
"div",
|
|
437
|
-
{ class: "meta" },
|
|
438
|
-
`${commentLabel(comment)} · ${comment.id}${where}${again}${rating}`,
|
|
439
|
-
),
|
|
440
|
-
el("div", { class: "text", text: comment.body }),
|
|
441
|
-
...(comment.replies ?? []).map(replyView),
|
|
442
|
-
openBoxes.has(replyKey)
|
|
443
|
-
? commentForm({
|
|
444
|
-
key: replyKey,
|
|
445
|
-
placeholder: "Reply",
|
|
446
|
-
action: "Reply",
|
|
447
|
-
submit: (body) =>
|
|
448
|
-
api("POST", `/api/comments/${comment.id}/replies`, { author: "reviewer", body }),
|
|
449
|
-
close: () => openBoxes.delete(replyKey),
|
|
450
|
-
})
|
|
451
|
-
: null,
|
|
452
|
-
el(
|
|
453
|
-
"div",
|
|
454
|
-
{ class: "meta" },
|
|
455
|
-
question || comment.replies?.length
|
|
456
|
-
? el("button", {
|
|
457
|
-
class: "link",
|
|
458
|
-
text: "reply",
|
|
459
|
-
onclick: () => {
|
|
460
|
-
openBoxes.add(replyKey);
|
|
461
|
-
focusKey = replyKey;
|
|
462
|
-
render();
|
|
463
|
-
},
|
|
464
|
-
})
|
|
465
|
-
: null,
|
|
466
|
-
el("button", {
|
|
467
|
-
class: "link",
|
|
468
|
-
text: "edit",
|
|
469
|
-
onclick: () => {
|
|
470
|
-
editing.add(comment.id);
|
|
471
|
-
focusKey = `edit:${comment.id}`;
|
|
472
|
-
render();
|
|
473
|
-
},
|
|
474
|
-
}),
|
|
475
|
-
el("button", {
|
|
476
|
-
class: "link",
|
|
477
|
-
text: "delete",
|
|
478
|
-
onclick: () =>
|
|
479
|
-
guarded(async () => {
|
|
480
|
-
await api("DELETE", `/api/comments/${comment.id}`);
|
|
481
|
-
await reloadComments();
|
|
482
|
-
}),
|
|
483
|
-
}),
|
|
484
|
-
),
|
|
485
|
-
);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/** Marks a previous question resolved or reopens it; the server's event repeats it harmlessly. */
|
|
489
|
-
function setResolved(card, resolved) {
|
|
490
|
-
return guarded(async () => {
|
|
491
|
-
await api("POST", "/api/resolved", { comment: card.id, resolved });
|
|
492
|
-
const others = state.resolved.filter((id) => id !== card.id);
|
|
493
|
-
state.resolved = resolved ? [...others, card.id] : others;
|
|
494
|
-
render();
|
|
495
|
-
});
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
function explainedIn(card) {
|
|
499
|
-
const { place } = card;
|
|
500
|
-
if (place === null) return null;
|
|
501
|
-
const path = place.unit === null ? null : unitsById.get(place.unit)?.path;
|
|
502
|
-
return `${path ? `${path} in ` : ""}commit ${place.commit + 1}`;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
/** The previous version's question with the agent's answer, where it now lives. */
|
|
506
|
-
function answerView(card) {
|
|
507
|
-
const { comment, response, place } = card;
|
|
508
|
-
if (card.resolved) {
|
|
509
|
-
return el(
|
|
510
|
-
"div",
|
|
511
|
-
{ class: "answer resolved", "data-answer": card.id },
|
|
512
|
-
el("span", { text: `✓ Resolved · ${firstLine(comment.body)}` }),
|
|
513
|
-
el("button", { class: "link", text: "Reopen", onclick: () => setResolved(card, false) }),
|
|
514
|
-
);
|
|
515
|
-
}
|
|
516
|
-
const againKey = `again:${card.id}`;
|
|
517
|
-
const lines = comment.context.lines ?? [];
|
|
518
|
-
const followUps = state.comments.filter((c) => c.follows === card.id);
|
|
519
|
-
return el(
|
|
520
|
-
"section",
|
|
521
|
-
{ class: "answer", "data-answer": card.id, "aria-label": "Answer to your question" },
|
|
522
|
-
el("div", {
|
|
523
|
-
class: "meta",
|
|
524
|
-
text: `You asked (v${state.previous.version}) · ${statusLine(card)}`,
|
|
525
|
-
}),
|
|
526
|
-
el("div", { class: "said", text: comment.body }),
|
|
527
|
-
lines.length > 0 ? el("pre", { class: "quote", text: lines.join("\n") }) : null,
|
|
528
|
-
response ? el("div", { class: "meta", text: "Answer" }) : null,
|
|
529
|
-
response ? el("div", { class: "said", text: response.note }) : null,
|
|
530
|
-
place === null && comment.anchor.kind === "hunk"
|
|
531
|
-
? el("div", { class: "meta", text: "The code you asked about has changed since." })
|
|
532
|
-
: null,
|
|
533
|
-
place === null ? null : el("div", { class: "meta", text: `Explained in ${explainedIn(card)}` }),
|
|
534
|
-
...(place?.continues ?? []).map((commit) =>
|
|
535
|
-
el("button", {
|
|
536
|
-
class: "link",
|
|
537
|
-
text: `continues in commit ${commit + 1}`,
|
|
538
|
-
onclick: () => select(commit, { focus: true }),
|
|
539
|
-
}),
|
|
540
|
-
),
|
|
541
|
-
...followUps.map(commentView),
|
|
542
|
-
openBoxes.has(againKey)
|
|
543
|
-
? commentForm({
|
|
544
|
-
key: againKey,
|
|
545
|
-
placeholder: "What is still unclear?",
|
|
546
|
-
action: "Ask again",
|
|
547
|
-
submit: (body) => api("POST", "/api/comments", askAgain(card, body)),
|
|
548
|
-
close: () => openBoxes.delete(againKey),
|
|
549
|
-
})
|
|
550
|
-
: el(
|
|
551
|
-
"div",
|
|
552
|
-
{ class: "answer-actions" },
|
|
553
|
-
el("button", {
|
|
554
|
-
class: "primary",
|
|
555
|
-
text: "Resolved",
|
|
556
|
-
onclick: () => setResolved(card, true),
|
|
557
|
-
}),
|
|
558
|
-
el("button", {
|
|
559
|
-
text: "Ask again",
|
|
560
|
-
onclick: () => {
|
|
561
|
-
openBoxes.add(againKey);
|
|
562
|
-
focusKey = againKey;
|
|
563
|
-
render();
|
|
564
|
-
},
|
|
565
|
-
}),
|
|
566
|
-
),
|
|
567
|
-
);
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
/** While playing, an answer waits for its moment; browsing shows every one. */
|
|
571
|
-
function visibleCards(list) {
|
|
572
|
-
return player.view === "play" ? list.filter((c) => seenAnswers.has(c.id) || c.resolved) : list;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
function showAnswer(card) {
|
|
576
|
-
seenAnswers.add(card.id);
|
|
577
|
-
if (card.place !== null) select(card.place.commit);
|
|
578
|
-
else render();
|
|
579
|
-
document.querySelector(`[data-answer="${card.id}"]`)?.scrollIntoView({ block: "center" });
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
/** The sidebar list of the previous version's questions. Answers with no place show in full. */
|
|
583
|
-
function renderAnswers() {
|
|
584
|
-
const panel = $("answers");
|
|
585
|
-
panel.hidden = cards.length === 0;
|
|
586
|
-
if (cards.length === 0) return;
|
|
587
|
-
const open = cards.filter((c) => !c.resolved).length;
|
|
588
|
-
panel.replaceChildren(
|
|
589
|
-
el("strong", { text: `Answers · ${open} to read` }),
|
|
590
|
-
...cards.map((card) =>
|
|
591
|
-
el(
|
|
592
|
-
"div",
|
|
593
|
-
{ class: "answer-entry" },
|
|
594
|
-
el("button", {
|
|
595
|
-
type: "button",
|
|
596
|
-
class: card.resolved ? "answer-row done" : "answer-row",
|
|
597
|
-
text: `${statusLine(card)} · ${firstLine(card.comment.body)}`,
|
|
598
|
-
onclick: () => showAnswer(card),
|
|
599
|
-
}),
|
|
600
|
-
card.place === null && !card.resolved ? answerView(card) : null,
|
|
601
|
-
),
|
|
602
|
-
),
|
|
603
|
-
);
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
/** Severity picked for an undecided concern, applied when it is kept. */
|
|
607
|
-
const pickedSeverity = new Map();
|
|
608
|
-
|
|
609
|
-
/** Records a decision on a concern; the server's event repeats it harmlessly. */
|
|
610
|
-
function decide(concern, decision, extra = {}) {
|
|
611
|
-
return guarded(async () => {
|
|
612
|
-
const severity = extra.severity ?? pickedSeverity.get(concern.id);
|
|
613
|
-
const body = {
|
|
614
|
-
decision,
|
|
615
|
-
...(severity && severity !== concern.severity ? { severity } : {}),
|
|
616
|
-
...(extra.note === undefined ? {} : { note: extra.note }),
|
|
617
|
-
};
|
|
618
|
-
await api("POST", `/api/concerns/${concern.id}`, body);
|
|
619
|
-
state.decisions = { ...state.decisions, [concern.id]: { concern: concern.id, ...body } };
|
|
620
|
-
pickedSeverity.delete(concern.id);
|
|
621
|
-
if (player.view === "play") notice = null;
|
|
622
|
-
render();
|
|
623
|
-
});
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
function severitySelect(current, onchange) {
|
|
627
|
-
const select = el("select", { "aria-label": "Severity" });
|
|
628
|
-
for (const level of SEVERITIES) {
|
|
629
|
-
select.append(el("option", { value: level, text: level }));
|
|
630
|
-
}
|
|
631
|
-
select.value = current;
|
|
632
|
-
select.addEventListener("change", () => onchange(select.value));
|
|
633
|
-
return select;
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
/**
|
|
637
|
-
* The agent's concern as a card in its own voice. It is never spoken. Undecided it offers keep and
|
|
638
|
-
* dismiss; kept it takes the reviewer's own words; dismissed it folds to one row.
|
|
639
|
-
*/
|
|
640
|
-
function concernView(concern, { after } = {}) {
|
|
641
|
-
const decision = state.decisions[concern.id];
|
|
642
|
-
const verdict = decision?.decision ?? "undecided";
|
|
643
|
-
const level = severityOf(concern, state.decisions);
|
|
644
|
-
const attrs = { "data-concern": concern.id, "data-after": after };
|
|
645
|
-
if (verdict === "dismissed") {
|
|
646
|
-
return el(
|
|
647
|
-
"div",
|
|
648
|
-
{ class: "concern dismissed", ...attrs },
|
|
649
|
-
el("span", { text: `Dismissed · ${concern.text}` }),
|
|
650
|
-
el("button", {
|
|
651
|
-
class: "link",
|
|
652
|
-
text: "Undo",
|
|
653
|
-
onclick: () => decide(concern, "kept"),
|
|
654
|
-
}),
|
|
655
|
-
);
|
|
656
|
-
}
|
|
657
|
-
const noteKey = `note:${concern.id}`;
|
|
658
|
-
return el(
|
|
659
|
-
"section",
|
|
660
|
-
{
|
|
661
|
-
class: `concern sev-${level} ${verdict}`,
|
|
662
|
-
"aria-label": `Agent concern, ${level}`,
|
|
663
|
-
...attrs,
|
|
664
|
-
},
|
|
665
|
-
el(
|
|
666
|
-
"div",
|
|
667
|
-
{ class: "concern-head" },
|
|
668
|
-
el("span", { class: "who", text: "Agent" }),
|
|
669
|
-
el("span", { class: "chip", text: level }),
|
|
670
|
-
el("span", { class: "kind-tag", text: concern.kind }),
|
|
671
|
-
verdict === "kept" ? el("span", { class: "verdict", text: "Kept" }) : null,
|
|
672
|
-
),
|
|
673
|
-
el("div", { class: "said", text: concern.text }),
|
|
674
|
-
verdict === "kept" && decision.note
|
|
675
|
-
? el("div", { class: "said mine", text: `You: ${decision.note}` })
|
|
676
|
-
: null,
|
|
677
|
-
verdict === "kept"
|
|
678
|
-
? commentForm({
|
|
679
|
-
key: noteKey,
|
|
680
|
-
placeholder: "Your words (optional)",
|
|
681
|
-
action: "Save note",
|
|
682
|
-
initial: decision.note ?? "",
|
|
683
|
-
submit: (note) => decide(concern, "kept", { note, severity: level }),
|
|
684
|
-
close: () => {},
|
|
685
|
-
})
|
|
686
|
-
: null,
|
|
687
|
-
el(
|
|
688
|
-
"div",
|
|
689
|
-
{ class: "concern-actions" },
|
|
690
|
-
verdict === "undecided"
|
|
691
|
-
? el("button", {
|
|
692
|
-
class: "primary",
|
|
693
|
-
text: "Keep",
|
|
694
|
-
title: "Keep this concern in the report (y)",
|
|
695
|
-
onclick: () => decide(concern, "kept"),
|
|
696
|
-
})
|
|
697
|
-
: null,
|
|
698
|
-
el("button", {
|
|
699
|
-
text: "Dismiss",
|
|
700
|
-
title: "Drop this concern (x)",
|
|
701
|
-
onclick: () => decide(concern, "dismissed"),
|
|
702
|
-
}),
|
|
703
|
-
el(
|
|
704
|
-
"label",
|
|
705
|
-
{ class: "sev-pick" },
|
|
706
|
-
"Severity ",
|
|
707
|
-
severitySelect(level, (next) => {
|
|
708
|
-
if (verdict === "kept") decide(concern, "kept", { severity: next, note: decision.note });
|
|
709
|
-
else {
|
|
710
|
-
pickedSeverity.set(concern.id, next);
|
|
711
|
-
render();
|
|
712
|
-
}
|
|
713
|
-
}),
|
|
714
|
-
),
|
|
715
|
-
),
|
|
716
|
-
);
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
/** The commit's concerns with no unit, shown under its message. */
|
|
720
|
-
function commitConcerns(commit) {
|
|
721
|
-
return (commit.concerns ?? []).filter((c) => c.unit === undefined);
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
/** Concerns on the commit that are still undecided, for the list dot and the stage reminder. */
|
|
725
|
-
function openConcerns() {
|
|
726
|
-
return undecided(allConcerns(state.commits), state.decisions);
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
/** The concern y and x act on: the chunk on screen, else the commit being read. */
|
|
730
|
-
function actionableConcern() {
|
|
731
|
-
const commit = state.commits[selected];
|
|
732
|
-
if (!commit) return null;
|
|
733
|
-
const at = player.view === "play" ? currentBeat() : -1;
|
|
734
|
-
const unit = beats[at]?.kind === "chunk" ? unitsById.get(beats[at].unit) : undefined;
|
|
735
|
-
const chunk =
|
|
736
|
-
unit?.kind === "hunk" ? concernsInChunk(commit.concerns ?? [], beats, at, unit.body) : [];
|
|
737
|
-
return pickConcern(commit.concerns ?? [], chunk, state.decisions);
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
function decideCurrent(decision) {
|
|
741
|
-
const concern = actionableConcern();
|
|
742
|
-
if (concern) decide(concern, decision);
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
/** Only a question is written with a kind; absent means a change request. */
|
|
746
|
-
function kindField(kind) {
|
|
747
|
-
return kind === "question" ? { kind } : {};
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
/** A reviewer's own severity rating, when they gave one. */
|
|
751
|
-
function rated(severity) {
|
|
752
|
-
return severity ? { severity } : {};
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
/** A button that opens into a comment form, focused. */
|
|
756
|
-
function commentBox({ key, label, placeholder, anchor, context }) {
|
|
757
|
-
if (!openBoxes.has(key)) {
|
|
758
|
-
return el("button", {
|
|
759
|
-
text: label,
|
|
760
|
-
onclick: () => {
|
|
761
|
-
openBoxes.add(key);
|
|
762
|
-
focusKey = key;
|
|
763
|
-
render();
|
|
764
|
-
},
|
|
765
|
-
});
|
|
766
|
-
}
|
|
767
|
-
return commentForm({
|
|
768
|
-
key,
|
|
769
|
-
placeholder,
|
|
770
|
-
kinds: true,
|
|
771
|
-
submit: (body, kind, severity) =>
|
|
772
|
-
api("POST", "/api/comments", {
|
|
773
|
-
anchor,
|
|
774
|
-
context,
|
|
775
|
-
body,
|
|
776
|
-
...kindField(kind),
|
|
777
|
-
...rated(severity),
|
|
778
|
-
}),
|
|
779
|
-
close: () => openBoxes.delete(key),
|
|
780
|
-
});
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
function describeFileUnit(unit) {
|
|
784
|
-
const change = unit.before === null ? "added" : unit.after === null ? "deleted" : "changed";
|
|
785
|
-
return `${unit.reason} file ${change}`;
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
/** Body indices of a commit's own change lines in a unit, in hunk order. */
|
|
789
|
-
function ownLines(unit, commitIndex) {
|
|
790
|
-
return rowsOf(unit.body, ownersOf(unit), commitIndex).flatMap((r) =>
|
|
791
|
-
r.kind === "line" && r.mine ? [r.line] : [],
|
|
792
|
-
);
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
/** Picks a line for a comment; shift extends the pick to a range. Pauses playback. */
|
|
796
|
-
function pickLine(unit, commitIndex, line, extend) {
|
|
797
|
-
const own = ownLines(unit, commitIndex);
|
|
798
|
-
const same = extend && selection?.unit === unit.id && selection.commit === commitIndex;
|
|
799
|
-
const from = same ? selection.from : line;
|
|
800
|
-
const [a, b] = [own.indexOf(from), own.indexOf(line)].sort((x, y) => x - y);
|
|
801
|
-
selection = { commit: commitIndex, unit: unit.id, from, lines: own.slice(a, b + 1) };
|
|
802
|
-
focusKey = `line:${commitIndex}:${unit.id}`;
|
|
803
|
-
if (player.playing) pause();
|
|
804
|
-
render();
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
function lineBox(unit, commit) {
|
|
808
|
-
const changes = selection.lines.map((i) => unit.body[i].change);
|
|
809
|
-
return commentForm({
|
|
810
|
-
key: `line:${commit.index}:${unit.id}`,
|
|
811
|
-
placeholder: `Comment on ${describeLines(unit.body, numbers.get(unit.id) ?? [], changes)}`,
|
|
812
|
-
kinds: true,
|
|
813
|
-
submit: (body, kind, severity) =>
|
|
814
|
-
api("POST", "/api/comments", {
|
|
815
|
-
anchor: { kind: "hunk", unit: unit.id, commit: commit.index, changes },
|
|
816
|
-
context: {
|
|
817
|
-
commitMessage: commit.message,
|
|
818
|
-
path: unit.path,
|
|
819
|
-
lines: selection.lines.map((i) => unit.body[i].text.replace(/\n$/, "")),
|
|
820
|
-
},
|
|
821
|
-
body,
|
|
822
|
-
...kindField(kind),
|
|
823
|
-
...rated(severity),
|
|
824
|
-
}),
|
|
825
|
-
close: () => {
|
|
826
|
-
selection = null;
|
|
827
|
-
},
|
|
828
|
-
});
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
/** One line of a hunk. Only the commit's own change lines can be picked for a comment. */
|
|
832
|
-
function diffLine(unit, commit, index, mine) {
|
|
833
|
-
const line = unit.body[index];
|
|
834
|
-
const key = lineKey(unit.id, index);
|
|
835
|
-
const spans = spansOf(unit)?.[index];
|
|
836
|
-
const text = spans
|
|
837
|
-
? el(
|
|
838
|
-
"span",
|
|
839
|
-
{ class: "text" },
|
|
840
|
-
...spans.map((s) =>
|
|
841
|
-
el("span", { class: s.tone ? `tok-${s.tone}` : undefined, text: s.text }),
|
|
842
|
-
),
|
|
843
|
-
)
|
|
844
|
-
: el("span", { class: "text", text: line.text.replace(/\n$/, "") });
|
|
845
|
-
const at = numbers.get(unit.id)?.[index];
|
|
846
|
-
const gutter = el(
|
|
847
|
-
"span",
|
|
848
|
-
{ class: "gutter" },
|
|
849
|
-
el("span", { class: "ln", text: at?.old == null ? "" : String(at.old) }),
|
|
850
|
-
el("span", { class: "ln", text: at?.new == null ? "" : String(at.new) }),
|
|
851
|
-
);
|
|
852
|
-
if (!mine) {
|
|
853
|
-
return el(
|
|
854
|
-
"div",
|
|
855
|
-
{ class: "dl", "data-line": key },
|
|
856
|
-
gutter,
|
|
857
|
-
el("span", { class: "op", text: " " }),
|
|
858
|
-
text,
|
|
859
|
-
);
|
|
860
|
-
}
|
|
861
|
-
gutter.prepend(
|
|
862
|
-
el("button", {
|
|
863
|
-
type: "button",
|
|
864
|
-
class: "add-comment",
|
|
865
|
-
tabindex: "-1",
|
|
866
|
-
"aria-label": "Comment on this line",
|
|
867
|
-
text: "+",
|
|
868
|
-
}),
|
|
869
|
-
);
|
|
870
|
-
const picked =
|
|
871
|
-
selection?.commit === commit.index &&
|
|
872
|
-
selection.unit === unit.id &&
|
|
873
|
-
selection.lines.includes(index);
|
|
874
|
-
const classes = ["dl", line.op === "+" ? "add" : "del", "mine"];
|
|
875
|
-
if (picked) classes.push("picked");
|
|
876
|
-
return el(
|
|
877
|
-
"div",
|
|
878
|
-
{
|
|
879
|
-
class: classes.join(" "),
|
|
880
|
-
"data-line": key,
|
|
881
|
-
onmousedown: (event) => {
|
|
882
|
-
// Shift-click extends the pick; without this it also selects page text.
|
|
883
|
-
if (event.shiftKey) event.preventDefault();
|
|
884
|
-
},
|
|
885
|
-
onclick: (event) => pickLine(unit, commit.index, index, event.shiftKey),
|
|
886
|
-
onkeydown: (event) => lineKeys(event, unit, commit.index, index),
|
|
887
|
-
tabindex: "-1",
|
|
888
|
-
"aria-label": `${line.op === "+" ? "Added" : "Removed"}: ${line.text.trim() || "blank line"}`,
|
|
889
|
-
},
|
|
890
|
-
gutter,
|
|
891
|
-
el("span", { class: "op", text: line.op }),
|
|
892
|
-
text,
|
|
893
|
-
);
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
/**
|
|
897
|
-
* Inside a hunk the commit's own lines are one tab stop: arrows move between them, Space or
|
|
898
|
-
* Enter picks one for a comment and Shift extends the pick.
|
|
899
|
-
*/
|
|
900
|
-
function lineKeys(event, unit, commitIndex, index) {
|
|
901
|
-
const lines = [...event.currentTarget.parentElement.querySelectorAll(".dl.mine:not(.pending)")];
|
|
902
|
-
const at = lines.indexOf(event.currentTarget);
|
|
903
|
-
const move = { ArrowDown: 1, ArrowUp: -1 }[event.key];
|
|
904
|
-
if (move !== undefined) {
|
|
905
|
-
const to = lines[at + move];
|
|
906
|
-
if (to) {
|
|
907
|
-
event.currentTarget.tabIndex = -1;
|
|
908
|
-
to.tabIndex = 0;
|
|
909
|
-
to.focus();
|
|
910
|
-
}
|
|
911
|
-
} else if (event.key === " " || event.key === "Enter") {
|
|
912
|
-
pickLine(unit, commitIndex, index, event.shiftKey);
|
|
913
|
-
} else return;
|
|
914
|
-
event.preventDefault();
|
|
915
|
-
event.stopPropagation();
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
/** Gives each hunk one tab stop, on its first own line. */
|
|
919
|
-
function rovingLines() {
|
|
920
|
-
for (const diff of $("main").querySelectorAll(".diff")) {
|
|
921
|
-
const first = diff.querySelector(".dl.mine:not(.pending)");
|
|
922
|
-
if (first) first.tabIndex = 0;
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
/** Lines a later commit adds, folded into one row that leads to that commit. */
|
|
927
|
-
function foldRow(lines, owner) {
|
|
928
|
-
const n = lines.length;
|
|
929
|
-
return el("button", {
|
|
930
|
-
type: "button",
|
|
931
|
-
class: "fold",
|
|
932
|
-
text: `${n} line${n === 1 ? "" : "s"} arrive${n === 1 ? "s" : ""} in commit ${owner + 1}`,
|
|
933
|
-
onclick: () => select(owner),
|
|
934
|
-
});
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
function ownersOf(unit) {
|
|
938
|
-
const byChange = owners.get(unit.id);
|
|
939
|
-
return Array.from({ length: unit.changeCount }, (_, c) => byChange?.get(c));
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
function unitComments(unit, commit) {
|
|
943
|
-
const anchor = { kind: "hunk", unit: unit.id, commit: commit.index, changes: null };
|
|
944
|
-
return el(
|
|
945
|
-
"div",
|
|
946
|
-
{ class: "comments" },
|
|
947
|
-
...commentsAt(anchor).map(commentView),
|
|
948
|
-
el(
|
|
949
|
-
"div",
|
|
950
|
-
{ class: "controls" },
|
|
951
|
-
commentBox({
|
|
952
|
-
key: `box:${commit.index}:${unit.id}`,
|
|
953
|
-
label: unit.kind === "hunk" ? "Comment on this hunk" : "Comment",
|
|
954
|
-
placeholder: "What about this change?",
|
|
955
|
-
anchor,
|
|
956
|
-
context: { commitMessage: commit.message, path: unit.path },
|
|
957
|
-
}),
|
|
958
|
-
),
|
|
959
|
-
);
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
/** Narration shown in place while browsing; playback moves its own card instead. */
|
|
963
|
-
function staticNarration(text) {
|
|
964
|
-
if (!text || player.view === "play") return null;
|
|
965
|
-
return el("blockquote", { class: "narration" }, text);
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
function unitView(unit, commit) {
|
|
969
|
-
const ref = commit.refs.find((r) => r.unit === unit.id);
|
|
970
|
-
const head = el("div", { class: "unit-head", title: unit.id });
|
|
971
|
-
let content;
|
|
972
|
-
if (unit.kind === "hunk") {
|
|
973
|
-
const rows = rowsOf(unit.body, ownersOf(unit), commit.index);
|
|
974
|
-
const first = rows.find((r) => r.kind === "line" && r.mine);
|
|
975
|
-
const scope = first === undefined ? null : scopeOf(unit.body, first.line);
|
|
976
|
-
const mineCount = rows.filter((r) => r.kind === "line" && r.mine).length;
|
|
977
|
-
head.append(
|
|
978
|
-
el("span", { class: "scope", text: scope === null ? "" : `in ${scope}` }),
|
|
979
|
-
el("span", { class: "spacer" }),
|
|
980
|
-
el("span", {
|
|
981
|
-
text: `${mineCount} of ${unit.changeCount} changed lines in this commit`,
|
|
982
|
-
}),
|
|
983
|
-
);
|
|
984
|
-
const picking = selection?.commit === commit.index && selection.unit === unit.id;
|
|
985
|
-
const boxAfter = picking ? Math.max(...selection.lines) : -1;
|
|
986
|
-
const own = ownLines(unit, commit.index);
|
|
987
|
-
const raised = (commit.concerns ?? []).filter((c) => c.unit === unit.id);
|
|
988
|
-
const under = new Map();
|
|
989
|
-
for (const concern of raised) {
|
|
990
|
-
const at = anchorLine(concern, unit.body, own);
|
|
991
|
-
if (at !== null) under.set(at, [...(under.get(at) ?? []), concern]);
|
|
992
|
-
}
|
|
993
|
-
const flagged = new Map(
|
|
994
|
-
raised.flatMap((c) =>
|
|
995
|
-
bodyIndexes(unit.body, c.lines ?? []).map((i) => [i, severityOf(c, state.decisions)]),
|
|
996
|
-
),
|
|
997
|
-
);
|
|
998
|
-
content = el(
|
|
999
|
-
"div",
|
|
1000
|
-
{ class: "diff" },
|
|
1001
|
-
...rows.flatMap((row) => {
|
|
1002
|
-
if (row.kind === "fold") return [foldRow(row.lines, row.commit)];
|
|
1003
|
-
const line = diffLine(unit, commit, row.line, row.mine);
|
|
1004
|
-
const level = flagged.get(row.line);
|
|
1005
|
-
if (level) line.classList.add("flagged", `flag-${level}`);
|
|
1006
|
-
const cardsUnder = (under.get(row.line) ?? []).map((c) =>
|
|
1007
|
-
concernView(c, { after: lineKey(unit.id, row.line) }),
|
|
1008
|
-
);
|
|
1009
|
-
return [line, ...cardsUnder, ...(row.line === boxAfter ? [lineBox(unit, commit)] : [])];
|
|
1010
|
-
}),
|
|
1011
|
-
);
|
|
1012
|
-
} else {
|
|
1013
|
-
const summary =
|
|
1014
|
-
unit.kind === "file" ? describeFileUnit(unit) : `mode change ${unit.from} → ${unit.to}`;
|
|
1015
|
-
head.append(el("span", { text: summary }));
|
|
1016
|
-
content = el("div", { class: "card", text: `${summary}: ${unit.path}` });
|
|
1017
|
-
}
|
|
1018
|
-
return el(
|
|
1019
|
-
"div",
|
|
1020
|
-
{ class: "unit", "data-unit": unit.id },
|
|
1021
|
-
head,
|
|
1022
|
-
...visibleCards(
|
|
1023
|
-
cardsAt(
|
|
1024
|
-
cards,
|
|
1025
|
-
commit.index,
|
|
1026
|
-
unit.id,
|
|
1027
|
-
commit.refs.map((r) => r.unit),
|
|
1028
|
-
),
|
|
1029
|
-
).map(answerView),
|
|
1030
|
-
staticNarration(ref?.narration?.trim()),
|
|
1031
|
-
content,
|
|
1032
|
-
unitComments(unit, commit),
|
|
1033
|
-
);
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
/** A dot in the commit list while the commit holds concerns nobody has decided. */
|
|
1037
|
-
function concernDot(commit) {
|
|
1038
|
-
const open = undecided(commit.concerns ?? [], state.decisions);
|
|
1039
|
-
const level = topSeverity(open, state.decisions);
|
|
1040
|
-
if (level === null) return null;
|
|
1041
|
-
return el("span", {
|
|
1042
|
-
class: `dot sev-${level}`,
|
|
1043
|
-
role: "img",
|
|
1044
|
-
"aria-label": `${open.length} undecided ${level} concern${open.length === 1 ? "" : "s"}`,
|
|
1045
|
-
title: `${open.length} agent concern${open.length === 1 ? "" : "s"} to decide`,
|
|
1046
|
-
});
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
function renderList() {
|
|
1050
|
-
const list = $("commits");
|
|
1051
|
-
const hadFocus = list.contains(document.activeElement);
|
|
1052
|
-
list.replaceChildren(
|
|
1053
|
-
...state.commits.map((commit, i) => {
|
|
1054
|
-
const { questions, changes } = kindCounts(state.comments, commit.index);
|
|
1055
|
-
const playing = player.view === "play" && i === selected;
|
|
1056
|
-
return el(
|
|
1057
|
-
"li",
|
|
1058
|
-
{ class: i === selected ? "selected" : undefined },
|
|
1059
|
-
el(
|
|
1060
|
-
"button",
|
|
1061
|
-
{
|
|
1062
|
-
type: "button",
|
|
1063
|
-
class: "commit-row",
|
|
1064
|
-
"aria-current": i === selected ? "step" : undefined,
|
|
1065
|
-
onclick: () => {
|
|
1066
|
-
setListOpen(false);
|
|
1067
|
-
select(i, { focus: true });
|
|
1068
|
-
},
|
|
1069
|
-
},
|
|
1070
|
-
el("span", { class: "index", text: String(i + 1) }),
|
|
1071
|
-
el("span", { class: "title", text: firstLine(commit.message) }),
|
|
1072
|
-
concernDot(commit),
|
|
1073
|
-
playing
|
|
1074
|
-
? el("span", { class: "mark", title: "Playing", text: "▶" })
|
|
1075
|
-
: watched.has(i)
|
|
1076
|
-
? el("span", { class: "mark", title: "Watched", text: "✓" })
|
|
1077
|
-
: null,
|
|
1078
|
-
questions > 0
|
|
1079
|
-
? el("span", {
|
|
1080
|
-
class: "badge question",
|
|
1081
|
-
title: `${questions} question${questions === 1 ? "" : "s"}`,
|
|
1082
|
-
text: `${questions} ?`,
|
|
1083
|
-
})
|
|
1084
|
-
: null,
|
|
1085
|
-
changes > 0
|
|
1086
|
-
? el("span", {
|
|
1087
|
-
class: "badge",
|
|
1088
|
-
title: `${changes} comment${changes === 1 ? "" : "s"}`,
|
|
1089
|
-
text: String(changes),
|
|
1090
|
-
})
|
|
1091
|
-
: null,
|
|
1092
|
-
),
|
|
1093
|
-
);
|
|
1094
|
-
}),
|
|
1095
|
-
);
|
|
1096
|
-
list.children[selected]?.scrollIntoView({ block: "nearest" });
|
|
1097
|
-
if (hadFocus) list.children[selected]?.querySelector("button")?.focus({ preventScroll: true });
|
|
1098
|
-
markClamped();
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
/** Only a clamped title needs its full text on hover and focus. */
|
|
1102
|
-
function markClamped() {
|
|
1103
|
-
for (const row of $("commits").querySelectorAll(".commit-row")) {
|
|
1104
|
-
const title = row.querySelector(".title");
|
|
1105
|
-
if (title.scrollHeight > title.clientHeight) row.title = title.textContent;
|
|
1106
|
-
else row.removeAttribute("title");
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
const rail = window.matchMedia("(max-width: 899px)");
|
|
1111
|
-
|
|
1112
|
-
/** In the rail, hovering or focusing a row shows its title beside it. */
|
|
1113
|
-
function showRailTip(event) {
|
|
1114
|
-
const row = event.target.closest?.(".commit-row");
|
|
1115
|
-
const tip = $("rail-tip");
|
|
1116
|
-
if (!rail.matches || $("sidebar").classList.contains("open") || !row) return;
|
|
1117
|
-
const box = row.getBoundingClientRect();
|
|
1118
|
-
tip.textContent = row.querySelector(".title").textContent;
|
|
1119
|
-
tip.style.left = `${box.right + 6}px`;
|
|
1120
|
-
tip.style.top = `${box.top + 4}px`;
|
|
1121
|
-
tip.hidden = false;
|
|
1122
|
-
row.setAttribute("aria-describedby", "rail-tip");
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
function hideRailTip(event) {
|
|
1126
|
-
$("rail-tip").hidden = true;
|
|
1127
|
-
event.target.closest?.(".commit-row")?.removeAttribute("aria-describedby");
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
function setListOpen(open) {
|
|
1131
|
-
$("sidebar").classList.toggle("open", open);
|
|
1132
|
-
$("commits-toggle").setAttribute("aria-expanded", String(open));
|
|
1133
|
-
$("rail-tip").hidden = true;
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
function renderPlanNote() {
|
|
1137
|
-
const anchor = { kind: "plan" };
|
|
1138
|
-
const walk = isWalk();
|
|
1139
|
-
$("plan-note").replaceChildren(
|
|
1140
|
-
el("strong", { text: walk ? "General comment" : "Plan note" }),
|
|
1141
|
-
...commentsAt(anchor).map(commentView),
|
|
1142
|
-
commentBox({
|
|
1143
|
-
key: "box:plan",
|
|
1144
|
-
label: walk ? "Add a general comment" : "Add a note on the whole plan",
|
|
1145
|
-
placeholder: walk
|
|
1146
|
-
? "Anything about the change as a whole"
|
|
1147
|
-
: "Anything about the plan as a whole",
|
|
1148
|
-
anchor,
|
|
1149
|
-
context: { commitMessage: "", path: null },
|
|
1150
|
-
}),
|
|
1151
|
-
);
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
function renderMain() {
|
|
1155
|
-
const main = $("main");
|
|
1156
|
-
const scroll = main.scrollTop;
|
|
1157
|
-
const commit = state.commits[selected];
|
|
1158
|
-
if (!commit) {
|
|
1159
|
-
main.replaceChildren(el("p", { class: "muted", text: "This plan has no commits." }));
|
|
1160
|
-
return;
|
|
1161
|
-
}
|
|
1162
|
-
const messageAnchor = { kind: "message", commit: commit.index };
|
|
1163
|
-
const positionAnchor = { kind: "position", commit: commit.index };
|
|
1164
|
-
const context = { commitMessage: commit.message, path: null };
|
|
1165
|
-
|
|
1166
|
-
const groups = new Map();
|
|
1167
|
-
for (const ref of commit.refs) {
|
|
1168
|
-
const unit = unitsById.get(ref.unit);
|
|
1169
|
-
if (!unit) continue;
|
|
1170
|
-
groups.set(unit.path, [...(groups.get(unit.path) ?? []), unit]);
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
main.replaceChildren(
|
|
1174
|
-
stage,
|
|
1175
|
-
el(
|
|
1176
|
-
"div",
|
|
1177
|
-
{ class: "commit-head" },
|
|
1178
|
-
el("div", { class: "message", text: commit.message }),
|
|
1179
|
-
staticNarration(commit.narration?.trim()),
|
|
1180
|
-
...commentsAt(messageAnchor).map(commentView),
|
|
1181
|
-
...visibleCards(
|
|
1182
|
-
cardsAt(
|
|
1183
|
-
cards,
|
|
1184
|
-
commit.index,
|
|
1185
|
-
null,
|
|
1186
|
-
commit.refs.map((r) => r.unit),
|
|
1187
|
-
),
|
|
1188
|
-
).map(answerView),
|
|
1189
|
-
...commitConcerns(commit).map((c) => concernView(c)),
|
|
1190
|
-
// A walk plan's messages and order are the planner's, not the author's, so only code is
|
|
1191
|
-
// up for comment there.
|
|
1192
|
-
isWalk()
|
|
1193
|
-
? null
|
|
1194
|
-
: el(
|
|
1195
|
-
"div",
|
|
1196
|
-
{ class: "controls" },
|
|
1197
|
-
commentBox({
|
|
1198
|
-
key: `box:message:${commit.index}`,
|
|
1199
|
-
label: "Comment on the message",
|
|
1200
|
-
placeholder: "What should this message say?",
|
|
1201
|
-
anchor: messageAnchor,
|
|
1202
|
-
context,
|
|
1203
|
-
}),
|
|
1204
|
-
commentBox({
|
|
1205
|
-
key: `box:position:${commit.index}`,
|
|
1206
|
-
label: "This belongs elsewhere",
|
|
1207
|
-
placeholder: "Where should this go, and why?",
|
|
1208
|
-
anchor: positionAnchor,
|
|
1209
|
-
context,
|
|
1210
|
-
}),
|
|
1211
|
-
),
|
|
1212
|
-
...commentsAt(positionAnchor).map(commentView),
|
|
1213
|
-
),
|
|
1214
|
-
...[...groups].map(([path, units]) =>
|
|
1215
|
-
el(
|
|
1216
|
-
"section",
|
|
1217
|
-
{ class: "file-group" },
|
|
1218
|
-
el("h3", { text: path }),
|
|
1219
|
-
...units.map((unit) => unitView(unit, commit)),
|
|
1220
|
-
),
|
|
1221
|
-
),
|
|
1222
|
-
);
|
|
1223
|
-
main.scrollTop = scroll;
|
|
1224
|
-
applyPlayback();
|
|
1225
|
-
rovingLines();
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
/** The textarea being typed in, so a re-render caused by someone else's event keeps the caret. */
|
|
1229
|
-
function typingPlace() {
|
|
1230
|
-
const area = document.activeElement;
|
|
1231
|
-
if (!(area instanceof HTMLTextAreaElement)) return null;
|
|
1232
|
-
const key = area.closest("[data-box]")?.getAttribute("data-box");
|
|
1233
|
-
return key ? { key, start: area.selectionStart, end: area.selectionEnd } : null;
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
function render() {
|
|
1237
|
-
const typing = typingPlace();
|
|
1238
|
-
cards = answerCards(state.previous, state.resolved, state.commits);
|
|
1239
|
-
renderList();
|
|
1240
|
-
renderAnswers();
|
|
1241
|
-
renderPlanNote();
|
|
1242
|
-
renderMain();
|
|
1243
|
-
if (focusKey !== null) {
|
|
1244
|
-
document.querySelector(`[data-box="${focusKey}"] textarea`)?.focus();
|
|
1245
|
-
focusKey = null;
|
|
1246
|
-
} else if (typing !== null) {
|
|
1247
|
-
const area = document.querySelector(`[data-box="${typing.key}"] textarea`);
|
|
1248
|
-
if (area instanceof HTMLTextAreaElement) {
|
|
1249
|
-
area.focus();
|
|
1250
|
-
area.setSelectionRange(typing.start, typing.end);
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
/** Moves to a commit. A move the reviewer asked for takes focus to the commit heading. */
|
|
1256
|
-
function select(index, { focus = false } = {}) {
|
|
1257
|
-
const next = Math.max(0, Math.min(state.commits.length - 1, index));
|
|
1258
|
-
if (player.view === "play") seek(beatStart(steps, introOf(beats, next)));
|
|
1259
|
-
else {
|
|
1260
|
-
selected = next;
|
|
1261
|
-
render();
|
|
1262
|
-
$("main").scrollTop = 0;
|
|
1263
|
-
}
|
|
1264
|
-
if (focus) focusHeading();
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
function focusHeading() {
|
|
1268
|
-
stage.querySelector("h2")?.focus({ preventScroll: true });
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
function isWalk() {
|
|
1272
|
-
return state?.mode === "walk";
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
const synth = "speechSynthesis" in window ? window.speechSynthesis : null;
|
|
1276
|
-
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
1277
|
-
/**
|
|
1278
|
-
* view "play" shows the plan beat by beat; "browse" shows every line. position indexes the step
|
|
1279
|
-
* on screen, steps.length being the end. generation retires a running loop on every move.
|
|
1280
|
-
*/
|
|
1281
|
-
const player = {
|
|
1282
|
-
view: "browse",
|
|
1283
|
-
playing: false,
|
|
1284
|
-
position: 0,
|
|
1285
|
-
generation: 0,
|
|
1286
|
-
speed: 1,
|
|
1287
|
-
muted: false,
|
|
1288
|
-
voice: null,
|
|
1289
|
-
};
|
|
1290
|
-
let beats = [];
|
|
1291
|
-
/** Commits whose outro playback reached. */
|
|
1292
|
-
const watched = new Set();
|
|
1293
|
-
/** When p last restarted a commit, so a second press soon after goes one further back. */
|
|
1294
|
-
let restartedAt = 0;
|
|
1295
|
-
/** Shown in the narration card until the next move, such as after a comment is saved. */
|
|
1296
|
-
let notice = null;
|
|
1297
|
-
/** Whether playback has run at all, so the ready card shows only before the first Play. */
|
|
1298
|
-
let started = false;
|
|
1299
|
-
let steps = [];
|
|
1300
|
-
let narrated = true;
|
|
1301
|
-
let voices = [];
|
|
1302
|
-
/** The one narration card. It moves to whatever the reviewer is looking at. */
|
|
1303
|
-
const card = el("blockquote", { class: "narration", "aria-live": "polite" });
|
|
1304
|
-
/** Sticky header of the code pane: where the reviewer is. */
|
|
1305
|
-
const stage = el("div", { class: "stage" });
|
|
1306
|
-
|
|
1307
|
-
function stored(key) {
|
|
1308
|
-
try {
|
|
1309
|
-
return localStorage.getItem(`follow-me.${key}`);
|
|
1310
|
-
} catch {
|
|
1311
|
-
return null;
|
|
1312
|
-
}
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
function store(key, value) {
|
|
1316
|
-
try {
|
|
1317
|
-
localStorage.setItem(`follow-me.${key}`, value);
|
|
1318
|
-
} catch {
|
|
1319
|
-
// Private windows may refuse storage; the setting then lasts for this page only.
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
function silent() {
|
|
1324
|
-
return player.muted || synth === null || !narrated;
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
/** Resolves true after `ms` of playing time, or false once a move retires this loop. */
|
|
1328
|
-
function sleep(ms, generation) {
|
|
1329
|
-
return new Promise((resolve) => {
|
|
1330
|
-
let left = ms;
|
|
1331
|
-
let last = performance.now();
|
|
1332
|
-
const tick = () => {
|
|
1333
|
-
if (generation !== player.generation) return resolve(false);
|
|
1334
|
-
const now = performance.now();
|
|
1335
|
-
if (player.playing) left -= now - last;
|
|
1336
|
-
last = now;
|
|
1337
|
-
if (left <= 0 && player.playing) return resolve(true);
|
|
1338
|
-
setTimeout(tick, Math.max(16, Math.min(left, 100)));
|
|
1339
|
-
};
|
|
1340
|
-
tick();
|
|
1341
|
-
});
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
/**
|
|
1345
|
-
* Speaks one utterance, cancelling whatever was speaking, so voice and picture never drift.
|
|
1346
|
-
* Resolves when it ends, or after a bound for engines that never report the end.
|
|
1347
|
-
*/
|
|
1348
|
-
function speak(text, generation) {
|
|
1349
|
-
synth.cancel();
|
|
1350
|
-
const utterance = new SpeechSynthesisUtterance(text);
|
|
1351
|
-
utterance.rate = speechRate(player.speed);
|
|
1352
|
-
const voice = voices.find((v) => v.name === player.voice);
|
|
1353
|
-
if (voice) {
|
|
1354
|
-
utterance.voice = voice;
|
|
1355
|
-
utterance.lang = voice.lang;
|
|
1356
|
-
}
|
|
1357
|
-
const ended = new Promise((resolve) => {
|
|
1358
|
-
utterance.addEventListener("end", () => resolve(true));
|
|
1359
|
-
utterance.addEventListener("error", () => resolve(true));
|
|
1360
|
-
});
|
|
1361
|
-
synth.speak(utterance);
|
|
1362
|
-
return Promise.race([ended, sleep(speechTimeout(text, player.speed), generation)]);
|
|
1363
|
-
}
|
|
1364
|
-
|
|
1365
|
-
function scrollMain(top) {
|
|
1366
|
-
$("main").scrollTo({
|
|
1367
|
-
top: Math.max(0, top),
|
|
1368
|
-
behavior: reducedMotion.matches ? "auto" : "smooth",
|
|
1369
|
-
});
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
/** Brings a node to 30% from the top of the code pane. */
|
|
1373
|
-
function scrollToChunk(node) {
|
|
1374
|
-
const main = $("main");
|
|
1375
|
-
const offset = node.getBoundingClientRect().top - main.getBoundingClientRect().top;
|
|
1376
|
-
scrollMain(main.scrollTop + offset - main.clientHeight * 0.3);
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
/** What watching costs, shown before the first Play. */
|
|
1380
|
-
function readyText() {
|
|
1381
|
-
const paths = new Set(state.units.map((u) => u.path));
|
|
1382
|
-
const minutes = Math.max(1, Math.round(duration(steps) / 60_000));
|
|
1383
|
-
const what = `${state.commits.length} commits · ${paths.size} files · about ${minutes} min at ${player.speed}x`;
|
|
1384
|
-
return `${what}. Press Play or Space to watch the branch being written.`;
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
|
-
function setCard(text, kind) {
|
|
1388
|
-
card.textContent = text;
|
|
1389
|
-
card.className = `narration ${kind}`;
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
/** Where the card and the reader's eye go for a beat: its first line, else its unit. */
|
|
1393
|
-
function anchorOf(beat) {
|
|
1394
|
-
const main = $("main");
|
|
1395
|
-
if (beat.kind !== "chunk") return main.querySelector(".commit-head");
|
|
1396
|
-
const first = beat.lines[0];
|
|
1397
|
-
const line =
|
|
1398
|
-
first === undefined
|
|
1399
|
-
? null
|
|
1400
|
-
: (main.querySelector(`[data-ph="${lineKey(beat.unit, first)}"]`) ??
|
|
1401
|
-
main.querySelector(`[data-line="${lineKey(beat.unit, first)}"]`));
|
|
1402
|
-
return line ?? main.querySelector(`[data-unit="${beat.unit}"] .card`);
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
function chunkText(beat) {
|
|
1406
|
-
return beat.narration ?? beat.label;
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
/** Renders the position: which lines exist yet, the placeholders, the card and the countdown. */
|
|
1410
|
-
function renderStage() {
|
|
1411
|
-
const commit = state.commits[selected];
|
|
1412
|
-
if (!commit) return;
|
|
1413
|
-
const beat = beatAt(steps, player.position);
|
|
1414
|
-
const here = player.view === "play" ? beats[beat] : undefined;
|
|
1415
|
-
const chunk = here === undefined ? null : chunkOf(beats, beat);
|
|
1416
|
-
const path = here?.kind === "chunk" ? unitsById.get(here.unit)?.path : undefined;
|
|
1417
|
-
const parts = [
|
|
1418
|
-
firstLine(commit.message),
|
|
1419
|
-
chunk === null ? null : `chunk ${chunk.index} of ${chunk.count}`,
|
|
1420
|
-
path ?? null,
|
|
1421
|
-
].filter((p) => p !== null);
|
|
1422
|
-
stage.replaceChildren(
|
|
1423
|
-
el(
|
|
1424
|
-
"h2",
|
|
1425
|
-
{ tabindex: "-1" },
|
|
1426
|
-
el("span", { text: `Commit ${selected + 1} of ${state.commits.length}` }),
|
|
1427
|
-
...parts.map((p) => el("span", { class: "part", text: ` · ${p}` })),
|
|
1428
|
-
toDecide(openConcerns().length) === null
|
|
1429
|
-
? null
|
|
1430
|
-
: el("span", { class: "part to-decide", text: ` · ${toDecide(openConcerns().length)}` }),
|
|
1431
|
-
),
|
|
1432
|
-
);
|
|
1433
|
-
const strip = $("strip");
|
|
1434
|
-
strip.replaceChildren(
|
|
1435
|
-
...progress(beats, player.view === "play" ? beat : 0).map((seg) =>
|
|
1436
|
-
el(
|
|
1437
|
-
"button",
|
|
1438
|
-
{
|
|
1439
|
-
type: "button",
|
|
1440
|
-
class: seg.commit === selected ? "seg here" : "seg",
|
|
1441
|
-
style: `flex-grow: ${seg.weight}`,
|
|
1442
|
-
title: `Commit ${seg.commit + 1}: ${firstLine(state.commits[seg.commit]?.message ?? "")}`,
|
|
1443
|
-
"aria-label": `Go to commit ${seg.commit + 1}`,
|
|
1444
|
-
onclick: () => seek(beatStart(steps, introOf(beats, seg.commit))),
|
|
1445
|
-
},
|
|
1446
|
-
el("span", { class: "fill", style: `width: ${Math.round(seg.filled * 100)}%` }),
|
|
1447
|
-
),
|
|
1448
|
-
),
|
|
1449
|
-
);
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
/** Mermaid, imported on first use, so a plan without diagrams never downloads it. */
|
|
1453
|
-
let mermaid = null;
|
|
1454
|
-
let drawn = null;
|
|
1455
|
-
let drawing = 0;
|
|
1456
|
-
|
|
1457
|
-
async function loadMermaid() {
|
|
1458
|
-
if (mermaid === null) {
|
|
1459
|
-
const { default: loaded } = await import("/vendor/mermaid.mjs");
|
|
1460
|
-
loaded.initialize({
|
|
1461
|
-
startOnLoad: false,
|
|
1462
|
-
securityLevel: "strict",
|
|
1463
|
-
suppressErrorRendering: true,
|
|
1464
|
-
theme: window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "default",
|
|
1465
|
-
});
|
|
1466
|
-
mermaid = loaded;
|
|
1467
|
-
}
|
|
1468
|
-
return mermaid;
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1471
|
-
/** Draws the diagram for what is on screen into the side panel, or hides the panel. */
|
|
1472
|
-
async function renderDiagram() {
|
|
1473
|
-
const panel = $("diagram");
|
|
1474
|
-
const beat = player.view === "play" ? beats[beatAt(steps, player.position)] : undefined;
|
|
1475
|
-
const shown = diagramAt(state.commits[selected], beat);
|
|
1476
|
-
panel.hidden = shown === null;
|
|
1477
|
-
if (shown === null || shown.source === drawn) return;
|
|
1478
|
-
drawn = shown.source;
|
|
1479
|
-
const unit = shown.unit === null ? null : unitsById.get(shown.unit);
|
|
1480
|
-
$("diagram-from").textContent = unit ? unit.path : "this commit";
|
|
1481
|
-
const body = $("diagram-body");
|
|
1482
|
-
drawing += 1;
|
|
1483
|
-
const id = `diagram-${drawing}`;
|
|
1484
|
-
try {
|
|
1485
|
-
const { svg } = await (await loadMermaid()).render(id, shown.source);
|
|
1486
|
-
if (drawn !== shown.source) return;
|
|
1487
|
-
// Mermaid sanitizes its own output under securityLevel "strict".
|
|
1488
|
-
body.innerHTML = svg;
|
|
1489
|
-
} catch (error) {
|
|
1490
|
-
if (drawn !== shown.source) return;
|
|
1491
|
-
body.replaceChildren(
|
|
1492
|
-
el("p", { class: "diagram-error", text: "Mermaid could not draw this diagram:" }),
|
|
1493
|
-
el("pre", { class: "diagram-error", text: error?.message ?? String(error) }),
|
|
1494
|
-
);
|
|
1495
|
-
}
|
|
1496
|
-
}
|
|
1497
|
-
|
|
1498
|
-
function applyPlayback() {
|
|
1499
|
-
const main = $("main");
|
|
1500
|
-
if (hasDiagrams(state.commits)) renderDiagram();
|
|
1501
|
-
for (const node of main.querySelectorAll(".ph, .countdown")) node.remove();
|
|
1502
|
-
card.remove();
|
|
1503
|
-
renderStage();
|
|
1504
|
-
if (player.view !== "play") {
|
|
1505
|
-
for (const node of main.querySelectorAll("[data-line], .unit")) {
|
|
1506
|
-
node.classList.remove("pending", "struck", "in-chunk");
|
|
1507
|
-
}
|
|
1508
|
-
for (const node of main.querySelectorAll(".concern")) node.classList.remove("pending");
|
|
1509
|
-
return;
|
|
1510
|
-
}
|
|
1511
|
-
const { states, upcoming, current } = lineStates(beats, steps, player.position, selected);
|
|
1512
|
-
const chunk = current === null ? null : beats[current];
|
|
1513
|
-
const inChunk = new Set(chunk?.lines.map((line) => lineKey(chunk.unit, line)) ?? []);
|
|
1514
|
-
for (const node of main.querySelectorAll("[data-line]")) {
|
|
1515
|
-
const key = node.dataset.line;
|
|
1516
|
-
const look = states.get(key);
|
|
1517
|
-
node.classList.toggle("pending", look === "hidden");
|
|
1518
|
-
node.classList.toggle("struck", look === "struck");
|
|
1519
|
-
node.classList.toggle("in-chunk", inChunk.has(key) && look !== "hidden");
|
|
1520
|
-
}
|
|
1521
|
-
for (const node of main.querySelectorAll(".concern[data-after]")) {
|
|
1522
|
-
node.classList.toggle("pending", states.get(node.dataset.after) === "hidden");
|
|
1523
|
-
}
|
|
1524
|
-
for (const node of main.querySelectorAll(".unit")) {
|
|
1525
|
-
node.classList.toggle(
|
|
1526
|
-
"in-chunk",
|
|
1527
|
-
chunk?.unit === node.dataset.unit && chunk.lines.length === 0,
|
|
1528
|
-
);
|
|
1529
|
-
}
|
|
1530
|
-
for (const [key, n] of upcoming) {
|
|
1531
|
-
const line = main.querySelector(`[data-line="${key}"]`);
|
|
1532
|
-
const ph = el("div", {
|
|
1533
|
-
class: inChunk.has(key) ? "ph in-chunk" : "ph",
|
|
1534
|
-
"data-ph": key,
|
|
1535
|
-
text: `${n} line${n === 1 ? "" : "s"} coming${inChunk.has(key) ? "" : " next"}`,
|
|
1536
|
-
});
|
|
1537
|
-
line?.before(ph);
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
|
-
const step = steps[player.position];
|
|
1541
|
-
const beat = beats[beatAt(steps, player.position)];
|
|
1542
|
-
if (notice !== null) {
|
|
1543
|
-
setCard(notice, "notice");
|
|
1544
|
-
(beat?.kind === "chunk" ? anchorOf(beat) : null)?.before(card);
|
|
1545
|
-
if (!card.isConnected) main.querySelector(".commit-head .message")?.after(card);
|
|
1546
|
-
return;
|
|
1547
|
-
}
|
|
1548
|
-
if (step === undefined || beat === undefined) {
|
|
1549
|
-
setCard(
|
|
1550
|
-
isWalk()
|
|
1551
|
-
? "That is the whole branch. Leave any last comments, then finish the code review."
|
|
1552
|
-
: "That is the whole plan.",
|
|
1553
|
-
"end",
|
|
1554
|
-
);
|
|
1555
|
-
main.querySelector(".commit-head")?.append(card);
|
|
1556
|
-
return;
|
|
1557
|
-
}
|
|
1558
|
-
if (beat.kind === "intro" && player.position === 0 && !player.playing && !started) {
|
|
1559
|
-
setCard(readyText(), "label");
|
|
1560
|
-
main.querySelector(".commit-head .message")?.after(card);
|
|
1561
|
-
return;
|
|
1562
|
-
}
|
|
1563
|
-
if (beat.kind === "intro") {
|
|
1564
|
-
const note = narrated ? null : "This plan has no narration. Showing commit messages.";
|
|
1565
|
-
if (beat.narration !== null || (note !== null && beat.commit === 0)) {
|
|
1566
|
-
setCard(beat.narration ?? note, "say");
|
|
1567
|
-
main.querySelector(".commit-head .message")?.after(card);
|
|
1568
|
-
}
|
|
1569
|
-
} else if (beat.kind === "chunk") {
|
|
1570
|
-
setCard(chunkText(beat), beat.narration === null ? "label" : "say");
|
|
1571
|
-
anchorOf(beat)?.before(card);
|
|
1572
|
-
} else {
|
|
1573
|
-
const countdown = el(
|
|
1574
|
-
"div",
|
|
1575
|
-
{ class: "countdown" },
|
|
1576
|
-
el("span", { text: beat.next === null ? "Last commit" : `Next: ${beat.next}` }),
|
|
1577
|
-
el("span", { class: "bar", style: `animation-duration: ${step.ms}ms` }),
|
|
1578
|
-
);
|
|
1579
|
-
countdown.classList.toggle("paused", !player.playing);
|
|
1580
|
-
stage.append(countdown);
|
|
1581
|
-
watched.add(beat.commit);
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
1584
|
-
|
|
1585
|
-
/** Shows the step at the current position: its commit, its lines, and where the eye goes. */
|
|
1586
|
-
function present() {
|
|
1587
|
-
const commit = commitOf(beats, beatAt(steps, player.position));
|
|
1588
|
-
const beat = beats[beatAt(steps, player.position)];
|
|
1589
|
-
if (commit !== selected) {
|
|
1590
|
-
selected = commit;
|
|
1591
|
-
render();
|
|
1592
|
-
$("main").scrollTop = 0;
|
|
1593
|
-
} else {
|
|
1594
|
-
applyPlayback();
|
|
1595
|
-
renderList();
|
|
1596
|
-
}
|
|
1597
|
-
if (beat?.kind === "chunk") {
|
|
1598
|
-
const anchor = card.isConnected ? card : anchorOf(beat);
|
|
1599
|
-
if (anchor) scrollToChunk(anchor);
|
|
1600
|
-
} else if (beat?.kind === "intro") scrollMain(0);
|
|
1601
|
-
updatePlayer();
|
|
1602
|
-
}
|
|
1603
|
-
|
|
1604
|
-
/** Waits out one step. A say step lasts its reading time or until speech ends, whichever is later. */
|
|
1605
|
-
async function perform(step, generation) {
|
|
1606
|
-
if (step.kind === "show") {
|
|
1607
|
-
const extra = dwellExtra(chunkConcerns(step.beat), state.decisions, player.speed);
|
|
1608
|
-
return sleep(step.ms + extra, generation);
|
|
1609
|
-
}
|
|
1610
|
-
if (step.kind !== "say" || silent()) return sleep(step.ms, generation);
|
|
1611
|
-
const [read, heard] = await Promise.all([
|
|
1612
|
-
sleep(step.ms, generation),
|
|
1613
|
-
speak(step.text, generation).then((ok) => ok && sleep(step.tail, generation)),
|
|
1614
|
-
]);
|
|
1615
|
-
return read && heard && generation === player.generation;
|
|
1616
|
-
}
|
|
1617
|
-
|
|
1618
|
-
/** The concerns that belong to a chunk beat. */
|
|
1619
|
-
function chunkConcerns(beat) {
|
|
1620
|
-
const unit = unitsById.get(beats[beat]?.unit);
|
|
1621
|
-
const commit = state.commits[beats[beat]?.commit];
|
|
1622
|
-
if (unit?.kind !== "hunk" || !commit) return [];
|
|
1623
|
-
return concernsInChunk(commit.concerns ?? [], beats, beat, unit.body);
|
|
1624
|
-
}
|
|
1625
|
-
|
|
1626
|
-
/** A blocker stops playback as soon as the lines it is about are on screen. */
|
|
1627
|
-
function holdForConcern() {
|
|
1628
|
-
const step = steps[player.position];
|
|
1629
|
-
if (step === undefined) return;
|
|
1630
|
-
const held = blockersToHold(chunkConcerns(step.beat), step, state.decisions, seenBlockers);
|
|
1631
|
-
if (held.length === 0) return;
|
|
1632
|
-
for (const concern of held) seenBlockers.add(concern.id);
|
|
1633
|
-
notice = "The agent flagged a blocker here. Keep or dismiss it, then Space to continue.";
|
|
1634
|
-
pause();
|
|
1635
|
-
render();
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
|
-
/** Stops on an answer to the reviewer's own question, once the narration has been heard. */
|
|
1639
|
-
function holdForAnswer() {
|
|
1640
|
-
const held = holdAt(beats, steps, player.position, cards, seenAnswers);
|
|
1641
|
-
if (held.length === 0) return;
|
|
1642
|
-
for (const card of held) seenAnswers.add(card.id);
|
|
1643
|
-
notice =
|
|
1644
|
-
held.length === 1
|
|
1645
|
-
? "Your question is answered here. Space to continue."
|
|
1646
|
-
: `${held.length} of your questions are answered here. Space to continue.`;
|
|
1647
|
-
pause();
|
|
1648
|
-
render();
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
async function playLoop(generation) {
|
|
1652
|
-
while (player.position < steps.length) {
|
|
1653
|
-
if (!(await sleep(0, generation))) return;
|
|
1654
|
-
if (!(await perform(steps[player.position], generation))) return;
|
|
1655
|
-
player.position += 1;
|
|
1656
|
-
present();
|
|
1657
|
-
holdForAnswer();
|
|
1658
|
-
holdForConcern();
|
|
1659
|
-
}
|
|
1660
|
-
player.playing = false;
|
|
1661
|
-
renderList();
|
|
1662
|
-
updatePlayer();
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
/** Moves to a step and starts a fresh loop, which waits while paused. */
|
|
1666
|
-
function seek(position) {
|
|
1667
|
-
synth?.cancel();
|
|
1668
|
-
notice = null;
|
|
1669
|
-
player.position = Math.max(0, Math.min(steps.length, position));
|
|
1670
|
-
player.generation += 1;
|
|
1671
|
-
present();
|
|
1672
|
-
playLoop(player.generation);
|
|
1673
|
-
}
|
|
1674
|
-
|
|
1675
|
-
function play() {
|
|
1676
|
-
if (player.view !== "play") enterPlay();
|
|
1677
|
-
if (player.position >= steps.length) seek(0);
|
|
1678
|
-
notice = null;
|
|
1679
|
-
started = true;
|
|
1680
|
-
player.playing = true;
|
|
1681
|
-
applyPlayback();
|
|
1682
|
-
synth?.resume();
|
|
1683
|
-
updatePlayer();
|
|
1684
|
-
}
|
|
1685
|
-
|
|
1686
|
-
function pause() {
|
|
1687
|
-
player.playing = false;
|
|
1688
|
-
synth?.pause();
|
|
1689
|
-
updatePlayer();
|
|
1690
|
-
}
|
|
1691
|
-
|
|
1692
|
-
function enterPlay() {
|
|
1693
|
-
player.view = "play";
|
|
1694
|
-
$("player").hidden = false;
|
|
1695
|
-
render();
|
|
1696
|
-
seek(beatStart(steps, introOf(beats, selected)));
|
|
1697
|
-
}
|
|
1698
|
-
|
|
1699
|
-
function enterBrowse() {
|
|
1700
|
-
player.view = "browse";
|
|
1701
|
-
player.playing = false;
|
|
1702
|
-
player.generation += 1;
|
|
1703
|
-
synth?.cancel();
|
|
1704
|
-
$("player").hidden = true;
|
|
1705
|
-
render();
|
|
1706
|
-
updatePlayer();
|
|
1707
|
-
}
|
|
1708
|
-
|
|
1709
|
-
function currentBeat() {
|
|
1710
|
-
return beatAt(steps, player.position);
|
|
1711
|
-
}
|
|
1712
|
-
|
|
1713
|
-
/** Seeks to a beat; the beat starts over, so its narration replays. */
|
|
1714
|
-
function toBeat(beat) {
|
|
1715
|
-
seek(beatStart(steps, Math.max(0, Math.min(beats.length, beat))));
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
|
-
function stepBeat(delta) {
|
|
1719
|
-
if (player.view !== "play") enterPlay();
|
|
1720
|
-
toBeat(currentBeat() + delta);
|
|
1721
|
-
}
|
|
1722
|
-
|
|
1723
|
-
function nextCommit() {
|
|
1724
|
-
if (player.view !== "play") return select(selected + 1, { focus: true });
|
|
1725
|
-
toBeat(introOf(beats, selected + 1));
|
|
1726
|
-
focusHeading();
|
|
1727
|
-
}
|
|
1728
|
-
|
|
1729
|
-
/** Restarts the commit; a second press within 1.5s goes to the previous one. */
|
|
1730
|
-
function previousCommit() {
|
|
1731
|
-
if (player.view !== "play") return select(selected - 1, { focus: true });
|
|
1732
|
-
focusHeading();
|
|
1733
|
-
const now = performance.now();
|
|
1734
|
-
const intro = introOf(beats, selected);
|
|
1735
|
-
const again = now - restartedAt < 1500 || currentBeat() === intro;
|
|
1736
|
-
restartedAt = now;
|
|
1737
|
-
toBeat(again ? introOf(beats, selected - 1) : intro);
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
/** Pauses and opens a comment of a kind on the chunk on screen, its lines preselected. */
|
|
1741
|
-
function commentOnChunk(kind) {
|
|
1742
|
-
if (player.view !== "play") return;
|
|
1743
|
-
const at = currentBeat();
|
|
1744
|
-
const beat = beats[at];
|
|
1745
|
-
if (beat?.kind !== "chunk") {
|
|
1746
|
-
report(new Error("Step to a chunk to comment on it."));
|
|
1747
|
-
return;
|
|
1748
|
-
}
|
|
1749
|
-
pause();
|
|
1750
|
-
const show = steps.findIndex((s) => s.kind === "show" && s.beat === at);
|
|
1751
|
-
if (player.position < show) seek(show);
|
|
1752
|
-
if (beat.lines.length === 0) {
|
|
1753
|
-
const key = `box:${beat.commit}:${beat.unit}`;
|
|
1754
|
-
openBoxes.add(key);
|
|
1755
|
-
focusKey = key;
|
|
1756
|
-
} else {
|
|
1757
|
-
selection = { commit: beat.commit, unit: beat.unit, from: beat.lines[0], lines: beat.lines };
|
|
1758
|
-
focusKey = `line:${beat.commit}:${beat.unit}`;
|
|
1759
|
-
}
|
|
1760
|
-
kindDrafts.set(focusKey, { kind, touched: true, severity: "" });
|
|
1761
|
-
render();
|
|
1762
|
-
}
|
|
1763
|
-
|
|
1764
|
-
function toggleKeys() {
|
|
1765
|
-
const sheet = $("keys");
|
|
1766
|
-
if (sheet.open) sheet.close();
|
|
1767
|
-
else sheet.showModal();
|
|
1768
|
-
}
|
|
1769
|
-
|
|
1770
|
-
function setSpeed(speed) {
|
|
1771
|
-
player.speed = speed;
|
|
1772
|
-
steps = schedule(beats, speed);
|
|
1773
|
-
store("speed", String(speed));
|
|
1774
|
-
}
|
|
1775
|
-
|
|
1776
|
-
function updatePlayer() {
|
|
1777
|
-
$("main").querySelector(".countdown")?.classList.toggle("paused", !player.playing);
|
|
1778
|
-
const play = $("play");
|
|
1779
|
-
play.querySelector(".icon").textContent = player.playing ? "❚❚" : "▶";
|
|
1780
|
-
play.querySelector(".label").textContent = player.playing ? "Pause" : "Play";
|
|
1781
|
-
play.setAttribute("aria-pressed", String(player.playing));
|
|
1782
|
-
$("view-toggle").textContent = player.view === "play" ? "Show all" : "Play";
|
|
1783
|
-
const mute = $("mute");
|
|
1784
|
-
mute.disabled = synth === null;
|
|
1785
|
-
mute.textContent = synth === null ? "No speech" : player.muted ? "Unmute" : "Mute";
|
|
1786
|
-
mute.setAttribute("aria-pressed", String(player.muted));
|
|
1787
|
-
}
|
|
1788
|
-
|
|
1789
|
-
function setDiagramOpen(open) {
|
|
1790
|
-
$("diagram-toggle").setAttribute("aria-expanded", String(open));
|
|
1791
|
-
$("diagram").classList.toggle("collapsed", !open);
|
|
1792
|
-
}
|
|
1793
|
-
|
|
1794
|
-
function toggleMute() {
|
|
1795
|
-
if (!narrated || synth === null) return;
|
|
1796
|
-
player.muted = !player.muted;
|
|
1797
|
-
store("muted", String(player.muted));
|
|
1798
|
-
if (player.muted) synth.cancel();
|
|
1799
|
-
updatePlayer();
|
|
1800
|
-
}
|
|
1801
|
-
|
|
1802
|
-
function loadVoices() {
|
|
1803
|
-
if (synth === null) return;
|
|
1804
|
-
voices = synth.getVoices();
|
|
1805
|
-
if (!voices.some((v) => v.name === player.voice)) {
|
|
1806
|
-
player.voice = pickVoice(voices, stored("voice"), navigator.language);
|
|
1807
|
-
}
|
|
1808
|
-
const picker = $("voice");
|
|
1809
|
-
picker.replaceChildren(
|
|
1810
|
-
el("option", { value: "", text: "Browser default" }),
|
|
1811
|
-
...voices.map((v) => el("option", { value: v.name, text: `${v.name} (${v.lang})` })),
|
|
1812
|
-
);
|
|
1813
|
-
picker.value = player.voice ?? "";
|
|
1814
|
-
}
|
|
1815
|
-
|
|
1816
|
-
/** The beats and steps of the plan on screen; again whenever a new plan version replaces it. */
|
|
1817
|
-
function buildPlayback() {
|
|
1818
|
-
beats = buildScript(state.commits, state.units);
|
|
1819
|
-
narrated = hasNarration(state.commits);
|
|
1820
|
-
steps = schedule(beats, player.speed);
|
|
1821
|
-
for (const id of ["voice-label", "mute"]) $(id).hidden = !narrated;
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
function setUpPlayer() {
|
|
1825
|
-
player.speed = parseSpeed(stored("speed"));
|
|
1826
|
-
buildPlayback();
|
|
1827
|
-
player.muted = stored("muted") === "true";
|
|
1828
|
-
const speed = $("speed");
|
|
1829
|
-
speed.replaceChildren(...SPEEDS.map((s) => el("option", { value: String(s), text: `${s}x` })));
|
|
1830
|
-
speed.value = String(player.speed);
|
|
1831
|
-
speed.addEventListener("change", () => setSpeed(parseSpeed(speed.value)));
|
|
1832
|
-
const picker = $("voice");
|
|
1833
|
-
picker.disabled = synth === null;
|
|
1834
|
-
picker.addEventListener("change", () => {
|
|
1835
|
-
player.voice = picker.value || null;
|
|
1836
|
-
store("voice", picker.value);
|
|
1837
|
-
});
|
|
1838
|
-
synth?.addEventListener("voiceschanged", loadVoices);
|
|
1839
|
-
loadVoices();
|
|
1840
|
-
$("play").addEventListener("click", () => (player.playing ? pause() : play()));
|
|
1841
|
-
$("prev-commit").addEventListener("click", previousCommit);
|
|
1842
|
-
$("next-commit").addEventListener("click", nextCommit);
|
|
1843
|
-
$("prev-beat").addEventListener("click", () => stepBeat(-1));
|
|
1844
|
-
$("next-beat").addEventListener("click", () => stepBeat(1));
|
|
1845
|
-
$("keys-button").addEventListener("click", toggleKeys);
|
|
1846
|
-
$("mute").addEventListener("click", toggleMute);
|
|
1847
|
-
$("diagram-toggle").addEventListener("click", () => {
|
|
1848
|
-
const open = $("diagram-toggle").getAttribute("aria-expanded") !== "true";
|
|
1849
|
-
setDiagramOpen(open);
|
|
1850
|
-
store("diagram", String(open));
|
|
1851
|
-
});
|
|
1852
|
-
setDiagramOpen(stored("diagram") !== "false");
|
|
1853
|
-
$("view-toggle").addEventListener("click", () => {
|
|
1854
|
-
if (player.view === "play") enterBrowse();
|
|
1855
|
-
else play();
|
|
1856
|
-
// Space should now drive playback, not this button.
|
|
1857
|
-
focusHeading();
|
|
1858
|
-
});
|
|
1859
|
-
updatePlayer();
|
|
1860
|
-
}
|
|
1861
|
-
|
|
1862
|
-
function hasUnsentText() {
|
|
1863
|
-
return [...drafts.values()].some((text) => text.trim() !== "");
|
|
1864
|
-
}
|
|
1865
|
-
|
|
1866
|
-
/** What the review holds, in words: comments, questions, kept concerns, unread answers. */
|
|
1867
|
-
function commentSummary() {
|
|
1868
|
-
const kept = allConcerns(state.commits).filter(
|
|
1869
|
-
(c) => state.decisions[c.id]?.decision === "kept",
|
|
1870
|
-
).length;
|
|
1871
|
-
const unresolved = cards.filter((c) => c.status === "answered" && !c.resolved).length;
|
|
1872
|
-
return endSummary({ comments: state.comments, kept, unresolved, walk: isWalk() });
|
|
1873
|
-
}
|
|
1874
|
-
|
|
1875
|
-
/** The consolidated report as markdown, or null from a server that has none. */
|
|
1876
|
-
async function fetchReport() {
|
|
1877
|
-
const res = await fetch(`${sessionPrefix}/api/report`).catch(() => null);
|
|
1878
|
-
return res?.ok ? res.text() : null;
|
|
1879
|
-
}
|
|
1880
|
-
|
|
1881
|
-
function copyButton(label, text) {
|
|
1882
|
-
const button = el("button", {
|
|
1883
|
-
type: "button",
|
|
1884
|
-
text: label,
|
|
1885
|
-
onclick: async () => {
|
|
1886
|
-
await navigator.clipboard?.writeText(text).catch(() => {});
|
|
1887
|
-
button.textContent = "Copied";
|
|
1888
|
-
},
|
|
1889
|
-
});
|
|
1890
|
-
return button;
|
|
1891
|
-
}
|
|
1892
|
-
|
|
1893
|
-
/**
|
|
1894
|
-
* Asks before ending, with what the review holds. Resolves "end", or with agent concerns still
|
|
1895
|
-
* undecided "keep-all", "dismiss-all" or "back".
|
|
1896
|
-
*/
|
|
1897
|
-
function confirmEnd() {
|
|
1898
|
-
const sheet = $("confirm-end");
|
|
1899
|
-
const unsent = hasUnsentText() ? " Some comment boxes have text you have not sent." : "";
|
|
1900
|
-
const open = openConcerns().length;
|
|
1901
|
-
const text = `${commentSummary()}.${unsent}`;
|
|
1902
|
-
$("confirm-text").textContent = open === 0 ? text : `${undecidedPrompt(open)} ${text}`;
|
|
1903
|
-
$("confirm-ok").textContent = $("end").textContent;
|
|
1904
|
-
$("confirm-ok").hidden = open > 0;
|
|
1905
|
-
$("confirm-keep-all").hidden = open === 0;
|
|
1906
|
-
$("confirm-dismiss-all").hidden = open === 0;
|
|
1907
|
-
$("confirm-back").textContent = open === 0 ? "Keep reviewing" : "Go back";
|
|
1908
|
-
sheet.returnValue = "";
|
|
1909
|
-
sheet.showModal();
|
|
1910
|
-
return new Promise((resolve) => {
|
|
1911
|
-
sheet.addEventListener("close", () => resolve(sheet.returnValue || "back"), { once: true });
|
|
1912
|
-
});
|
|
1913
|
-
}
|
|
1914
|
-
|
|
1915
|
-
async function endReview() {
|
|
1916
|
-
if (player.playing) pause();
|
|
1917
|
-
const answer = await confirmEnd();
|
|
1918
|
-
if (answer === "back" || answer === "keep") return;
|
|
1919
|
-
if (answer === "keep-all" || answer === "dismiss-all") {
|
|
1920
|
-
const decision = answer === "keep-all" ? "kept" : "dismissed";
|
|
1921
|
-
for (const concern of openConcerns()) {
|
|
1922
|
-
await api("POST", `/api/concerns/${concern.id}`, { decision });
|
|
1923
|
-
state.decisions = { ...state.decisions, [concern.id]: { concern: concern.id, decision } };
|
|
1924
|
-
}
|
|
1925
|
-
}
|
|
1926
|
-
// The session is gone once it ends, so the report is read first. Nothing changes in between.
|
|
1927
|
-
const markdown = await fetchReport();
|
|
1928
|
-
const summary = commentSummary();
|
|
1929
|
-
ending = true;
|
|
1930
|
-
const { saved } = await api("POST", "/api/end").catch((error) => {
|
|
1931
|
-
ending = false;
|
|
1932
|
-
throw error;
|
|
1933
|
-
});
|
|
1934
|
-
player.generation += 1;
|
|
1935
|
-
synth?.cancel();
|
|
1936
|
-
const at = saved.indexOf(".follow-me/");
|
|
1937
|
-
const shortPath = (path) => (at === -1 ? path : path.slice(at));
|
|
1938
|
-
const command = "follow-me feedback";
|
|
1939
|
-
$("done").replaceChildren(
|
|
1940
|
-
el(
|
|
1941
|
-
"section",
|
|
1942
|
-
{
|
|
1943
|
-
class: markdown === null ? "done-card" : "done-card wide",
|
|
1944
|
-
"aria-labelledby": "done-title",
|
|
1945
|
-
},
|
|
1946
|
-
el("h1", { id: "done-title", text: isWalk() ? "Code review saved" : "Review saved" }),
|
|
1947
|
-
el("p", { class: "muted", text: `${summary}.` }),
|
|
1948
|
-
el("p", {}, el("code", { class: "path", text: at === -1 ? saved : saved.slice(at) })),
|
|
1949
|
-
markdown === null
|
|
1950
|
-
? null
|
|
1951
|
-
: el(
|
|
1952
|
-
"section",
|
|
1953
|
-
{ class: "report-box", "aria-label": "Report" },
|
|
1954
|
-
el(
|
|
1955
|
-
"div",
|
|
1956
|
-
{ class: "report-head" },
|
|
1957
|
-
el("strong", { text: "Report" }),
|
|
1958
|
-
el("code", { text: shortPath(reportPath(saved)) }),
|
|
1959
|
-
copyButton("Copy markdown", markdown),
|
|
1960
|
-
),
|
|
1961
|
-
el("pre", { class: "report", tabindex: "0", text: markdown }),
|
|
1962
|
-
),
|
|
1963
|
-
el(
|
|
1964
|
-
"p",
|
|
1965
|
-
{ class: "next" },
|
|
1966
|
-
isWalk() ? "The author reads it with " : "Next, run ",
|
|
1967
|
-
el("code", { text: command }),
|
|
1968
|
-
copyButton("Copy", command),
|
|
1969
|
-
),
|
|
1970
|
-
isWalk() ? null : el("p", { class: "muted", text: "or hand the session to your agent." }),
|
|
1971
|
-
el("p", {}, el("a", { href: "/", text: "All sessions" })),
|
|
1972
|
-
),
|
|
1973
|
-
);
|
|
1974
|
-
$("done").hidden = false;
|
|
1975
|
-
$("done-title").setAttribute("tabindex", "-1");
|
|
1976
|
-
$("done-title").focus();
|
|
1977
|
-
}
|
|
1978
|
-
|
|
1979
|
-
/** Playback keys. Shift+arrows move by commit, plain arrows by chunk. */
|
|
1980
|
-
const KEYS = {
|
|
1981
|
-
ArrowRight: (shift) => (shift ? nextCommit() : stepBeat(1)),
|
|
1982
|
-
ArrowLeft: (shift) => (shift ? previousCommit() : stepBeat(-1)),
|
|
1983
|
-
l: () => stepBeat(1),
|
|
1984
|
-
h: () => stepBeat(-1),
|
|
1985
|
-
n: nextCommit,
|
|
1986
|
-
j: nextCommit,
|
|
1987
|
-
p: previousCommit,
|
|
1988
|
-
k: previousCommit,
|
|
1989
|
-
r: () => player.view === "play" && toBeat(currentBeat()),
|
|
1990
|
-
c: () => commentOnChunk("change"),
|
|
1991
|
-
q: () => commentOnChunk("question"),
|
|
1992
|
-
y: () => decideCurrent("kept"),
|
|
1993
|
-
x: () => decideCurrent("dismissed"),
|
|
1994
|
-
m: toggleMute,
|
|
1995
|
-
"?": toggleKeys,
|
|
1996
|
-
};
|
|
1997
|
-
|
|
1998
|
-
document.addEventListener("keydown", (event) => {
|
|
1999
|
-
const target = event.target;
|
|
2000
|
-
if (
|
|
2001
|
-
target instanceof HTMLTextAreaElement ||
|
|
2002
|
-
target instanceof HTMLInputElement ||
|
|
2003
|
-
target instanceof HTMLSelectElement ||
|
|
2004
|
-
$("keys").open
|
|
2005
|
-
) {
|
|
2006
|
-
return;
|
|
2007
|
-
}
|
|
2008
|
-
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
2009
|
-
// A focused button already answers space by clicking itself.
|
|
2010
|
-
if (event.key === " " && !(target instanceof HTMLButtonElement)) {
|
|
2011
|
-
event.preventDefault();
|
|
2012
|
-
if (player.playing) pause();
|
|
2013
|
-
else play();
|
|
2014
|
-
return;
|
|
2015
|
-
}
|
|
2016
|
-
const action = Object.hasOwn(KEYS, event.key) ? KEYS[event.key] : undefined;
|
|
2017
|
-
if (action === undefined) return;
|
|
2018
|
-
event.preventDefault();
|
|
2019
|
-
action(event.shiftKey);
|
|
2020
|
-
});
|
|
2021
|
-
|
|
2022
|
-
$("end").addEventListener("click", () => guarded(endReview));
|
|
2023
|
-
$("commits-toggle").addEventListener("click", () =>
|
|
2024
|
-
setListOpen(!$("sidebar").classList.contains("open")),
|
|
2025
|
-
);
|
|
2026
|
-
$("sidebar").addEventListener("keydown", (event) => {
|
|
2027
|
-
if (event.key === "Escape" && $("sidebar").classList.contains("open")) {
|
|
2028
|
-
setListOpen(false);
|
|
2029
|
-
$("commits-toggle").focus();
|
|
2030
|
-
}
|
|
2031
|
-
});
|
|
2032
|
-
window.addEventListener("resize", markClamped);
|
|
2033
|
-
for (const type of ["mouseover", "focusin"]) $("commits").addEventListener(type, showRailTip);
|
|
2034
|
-
for (const type of ["mouseout", "focusout"]) $("commits").addEventListener(type, hideRailTip);
|
|
2035
|
-
window.addEventListener("focus", () => renderSwitcher());
|
|
2036
|
-
renderSwitcher();
|
|
2037
|
-
|
|
2038
|
-
function renderHeader() {
|
|
2039
|
-
const walk = isWalk();
|
|
2040
|
-
document.title = walk ? "follow-me code review" : "follow-me review";
|
|
2041
|
-
$("summary").textContent = walk
|
|
2042
|
-
? `${state.anchor.branch} · Code review`
|
|
2043
|
-
: `${state.anchor.branch} · Review of plan v${state.version}`;
|
|
2044
|
-
$("end").textContent = walk ? "Finish code review" : "End review";
|
|
2045
|
-
}
|
|
2046
|
-
|
|
2047
|
-
/** Makes a fetched state the page's own: indexes, highlighting and header. */
|
|
2048
|
-
async function adopt(fresh) {
|
|
2049
|
-
state = {
|
|
2050
|
-
...fresh,
|
|
2051
|
-
decisions: decisionsById(fresh.concerns ?? []),
|
|
2052
|
-
resolved: fresh.resolved ?? [],
|
|
2053
|
-
previous: fresh.previous ?? null,
|
|
2054
|
-
};
|
|
2055
|
-
indexOwnership();
|
|
2056
|
-
highlighted.clear();
|
|
2057
|
-
await loadHighlighting(state.units);
|
|
2058
|
-
renderHeader();
|
|
2059
|
-
}
|
|
2060
|
-
|
|
2061
|
-
/** Says a plan arrived, and offers the answers it brings when it has any. */
|
|
2062
|
-
function announceVersion() {
|
|
2063
|
-
const answered = answeredCount(state.previous);
|
|
2064
|
-
$("banner-text").textContent = bannerText(state.version, answered);
|
|
2065
|
-
$("banner-show").hidden = answered === 0;
|
|
2066
|
-
$("banner").hidden = false;
|
|
2067
|
-
}
|
|
2068
|
-
|
|
2069
|
-
/** A newer plan arrived: swap it in place and stay on the commit being read when it survives. */
|
|
2070
|
-
async function swapVersion() {
|
|
2071
|
-
const fresh = await api("GET", "/api/state");
|
|
2072
|
-
if (fresh.version <= state.version) return;
|
|
2073
|
-
const title = state.commits[selected] ? firstLine(state.commits[selected].message) : null;
|
|
2074
|
-
const was = selected;
|
|
2075
|
-
await adopt(fresh);
|
|
2076
|
-
buildPlayback();
|
|
2077
|
-
selected = keepCommit(title, was, state.commits);
|
|
2078
|
-
watched.clear();
|
|
2079
|
-
announceVersion();
|
|
2080
|
-
if (player.view === "play") seek(beatStart(steps, introOf(beats, selected)));
|
|
2081
|
-
else render();
|
|
2082
|
-
}
|
|
2083
|
-
|
|
2084
|
-
let ending = false;
|
|
2085
|
-
|
|
2086
|
-
function showEnded() {
|
|
2087
|
-
if (ending || !$("done").hidden) return;
|
|
2088
|
-
player.generation += 1;
|
|
2089
|
-
synth?.cancel();
|
|
2090
|
-
$("done").replaceChildren(
|
|
2091
|
-
el(
|
|
2092
|
-
"section",
|
|
2093
|
-
{ class: "done-card", "aria-labelledby": "done-title" },
|
|
2094
|
-
el("h1", { id: "done-title", text: "Review ended" }),
|
|
2095
|
-
el("p", { class: "muted", text: "This review was ended in another window. It is saved." }),
|
|
2096
|
-
el("p", {}, el("a", { href: "/", text: "All sessions" })),
|
|
2097
|
-
),
|
|
2098
|
-
);
|
|
2099
|
-
$("done").hidden = false;
|
|
2100
|
-
}
|
|
2101
|
-
|
|
2102
|
-
async function receive(event) {
|
|
2103
|
-
const { state: next, effect } = applyEvent(state, event);
|
|
2104
|
-
state = next;
|
|
2105
|
-
if (effect === "refetch") await swapVersion();
|
|
2106
|
-
else if (effect === "ended") showEnded();
|
|
2107
|
-
else render();
|
|
2108
|
-
}
|
|
2109
|
-
|
|
2110
|
-
const sleep_ = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2111
|
-
|
|
2112
|
-
/** The page's whole state again, after the server's log no longer matches what we followed. */
|
|
2113
|
-
async function resync() {
|
|
2114
|
-
const fresh = await api("GET", "/api/state");
|
|
2115
|
-
const title = state.commits[selected] ? firstLine(state.commits[selected].message) : null;
|
|
2116
|
-
const was = selected;
|
|
2117
|
-
await adopt(fresh);
|
|
2118
|
-
buildPlayback();
|
|
2119
|
-
selected = keepCommit(title, was, state.commits);
|
|
2120
|
-
render();
|
|
2121
|
-
return fresh.seq ?? 0;
|
|
2122
|
-
}
|
|
2123
|
-
|
|
2124
|
-
/** Reads the push stream until it fails twice in a row, resolving with the last seq seen. */
|
|
2125
|
-
function followStream(since, onEvent) {
|
|
2126
|
-
return new Promise((resolve) => {
|
|
2127
|
-
let last = since;
|
|
2128
|
-
let failures = 0;
|
|
2129
|
-
const source = new EventSource(`${sessionPrefix}/api/events/stream?since=${since}`);
|
|
2130
|
-
const stop = () => {
|
|
2131
|
-
source.close();
|
|
2132
|
-
resolve(last);
|
|
2133
|
-
};
|
|
2134
|
-
source.addEventListener("message", async (message) => {
|
|
2135
|
-
failures = 0;
|
|
2136
|
-
try {
|
|
2137
|
-
const event = JSON.parse(message.data);
|
|
2138
|
-
last = Math.max(last, event.seq ?? 0);
|
|
2139
|
-
await onEvent(event);
|
|
2140
|
-
} catch {
|
|
2141
|
-
// A malformed event is skipped; the next one still applies.
|
|
2142
|
-
}
|
|
2143
|
-
if (!$("done").hidden) stop();
|
|
2144
|
-
});
|
|
2145
|
-
source.addEventListener("error", () => {
|
|
2146
|
-
failures += 1;
|
|
2147
|
-
if (failures >= 2 || source.readyState === EventSource.CLOSED) stop();
|
|
2148
|
-
});
|
|
2149
|
-
});
|
|
2150
|
-
}
|
|
2151
|
-
|
|
2152
|
-
/** Long-polls for what others changed, reconnecting with backoff after an error. */
|
|
2153
|
-
async function pollEvents(from) {
|
|
2154
|
-
let since = from;
|
|
2155
|
-
let failures = 0;
|
|
2156
|
-
while ($("done").hidden) {
|
|
2157
|
-
try {
|
|
2158
|
-
const res = await fetch(`${sessionPrefix}/api/events?since=${since}&timeout=25000`);
|
|
2159
|
-
// A server without live updates answers 404; the page then works as it always did.
|
|
2160
|
-
if (res.status === 404) return;
|
|
2161
|
-
if (res.status === 409) {
|
|
2162
|
-
since = await resync();
|
|
2163
|
-
continue;
|
|
2164
|
-
}
|
|
2165
|
-
if (!res.ok) throw new Error(`events failed with ${res.status}`);
|
|
2166
|
-
const reply = await res.json();
|
|
2167
|
-
failures = 0;
|
|
2168
|
-
since = nextSince(since, reply);
|
|
2169
|
-
for (const event of reply.events ?? []) await receive(event);
|
|
2170
|
-
} catch {
|
|
2171
|
-
failures += 1;
|
|
2172
|
-
await sleep_(backoffMs(failures));
|
|
2173
|
-
}
|
|
2174
|
-
}
|
|
2175
|
-
}
|
|
2176
|
-
|
|
2177
|
-
/** Follows the server from the seq of the state we hold, so nothing between is missed. */
|
|
2178
|
-
async function followEvents() {
|
|
2179
|
-
let since = state.seq ?? 0;
|
|
2180
|
-
if ("EventSource" in window) since = await followStream(since, receive);
|
|
2181
|
-
if ($("done").hidden) await pollEvents(since);
|
|
2182
|
-
}
|
|
2183
|
-
|
|
2184
|
-
$("banner-show").addEventListener("click", () => {
|
|
2185
|
-
setListOpen(true);
|
|
2186
|
-
$("answers").scrollIntoView({ block: "nearest" });
|
|
2187
|
-
$("answers").querySelector("button")?.focus();
|
|
2188
|
-
});
|
|
2189
|
-
|
|
2190
|
-
$("banner-close").addEventListener("click", () => {
|
|
2191
|
-
$("banner").hidden = true;
|
|
2192
|
-
});
|
|
2193
|
-
|
|
2194
|
-
guarded(async () => {
|
|
2195
|
-
await adopt(await api("GET", "/api/state"));
|
|
2196
|
-
setUpPlayer();
|
|
2197
|
-
if (isWalk()) enterPlay();
|
|
2198
|
-
else render();
|
|
2199
|
-
if (answeredCount(state.previous) > 0) announceVersion();
|
|
2200
|
-
followEvents();
|
|
2201
|
-
});
|