@shiyuphan/follow-me 0.1.1 → 0.2.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 +127 -66
- package/dist/src/anthropic.js +41 -9
- package/dist/src/cli.js +632 -81
- package/dist/src/comments.js +300 -0
- package/dist/src/git.js +20 -3
- package/dist/src/host.js +92 -6
- package/dist/src/install.js +1 -1
- package/dist/src/naive.js +26 -0
- package/dist/src/plan.js +281 -25
- package/dist/src/prompt.js +391 -82
- package/dist/src/server.js +461 -137
- package/dist/src/session.js +104 -104
- package/dist/src/syntax.js +199 -0
- package/dist/src/types.js +3 -1
- package/dist/ui/app.js +1676 -272
- package/dist/ui/concerns.js +165 -0
- package/dist/ui/highlight.js +221 -0
- package/dist/ui/index.html +61 -7
- package/dist/ui/live.js +184 -0
- package/dist/ui/playback.js +480 -76
- package/dist/ui/questions.js +189 -0
- package/dist/ui/style.css +955 -90
- package/dist/ui/summary.js +45 -0
- package/package.json +4 -2
- package/skills/follow-me/SKILL.md +34 -6
package/dist/ui/app.js
CHANGED
|
@@ -1,15 +1,56 @@
|
|
|
1
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,
|
|
2
21
|
buildScript,
|
|
3
|
-
|
|
4
|
-
|
|
22
|
+
chunkOf,
|
|
23
|
+
commitOf,
|
|
24
|
+
describeLines,
|
|
25
|
+
diagramAt,
|
|
26
|
+
duration,
|
|
27
|
+
hasDiagrams,
|
|
28
|
+
hasNarration,
|
|
29
|
+
introOf,
|
|
5
30
|
lineKey,
|
|
31
|
+
lineNumbers,
|
|
6
32
|
lineStates,
|
|
7
33
|
parseSpeed,
|
|
8
34
|
pickVoice,
|
|
35
|
+
progress,
|
|
36
|
+
rowsOf,
|
|
9
37
|
SPEEDS,
|
|
38
|
+
schedule,
|
|
39
|
+
scopeOf,
|
|
40
|
+
speechRate,
|
|
10
41
|
speechTimeout,
|
|
11
|
-
stepDelay,
|
|
12
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";
|
|
13
54
|
|
|
14
55
|
const $ = (id) => document.getElementById(id);
|
|
15
56
|
|
|
@@ -33,22 +74,96 @@ function el(tag, props = {}, ...children) {
|
|
|
33
74
|
let state = null;
|
|
34
75
|
let selected = 0;
|
|
35
76
|
let unitsById = new Map();
|
|
77
|
+
/** unit id -> per hunk line, its { old, new } file line numbers */
|
|
78
|
+
let numbers = new Map();
|
|
36
79
|
/** unit id -> (change index -> index of the commit that owns the line) */
|
|
37
80
|
let owners = new Map();
|
|
38
|
-
/**
|
|
39
|
-
|
|
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;
|
|
40
88
|
/** Unsent textarea contents survive re-renders, keyed like the boxes. */
|
|
41
89
|
const drafts = new Map();
|
|
42
90
|
const openBoxes = new Set();
|
|
43
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
|
+
}
|
|
44
152
|
|
|
45
|
-
|
|
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) {
|
|
46
157
|
const res = await fetch(`${sessionPrefix}${path}`, {
|
|
47
158
|
method,
|
|
48
159
|
headers: { "content-type": "application/json" },
|
|
49
160
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
50
161
|
});
|
|
51
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
|
+
}
|
|
52
167
|
if (!res.ok) throw new Error(payload.error ?? `${method} ${path} failed with ${res.status}`);
|
|
53
168
|
return payload;
|
|
54
169
|
}
|
|
@@ -75,7 +190,9 @@ async function renderSwitcher() {
|
|
|
75
190
|
}
|
|
76
191
|
|
|
77
192
|
function report(error) {
|
|
78
|
-
$("status")
|
|
193
|
+
const status = $("status");
|
|
194
|
+
status.textContent = error ? error.message : "";
|
|
195
|
+
status.classList.toggle("error", Boolean(error));
|
|
79
196
|
}
|
|
80
197
|
|
|
81
198
|
async function guarded(action) {
|
|
@@ -89,6 +206,7 @@ async function guarded(action) {
|
|
|
89
206
|
|
|
90
207
|
function indexOwnership() {
|
|
91
208
|
unitsById = new Map(state.units.map((unit) => [unit.id, unit]));
|
|
209
|
+
numbers = lineNumbers(state.units);
|
|
92
210
|
owners = new Map();
|
|
93
211
|
for (const commit of state.commits) {
|
|
94
212
|
for (const ref of commit.refs) {
|
|
@@ -121,61 +239,236 @@ function firstLine(message) {
|
|
|
121
239
|
return message.split("\n")[0] ?? "";
|
|
122
240
|
}
|
|
123
241
|
|
|
124
|
-
|
|
125
|
-
|
|
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";
|
|
126
410
|
}
|
|
127
411
|
|
|
128
412
|
function commentView(comment) {
|
|
129
|
-
const key = `edit:${comment.id}`;
|
|
130
413
|
if (editing.has(comment.id)) {
|
|
131
|
-
const area = el("textarea", {
|
|
132
|
-
oninput: () => drafts.set(key, area.value),
|
|
133
|
-
});
|
|
134
|
-
area.value = drafts.get(key) ?? comment.body;
|
|
135
414
|
return el(
|
|
136
415
|
"div",
|
|
137
416
|
{ class: "comment" },
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
"
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
guarded(async () => {
|
|
147
|
-
await api("PUT", `/api/comments/${comment.id}`, { ...comment, body: area.value });
|
|
148
|
-
editing.delete(comment.id);
|
|
149
|
-
drafts.delete(key);
|
|
150
|
-
await reloadComments();
|
|
151
|
-
}),
|
|
152
|
-
}),
|
|
153
|
-
el("button", {
|
|
154
|
-
text: "Cancel",
|
|
155
|
-
onclick: () => {
|
|
156
|
-
editing.delete(comment.id);
|
|
157
|
-
drafts.delete(key);
|
|
158
|
-
render();
|
|
159
|
-
},
|
|
160
|
-
}),
|
|
161
|
-
),
|
|
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
|
+
}),
|
|
162
425
|
);
|
|
163
426
|
}
|
|
164
|
-
const
|
|
165
|
-
|
|
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}`;
|
|
166
432
|
return el(
|
|
167
433
|
"div",
|
|
168
|
-
{ class: "comment" },
|
|
169
|
-
el(
|
|
434
|
+
{ class: question ? "comment question" : "comment" },
|
|
435
|
+
el(
|
|
436
|
+
"div",
|
|
437
|
+
{ class: "meta" },
|
|
438
|
+
`${commentLabel(comment)} · ${comment.id}${where}${again}${rating}`,
|
|
439
|
+
),
|
|
170
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,
|
|
171
452
|
el(
|
|
172
453
|
"div",
|
|
173
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,
|
|
174
466
|
el("button", {
|
|
175
467
|
class: "link",
|
|
176
468
|
text: "edit",
|
|
177
469
|
onclick: () => {
|
|
178
470
|
editing.add(comment.id);
|
|
471
|
+
focusKey = `edit:${comment.id}`;
|
|
179
472
|
render();
|
|
180
473
|
},
|
|
181
474
|
}),
|
|
@@ -192,55 +485,299 @@ function commentView(comment) {
|
|
|
192
485
|
);
|
|
193
486
|
}
|
|
194
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
|
+
|
|
195
636
|
/**
|
|
196
|
-
*
|
|
197
|
-
*
|
|
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.
|
|
198
639
|
*/
|
|
199
|
-
function
|
|
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 }) {
|
|
200
757
|
if (!openBoxes.has(key)) {
|
|
201
758
|
return el("button", {
|
|
202
|
-
text:
|
|
759
|
+
text: label,
|
|
203
760
|
onclick: () => {
|
|
204
761
|
openBoxes.add(key);
|
|
762
|
+
focusKey = key;
|
|
205
763
|
render();
|
|
206
764
|
},
|
|
207
765
|
});
|
|
208
766
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
"div",
|
|
221
|
-
{ class: "row" },
|
|
222
|
-
el("button", {
|
|
223
|
-
class: "primary",
|
|
224
|
-
text: "Comment",
|
|
225
|
-
onclick: () =>
|
|
226
|
-
guarded(async () => {
|
|
227
|
-
const body = area.value.trim();
|
|
228
|
-
if (!body) return;
|
|
229
|
-
await api("POST", "/api/comments", { anchor: makeAnchor(), context, body });
|
|
230
|
-
close();
|
|
231
|
-
onSaved?.();
|
|
232
|
-
await reloadComments();
|
|
233
|
-
}),
|
|
234
|
-
}),
|
|
235
|
-
el("button", {
|
|
236
|
-
text: "Cancel",
|
|
237
|
-
onclick: () => {
|
|
238
|
-
close();
|
|
239
|
-
render();
|
|
240
|
-
},
|
|
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),
|
|
241
778
|
}),
|
|
242
|
-
),
|
|
243
|
-
);
|
|
779
|
+
close: () => openBoxes.delete(key),
|
|
780
|
+
});
|
|
244
781
|
}
|
|
245
782
|
|
|
246
783
|
function describeFileUnit(unit) {
|
|
@@ -248,124 +785,352 @@ function describeFileUnit(unit) {
|
|
|
248
785
|
return `${unit.reason} file ${change}`;
|
|
249
786
|
}
|
|
250
787
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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];
|
|
254
834
|
const key = lineKey(unit.id, index);
|
|
255
|
-
|
|
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) {
|
|
256
853
|
return el(
|
|
257
854
|
"div",
|
|
258
855
|
{ class: "dl", "data-line": key },
|
|
259
|
-
|
|
260
|
-
|
|
856
|
+
gutter,
|
|
857
|
+
el("span", { class: "op", text: " " }),
|
|
261
858
|
text,
|
|
262
859
|
);
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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"];
|
|
268
875
|
if (picked) classes.push("picked");
|
|
269
|
-
|
|
876
|
+
return el(
|
|
270
877
|
"div",
|
|
271
878
|
{
|
|
272
879
|
class: classes.join(" "),
|
|
273
880
|
"data-line": key,
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
: undefined,
|
|
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"}`,
|
|
283
889
|
},
|
|
284
|
-
|
|
285
|
-
|
|
890
|
+
gutter,
|
|
891
|
+
el("span", { class: "op", text: line.op }),
|
|
286
892
|
text,
|
|
287
893
|
);
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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));
|
|
291
940
|
}
|
|
292
941
|
|
|
293
942
|
function unitComments(unit, commit) {
|
|
294
943
|
const anchor = { kind: "hunk", unit: unit.id, commit: commit.index, changes: null };
|
|
295
|
-
const pickKey = `${commit.index}:${unit.id}`;
|
|
296
|
-
const existing = commentsAt(anchor);
|
|
297
|
-
const picked = () => [...(picks.get(pickKey) ?? [])].sort((a, b) => a - b);
|
|
298
944
|
return el(
|
|
299
945
|
"div",
|
|
300
946
|
{ class: "comments" },
|
|
301
|
-
...
|
|
947
|
+
...commentsAt(anchor).map(commentView),
|
|
302
948
|
el(
|
|
303
949
|
"div",
|
|
304
950
|
{ class: "controls" },
|
|
305
951
|
commentBox({
|
|
306
|
-
key: `box:${
|
|
307
|
-
label:
|
|
308
|
-
const n = picked().length;
|
|
309
|
-
return n > 0 ? `Comment on ${n} selected line${n === 1 ? "" : "s"}` : "Comment";
|
|
310
|
-
},
|
|
952
|
+
key: `box:${commit.index}:${unit.id}`,
|
|
953
|
+
label: unit.kind === "hunk" ? "Comment on this hunk" : "Comment",
|
|
311
954
|
placeholder: "What about this change?",
|
|
312
|
-
|
|
955
|
+
anchor,
|
|
313
956
|
context: { commitMessage: commit.message, path: unit.path },
|
|
314
|
-
onSaved: () => picks.delete(pickKey),
|
|
315
957
|
}),
|
|
316
958
|
),
|
|
317
959
|
);
|
|
318
960
|
}
|
|
319
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
|
+
|
|
320
968
|
function unitView(unit, commit) {
|
|
321
|
-
const
|
|
322
|
-
const head = el("div", { class: "unit-head"
|
|
969
|
+
const ref = commit.refs.find((r) => r.unit === unit.id);
|
|
970
|
+
const head = el("div", { class: "unit-head", title: unit.id });
|
|
323
971
|
let content;
|
|
324
972
|
if (unit.kind === "hunk") {
|
|
325
|
-
const
|
|
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;
|
|
326
977
|
head.append(
|
|
978
|
+
el("span", { class: "scope", text: scope === null ? "" : `in ${scope}` }),
|
|
327
979
|
el("span", { class: "spacer" }),
|
|
328
|
-
el("span", {
|
|
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
|
+
),
|
|
329
997
|
);
|
|
330
998
|
content = el(
|
|
331
999
|
"div",
|
|
332
1000
|
{ class: "diff" },
|
|
333
|
-
...
|
|
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
|
+
}),
|
|
334
1011
|
);
|
|
335
1012
|
} else {
|
|
336
1013
|
const summary =
|
|
337
1014
|
unit.kind === "file" ? describeFileUnit(unit) : `mode change ${unit.from} → ${unit.to}`;
|
|
1015
|
+
head.append(el("span", { text: summary }));
|
|
338
1016
|
content = el("div", { class: "card", text: `${summary}: ${unit.path}` });
|
|
339
1017
|
}
|
|
340
1018
|
return el(
|
|
341
1019
|
"div",
|
|
342
1020
|
{ class: "unit", "data-unit": unit.id },
|
|
343
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()),
|
|
344
1031
|
content,
|
|
345
1032
|
unitComments(unit, commit),
|
|
346
1033
|
);
|
|
347
1034
|
}
|
|
348
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
|
+
|
|
349
1049
|
function renderList() {
|
|
350
1050
|
const list = $("commits");
|
|
1051
|
+
const hadFocus = list.contains(document.activeElement);
|
|
351
1052
|
list.replaceChildren(
|
|
352
1053
|
...state.commits.map((commit, i) => {
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
).length;
|
|
1054
|
+
const { questions, changes } = kindCounts(state.comments, commit.index);
|
|
1055
|
+
const playing = player.view === "play" && i === selected;
|
|
356
1056
|
return el(
|
|
357
1057
|
"li",
|
|
358
|
-
{
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
+
),
|
|
365
1093
|
);
|
|
366
1094
|
}),
|
|
367
1095
|
);
|
|
368
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;
|
|
369
1134
|
}
|
|
370
1135
|
|
|
371
1136
|
function renderPlanNote() {
|
|
@@ -380,7 +1145,7 @@ function renderPlanNote() {
|
|
|
380
1145
|
placeholder: walk
|
|
381
1146
|
? "Anything about the change as a whole"
|
|
382
1147
|
: "Anything about the plan as a whole",
|
|
383
|
-
|
|
1148
|
+
anchor,
|
|
384
1149
|
context: { commitMessage: "", path: null },
|
|
385
1150
|
}),
|
|
386
1151
|
);
|
|
@@ -406,12 +1171,22 @@ function renderMain() {
|
|
|
406
1171
|
}
|
|
407
1172
|
|
|
408
1173
|
main.replaceChildren(
|
|
1174
|
+
stage,
|
|
409
1175
|
el(
|
|
410
1176
|
"div",
|
|
411
1177
|
{ class: "commit-head" },
|
|
412
|
-
el("h2", { text: `Commit ${selected + 1} of ${state.commits.length}` }),
|
|
413
1178
|
el("div", { class: "message", text: commit.message }),
|
|
1179
|
+
staticNarration(commit.narration?.trim()),
|
|
414
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)),
|
|
415
1190
|
// A walk plan's messages and order are the planner's, not the author's, so only code is
|
|
416
1191
|
// up for comment there.
|
|
417
1192
|
isWalk()
|
|
@@ -423,14 +1198,14 @@ function renderMain() {
|
|
|
423
1198
|
key: `box:message:${commit.index}`,
|
|
424
1199
|
label: "Comment on the message",
|
|
425
1200
|
placeholder: "What should this message say?",
|
|
426
|
-
|
|
1201
|
+
anchor: messageAnchor,
|
|
427
1202
|
context,
|
|
428
1203
|
}),
|
|
429
1204
|
commentBox({
|
|
430
1205
|
key: `box:position:${commit.index}`,
|
|
431
1206
|
label: "This belongs elsewhere",
|
|
432
1207
|
placeholder: "Where should this go, and why?",
|
|
433
|
-
|
|
1208
|
+
anchor: positionAnchor,
|
|
434
1209
|
context,
|
|
435
1210
|
}),
|
|
436
1211
|
),
|
|
@@ -447,23 +1222,50 @@ function renderMain() {
|
|
|
447
1222
|
);
|
|
448
1223
|
main.scrollTop = scroll;
|
|
449
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;
|
|
450
1234
|
}
|
|
451
1235
|
|
|
452
1236
|
function render() {
|
|
1237
|
+
const typing = typingPlace();
|
|
1238
|
+
cards = answerCards(state.previous, state.resolved, state.commits);
|
|
453
1239
|
renderList();
|
|
1240
|
+
renderAnswers();
|
|
454
1241
|
renderPlanNote();
|
|
455
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
|
+
}
|
|
456
1253
|
}
|
|
457
1254
|
|
|
458
|
-
|
|
1255
|
+
/** Moves to a commit. A move the reviewer asked for takes focus to the commit heading. */
|
|
1256
|
+
function select(index, { focus = false } = {}) {
|
|
459
1257
|
const next = Math.max(0, Math.min(state.commits.length - 1, index));
|
|
460
|
-
if (player.view === "play")
|
|
461
|
-
|
|
462
|
-
|
|
1258
|
+
if (player.view === "play") seek(beatStart(steps, introOf(beats, next)));
|
|
1259
|
+
else {
|
|
1260
|
+
selected = next;
|
|
1261
|
+
render();
|
|
1262
|
+
$("main").scrollTop = 0;
|
|
463
1263
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
1264
|
+
if (focus) focusHeading();
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function focusHeading() {
|
|
1268
|
+
stage.querySelector("h2")?.focus({ preventScroll: true });
|
|
467
1269
|
}
|
|
468
1270
|
|
|
469
1271
|
function isWalk() {
|
|
@@ -471,9 +1273,10 @@ function isWalk() {
|
|
|
471
1273
|
}
|
|
472
1274
|
|
|
473
1275
|
const synth = "speechSynthesis" in window ? window.speechSynthesis : null;
|
|
1276
|
+
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
474
1277
|
/**
|
|
475
|
-
* view "play"
|
|
476
|
-
*
|
|
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.
|
|
477
1280
|
*/
|
|
478
1281
|
const player = {
|
|
479
1282
|
view: "browse",
|
|
@@ -484,8 +1287,22 @@ const player = {
|
|
|
484
1287
|
muted: false,
|
|
485
1288
|
voice: null,
|
|
486
1289
|
};
|
|
487
|
-
let
|
|
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;
|
|
488
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" });
|
|
489
1306
|
|
|
490
1307
|
function stored(key) {
|
|
491
1308
|
try {
|
|
@@ -504,10 +1321,10 @@ function store(key, value) {
|
|
|
504
1321
|
}
|
|
505
1322
|
|
|
506
1323
|
function silent() {
|
|
507
|
-
return player.muted || synth === null;
|
|
1324
|
+
return player.muted || synth === null || !narrated;
|
|
508
1325
|
}
|
|
509
1326
|
|
|
510
|
-
/** Resolves true after `ms` of playing time, or false once a
|
|
1327
|
+
/** Resolves true after `ms` of playing time, or false once a move retires this loop. */
|
|
511
1328
|
function sleep(ms, generation) {
|
|
512
1329
|
return new Promise((resolve) => {
|
|
513
1330
|
let left = ms;
|
|
@@ -524,132 +1341,359 @@ function sleep(ms, generation) {
|
|
|
524
1341
|
});
|
|
525
1342
|
}
|
|
526
1343
|
|
|
527
|
-
|
|
528
|
-
|
|
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();
|
|
529
1350
|
const utterance = new SpeechSynthesisUtterance(text);
|
|
530
|
-
utterance.rate = player.speed;
|
|
1351
|
+
utterance.rate = speechRate(player.speed);
|
|
531
1352
|
const voice = voices.find((v) => v.name === player.voice);
|
|
532
1353
|
if (voice) {
|
|
533
1354
|
utterance.voice = voice;
|
|
534
1355
|
utterance.lang = voice.lang;
|
|
535
1356
|
}
|
|
536
|
-
synth.speak(utterance);
|
|
537
|
-
return utterance;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/** Some engines never fire "end", so a pause-aware timeout bounds the wait. */
|
|
541
|
-
function speakAndWait(text, generation) {
|
|
542
|
-
const utterance = speak(text);
|
|
543
|
-
if (!utterance) return Promise.resolve(true);
|
|
544
1357
|
const ended = new Promise((resolve) => {
|
|
545
1358
|
utterance.addEventListener("end", () => resolve(true));
|
|
546
1359
|
utterance.addEventListener("error", () => resolve(true));
|
|
547
1360
|
});
|
|
1361
|
+
synth.speak(utterance);
|
|
548
1362
|
return Promise.race([ended, sleep(speechTimeout(text, player.speed), generation)]);
|
|
549
1363
|
}
|
|
550
1364
|
|
|
551
|
-
function
|
|
552
|
-
$("
|
|
1365
|
+
function scrollMain(top) {
|
|
1366
|
+
$("main").scrollTo({
|
|
1367
|
+
top: Math.max(0, top),
|
|
1368
|
+
behavior: reducedMotion.matches ? "auto" : "smooth",
|
|
1369
|
+
});
|
|
553
1370
|
}
|
|
554
1371
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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;
|
|
566
1467
|
}
|
|
1468
|
+
return mermaid;
|
|
567
1469
|
}
|
|
568
1470
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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");
|
|
577
1507
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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);
|
|
588
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;
|
|
589
1593
|
} else {
|
|
590
1594
|
applyPlayback();
|
|
591
|
-
|
|
592
|
-
.querySelector(".dl.current")
|
|
593
|
-
?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
1595
|
+
renderList();
|
|
594
1596
|
}
|
|
595
|
-
|
|
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();
|
|
596
1649
|
}
|
|
597
1650
|
|
|
598
1651
|
async function playLoop(generation) {
|
|
599
|
-
while (player.position <
|
|
1652
|
+
while (player.position < steps.length) {
|
|
600
1653
|
if (!(await sleep(0, generation))) return;
|
|
601
|
-
if (!(await perform(
|
|
1654
|
+
if (!(await perform(steps[player.position], generation))) return;
|
|
1655
|
+
player.position += 1;
|
|
1656
|
+
present();
|
|
1657
|
+
holdForAnswer();
|
|
1658
|
+
holdForConcern();
|
|
602
1659
|
}
|
|
603
1660
|
player.playing = false;
|
|
604
|
-
|
|
605
|
-
isWalk()
|
|
606
|
-
? "That is the whole branch. Leave any last comments, then finish the code review."
|
|
607
|
-
: "That is the whole plan.",
|
|
608
|
-
);
|
|
1661
|
+
renderList();
|
|
609
1662
|
updatePlayer();
|
|
610
1663
|
}
|
|
611
1664
|
|
|
612
|
-
/** Moves
|
|
1665
|
+
/** Moves to a step and starts a fresh loop, which waits while paused. */
|
|
613
1666
|
function seek(position) {
|
|
614
1667
|
synth?.cancel();
|
|
615
|
-
|
|
1668
|
+
notice = null;
|
|
1669
|
+
player.position = Math.max(0, Math.min(steps.length, position));
|
|
616
1670
|
player.generation += 1;
|
|
617
|
-
|
|
618
|
-
render();
|
|
619
|
-
$("main").scrollTop = 0;
|
|
620
|
-
setCaption("");
|
|
1671
|
+
present();
|
|
621
1672
|
playLoop(player.generation);
|
|
622
1673
|
}
|
|
623
1674
|
|
|
624
1675
|
function play() {
|
|
625
1676
|
if (player.view !== "play") enterPlay();
|
|
626
|
-
if (player.position >=
|
|
1677
|
+
if (player.position >= steps.length) seek(0);
|
|
1678
|
+
notice = null;
|
|
1679
|
+
started = true;
|
|
627
1680
|
player.playing = true;
|
|
1681
|
+
applyPlayback();
|
|
628
1682
|
synth?.resume();
|
|
629
1683
|
updatePlayer();
|
|
630
1684
|
}
|
|
631
1685
|
|
|
632
|
-
/** Pausing preselects the line just written, so the comment button is ready for it. */
|
|
633
1686
|
function pause() {
|
|
634
1687
|
player.playing = false;
|
|
635
1688
|
synth?.pause();
|
|
636
1689
|
updatePlayer();
|
|
637
|
-
const key = $("main").querySelector(".dl.current")?.dataset.line;
|
|
638
|
-
if (key === undefined) return;
|
|
639
|
-
const at = key.lastIndexOf(":");
|
|
640
|
-
const unitId = key.slice(0, at);
|
|
641
|
-
const change = unitsById.get(unitId)?.body?.[Number(key.slice(at + 1))]?.change;
|
|
642
|
-
const pickKey = `${selected}:${unitId}`;
|
|
643
|
-
if (change === undefined || (picks.get(pickKey)?.size ?? 0) > 0) return;
|
|
644
|
-
picks.set(pickKey, new Set([change]));
|
|
645
|
-
render();
|
|
646
1690
|
}
|
|
647
1691
|
|
|
648
1692
|
function enterPlay() {
|
|
649
1693
|
player.view = "play";
|
|
650
1694
|
$("player").hidden = false;
|
|
651
|
-
|
|
652
|
-
|
|
1695
|
+
render();
|
|
1696
|
+
seek(beatStart(steps, introOf(beats, selected)));
|
|
653
1697
|
}
|
|
654
1698
|
|
|
655
1699
|
function enterBrowse() {
|
|
@@ -662,8 +1706,79 @@ function enterBrowse() {
|
|
|
662
1706
|
updatePlayer();
|
|
663
1707
|
}
|
|
664
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
|
+
|
|
665
1776
|
function updatePlayer() {
|
|
666
|
-
$("
|
|
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));
|
|
667
1782
|
$("view-toggle").textContent = player.view === "play" ? "Show all" : "Play";
|
|
668
1783
|
const mute = $("mute");
|
|
669
1784
|
mute.disabled = synth === null;
|
|
@@ -671,6 +1786,19 @@ function updatePlayer() {
|
|
|
671
1786
|
mute.setAttribute("aria-pressed", String(player.muted));
|
|
672
1787
|
}
|
|
673
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
|
+
|
|
674
1802
|
function loadVoices() {
|
|
675
1803
|
if (synth === null) return;
|
|
676
1804
|
voices = synth.getVoices();
|
|
@@ -685,16 +1813,22 @@ function loadVoices() {
|
|
|
685
1813
|
picker.value = player.voice ?? "";
|
|
686
1814
|
}
|
|
687
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
|
+
|
|
688
1824
|
function setUpPlayer() {
|
|
689
1825
|
player.speed = parseSpeed(stored("speed"));
|
|
1826
|
+
buildPlayback();
|
|
690
1827
|
player.muted = stored("muted") === "true";
|
|
691
1828
|
const speed = $("speed");
|
|
692
1829
|
speed.replaceChildren(...SPEEDS.map((s) => el("option", { value: String(s), text: `${s}x` })));
|
|
693
1830
|
speed.value = String(player.speed);
|
|
694
|
-
speed.addEventListener("change", () =>
|
|
695
|
-
player.speed = parseSpeed(speed.value);
|
|
696
|
-
store("speed", String(player.speed));
|
|
697
|
-
});
|
|
1831
|
+
speed.addEventListener("change", () => setSpeed(parseSpeed(speed.value)));
|
|
698
1832
|
const picker = $("voice");
|
|
699
1833
|
picker.disabled = synth === null;
|
|
700
1834
|
picker.addEventListener("change", () => {
|
|
@@ -704,17 +1838,24 @@ function setUpPlayer() {
|
|
|
704
1838
|
synth?.addEventListener("voiceschanged", loadVoices);
|
|
705
1839
|
loadVoices();
|
|
706
1840
|
$("play").addEventListener("click", () => (player.playing ? pause() : play()));
|
|
707
|
-
$("prev").addEventListener("click",
|
|
708
|
-
$("next").addEventListener("click",
|
|
709
|
-
$("
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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();
|
|
714
1858
|
});
|
|
715
|
-
$("view-toggle").addEventListener("click", () =>
|
|
716
|
-
player.view === "play" ? enterBrowse() : play(),
|
|
717
|
-
);
|
|
718
1859
|
updatePlayer();
|
|
719
1860
|
}
|
|
720
1861
|
|
|
@@ -722,45 +1863,145 @@ function hasUnsentText() {
|
|
|
722
1863
|
return [...drafts.values()].some((text) => text.trim() !== "");
|
|
723
1864
|
}
|
|
724
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
|
+
|
|
725
1915
|
async function endReview() {
|
|
726
|
-
if (
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
) {
|
|
730
|
-
|
|
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
|
+
}
|
|
731
1925
|
}
|
|
732
|
-
|
|
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
|
+
});
|
|
733
1934
|
player.generation += 1;
|
|
734
1935
|
synth?.cancel();
|
|
735
|
-
const
|
|
736
|
-
const
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
:
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
+
),
|
|
754
1973
|
);
|
|
755
|
-
done.hidden = false;
|
|
1974
|
+
$("done").hidden = false;
|
|
1975
|
+
$("done-title").setAttribute("tabindex", "-1");
|
|
1976
|
+
$("done-title").focus();
|
|
756
1977
|
}
|
|
757
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
|
+
|
|
758
1998
|
document.addEventListener("keydown", (event) => {
|
|
759
1999
|
const target = event.target;
|
|
760
2000
|
if (
|
|
761
2001
|
target instanceof HTMLTextAreaElement ||
|
|
762
2002
|
target instanceof HTMLInputElement ||
|
|
763
|
-
target instanceof HTMLSelectElement
|
|
2003
|
+
target instanceof HTMLSelectElement ||
|
|
2004
|
+
$("keys").open
|
|
764
2005
|
) {
|
|
765
2006
|
return;
|
|
766
2007
|
}
|
|
@@ -770,28 +2011,191 @@ document.addEventListener("keydown", (event) => {
|
|
|
770
2011
|
event.preventDefault();
|
|
771
2012
|
if (player.playing) pause();
|
|
772
2013
|
else play();
|
|
2014
|
+
return;
|
|
773
2015
|
}
|
|
774
|
-
|
|
775
|
-
if (
|
|
2016
|
+
const action = Object.hasOwn(KEYS, event.key) ? KEYS[event.key] : undefined;
|
|
2017
|
+
if (action === undefined) return;
|
|
2018
|
+
event.preventDefault();
|
|
2019
|
+
action(event.shiftKey);
|
|
776
2020
|
});
|
|
777
2021
|
|
|
778
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);
|
|
779
2035
|
window.addEventListener("focus", () => renderSwitcher());
|
|
780
2036
|
renderSwitcher();
|
|
781
2037
|
|
|
782
|
-
|
|
783
|
-
state = await api("GET", "/api/state");
|
|
784
|
-
indexOwnership();
|
|
785
|
-
script = buildScript(state.commits, state.units);
|
|
2038
|
+
function renderHeader() {
|
|
786
2039
|
const walk = isWalk();
|
|
787
2040
|
document.title = walk ? "follow-me code review" : "follow-me review";
|
|
788
2041
|
$("summary").textContent = walk
|
|
789
2042
|
? `${state.anchor.branch} · Code review`
|
|
790
2043
|
: `${state.anchor.branch} · Review of plan v${state.version}`;
|
|
791
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"));
|
|
792
2196
|
setUpPlayer();
|
|
793
|
-
if (
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
2197
|
+
if (isWalk()) enterPlay();
|
|
2198
|
+
else render();
|
|
2199
|
+
if (answeredCount(state.previous) > 0) announceVersion();
|
|
2200
|
+
followEvents();
|
|
797
2201
|
});
|