@shiyuphan/follow-me 0.1.1 → 0.2.0

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