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