@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/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
- 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
 
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
- /** "commit:unit" -> change indices the reader clicked, to anchor a comment to them */
36
- 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;
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
- async function api(method, path, body) {
43
- const res = await fetch(path, {
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").textContent = error ? error.message : "";
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
- function describeChanges(changes) {
101
- 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";
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
- area,
115
- el(
116
- "div",
117
- { class: "row box" },
118
- el("button", {
119
- class: "primary",
120
- text: "Save",
121
- onclick: () =>
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 where =
141
- 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}`;
142
432
  return el(
143
433
  "div",
144
- { class: "comment" },
145
- 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
+ ),
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
- * A collapsed button that opens into a textarea. `makeAnchor` runs at save time so a
173
- * 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.
174
639
  */
175
- 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 }) {
176
757
  if (!openBoxes.has(key)) {
177
758
  return el("button", {
178
- text: typeof label === "function" ? label() : label,
759
+ text: label,
179
760
  onclick: () => {
180
761
  openBoxes.add(key);
762
+ focusKey = key;
181
763
  render();
182
764
  },
183
765
  });
184
766
  }
185
- const area = el("textarea", { placeholder, oninput: () => drafts.set(key, area.value) });
186
- area.value = drafts.get(key) ?? "";
187
- const close = () => {
188
- openBoxes.delete(key);
189
- drafts.delete(key);
190
- };
191
- return el(
192
- "div",
193
- { class: "box" },
194
- area,
195
- el(
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
- function diffLine(unit, commit, line, index, pickKey) {
228
- const base = el("span", { class: "op", text: line.op });
229
- 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];
230
834
  const key = lineKey(unit.id, index);
231
- 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) {
232
853
  return el(
233
854
  "div",
234
855
  { class: "dl", "data-line": key },
235
- el("span", { class: "gutter" }),
236
- base,
856
+ gutter,
857
+ el("span", { class: "op", text: " " }),
237
858
  text,
238
859
  );
239
-
240
- const owner = owners.get(unit.id)?.get(line.change);
241
- const mine = owner === commit.index;
242
- const picked = picks.get(pickKey)?.has(line.change) ?? false;
243
- 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"];
244
875
  if (picked) classes.push("picked");
245
- const node = el(
876
+ return el(
246
877
  "div",
247
878
  {
248
879
  class: classes.join(" "),
249
880
  "data-line": key,
250
- title: mine ? "Click to select this line for a comment" : undefined,
251
- onclick: mine
252
- ? () => {
253
- const set = picks.get(pickKey) ?? new Set();
254
- if (!set.delete(line.change)) set.add(line.change);
255
- picks.set(pickKey, set);
256
- render();
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
- el("span", { class: "gutter", text: String(line.change) }),
261
- base,
890
+ gutter,
891
+ el("span", { class: "op", text: line.op }),
262
892
  text,
263
893
  );
264
- if (!mine && owner !== undefined)
265
- node.append(el("span", { class: "label", text: `→ commit ${owner + 1}` }));
266
- 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));
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
- ...existing.map(commentView),
947
+ ...commentsAt(anchor).map(commentView),
278
948
  el(
279
949
  "div",
280
950
  { class: "controls" },
281
951
  commentBox({
282
- key: `box:${pickKey}`,
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
- makeAnchor: () => ({ ...anchor, changes: picked().length > 0 ? picked() : null }),
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 pickKey = `${commit.index}:${unit.id}`;
298
- 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 });
299
971
  let content;
300
972
  if (unit.kind === "hunk") {
301
- 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;
302
977
  head.append(
978
+ el("span", { class: "scope", text: scope === null ? "" : `in ${scope}` }),
303
979
  el("span", { class: "spacer" }),
304
- 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
+ ),
305
997
  );
306
998
  content = el(
307
999
  "div",
308
1000
  { class: "diff" },
309
- ...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
+ }),
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 count = state.comments.filter(
330
- (c) => c.anchor.kind !== "plan" && c.anchor.commit === commit.index,
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
- class: i === selected ? "selected" : undefined,
336
- onclick: () => select(i),
337
- },
338
- el("span", { class: "index", text: String(i + 1) }),
339
- el("span", { class: "title", text: firstLine(commit.message) }),
340
- 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
+ ),
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
- makeAnchor: () => anchor,
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
- makeAnchor: () => messageAnchor,
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
- makeAnchor: () => positionAnchor,
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
- 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 } = {}) {
435
1257
  const next = Math.max(0, Math.min(state.commits.length - 1, index));
436
- if (player.view === "play") {
437
- seek(commitStart(script, next));
438
- return;
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
- selected = next;
441
- render();
442
- $("main").scrollTop = 0;
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" hides lines the playback has not written yet; "browse" shows every line.
452
- * 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.
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 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;
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 seek retires this loop. */
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
- function speak(text) {
504
- 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();
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 setCaption(text) {
528
- $("caption").textContent = text;
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
- function applyPlayback() {
532
- const { states, current } =
533
- player.view === "play"
534
- ? lineStates(script, player.position, selected)
535
- : { states: new Map(), current: null };
536
- for (const node of $("main").querySelectorAll("[data-line]")) {
537
- const key = node.dataset.line;
538
- const lineState = states.get(key);
539
- node.classList.toggle("pending", lineState === "hidden");
540
- node.classList.toggle("gone", lineState === "gone");
541
- 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;
542
1467
  }
1468
+ return mermaid;
543
1469
  }
544
1470
 
545
- async function perform(step, generation) {
546
- player.position += 1;
547
- if (step.kind === "commit") {
548
- if (selected === step.commit) applyPlayback();
549
- else {
550
- selected = step.commit;
551
- render();
552
- $("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");
553
1507
  }
554
- setCaption(step.say);
555
- speak(step.say);
556
- } else if (step.kind === "unit") {
557
- applyPlayback();
558
- $("main")
559
- .querySelector(`[data-unit="${step.unit}"]`)
560
- ?.scrollIntoView({ block: "start", behavior: "smooth" });
561
- if (step.say) {
562
- setCaption(step.say);
563
- 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);
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
- $("main")
568
- .querySelector(".dl.current")
569
- ?.scrollIntoView({ block: "nearest", behavior: "smooth" });
1595
+ renderList();
570
1596
  }
571
- 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();
572
1649
  }
573
1650
 
574
1651
  async function playLoop(generation) {
575
- while (player.position < script.length) {
1652
+ while (player.position < steps.length) {
576
1653
  if (!(await sleep(0, generation))) return;
577
- 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();
578
1659
  }
579
1660
  player.playing = false;
580
- setCaption(
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 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. */
589
1666
  function seek(position) {
590
1667
  synth?.cancel();
591
- player.position = position;
1668
+ notice = null;
1669
+ player.position = Math.max(0, Math.min(steps.length, position));
592
1670
  player.generation += 1;
593
- selected = script[position]?.commit ?? commitAt(script, position);
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 >= script.length) seek(0);
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
- seek(commitStart(script, selected));
628
- updatePlayer();
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
- $("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));
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", () => select(selected - 1));
684
- $("next").addEventListener("click", () => select(selected + 1));
685
- $("mute").addEventListener("click", () => {
686
- player.muted = !player.muted;
687
- store("muted", String(player.muted));
688
- if (player.muted) synth?.cancel();
689
- 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();
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
- hasUnsentText() &&
704
- !window.confirm("Some comment boxes have text you have not sent. End anyway?")
705
- ) {
706
- 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
+ }
707
1925
  }
708
- 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
+ });
709
1934
  player.generation += 1;
710
1935
  synth?.cancel();
711
- const done = $("done");
712
- const saved = `reviews/v${state.version}.json`;
713
- done.replaceChildren(
714
- isWalk()
715
- ? el(
716
- "p",
717
- {},
718
- `Code review saved to ${saved}. The author can run `,
719
- el("code", { text: "follow-me feedback" }),
720
- " to read it.",
721
- )
722
- : el(
723
- "p",
724
- {},
725
- `Review saved to ${saved}. Run `,
726
- el("code", { text: "follow-me feedback" }),
727
- " or hand the session to your agent.",
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
- if (event.key === "j" || event.key === "n") select(selected + 1);
750
- 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);
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
- guarded(async () => {
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 (walk) {
767
- enterPlay();
768
- setCaption("Press Play or space to watch this branch being written.");
769
- } else render();
2197
+ if (isWalk()) enterPlay();
2198
+ else render();
2199
+ if (answeredCount(state.previous) > 0) announceVersion();
2200
+ followEvents();
770
2201
  });