@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.
@@ -0,0 +1,165 @@
1
+ // @ts-check
2
+ // Pure logic for the agent's concerns: which are still undecided, where each one sits among the
3
+ // lines, when playback stops for one and how long a chunk lingers for it. No DOM, no fetch.
4
+
5
+ import { readMs } from "./playback.js";
6
+
7
+ /**
8
+ * @typedef {"blocker" | "major" | "minor"} Severity
9
+ * @typedef {{ id: string, severity: Severity, kind: string, text: string, lines?: number[],
10
+ * unit?: string }} Concern
11
+ * @typedef {{ concern: string, decision: "kept" | "dismissed", severity?: Severity,
12
+ * note?: string }} Decision
13
+ * @typedef {{ index: number, concerns?: Concern[] }} CommitConcerns
14
+ * @typedef {{ op: string, change?: number }} BodyLine
15
+ * @typedef {{ kind: string, commit: number, unit?: string, lines?: number[] }} Beat
16
+ */
17
+
18
+ export const SEVERITIES = /** @type {const} */ (["blocker", "major", "minor"]);
19
+
20
+ /**
21
+ * The severity in force: the reviewer's override when they kept it with one, else the agent's.
22
+ * @param {Concern} concern
23
+ * @param {Readonly<Record<string, Decision>>} decisions
24
+ * @returns {Severity}
25
+ */
26
+ export function severityOf(concern, decisions) {
27
+ return decisions[concern.id]?.severity ?? concern.severity;
28
+ }
29
+
30
+ /**
31
+ * @param {Concern} concern
32
+ * @param {Readonly<Record<string, Decision>>} decisions
33
+ * @returns {"undecided" | "kept" | "dismissed"}
34
+ */
35
+ export function stateOf(concern, decisions) {
36
+ return decisions[concern.id]?.decision ?? "undecided";
37
+ }
38
+
39
+ /**
40
+ * Every concern of the plan in commit order.
41
+ * @param {readonly CommitConcerns[]} commits
42
+ * @returns {Concern[]}
43
+ */
44
+ export function allConcerns(commits) {
45
+ return commits.flatMap((c) => c.concerns ?? []);
46
+ }
47
+
48
+ /**
49
+ * Concerns the reviewer has not decided.
50
+ * @param {readonly Concern[]} concerns
51
+ * @param {Readonly<Record<string, Decision>>} decisions
52
+ */
53
+ export function undecided(concerns, decisions) {
54
+ return concerns.filter((c) => stateOf(c, decisions) === "undecided");
55
+ }
56
+
57
+ /**
58
+ * The most severe of some concerns, or null for none.
59
+ * @param {readonly Concern[]} concerns
60
+ * @param {Readonly<Record<string, Decision>>} decisions
61
+ * @returns {Severity | null}
62
+ */
63
+ export function topSeverity(concerns, decisions) {
64
+ const found = new Set(concerns.map((c) => severityOf(c, decisions)));
65
+ return SEVERITIES.find((s) => found.has(s)) ?? null;
66
+ }
67
+
68
+ /**
69
+ * Body index of each change line asked for, skipping any the unit does not have.
70
+ * @param {readonly BodyLine[]} body
71
+ * @param {readonly number[]} changes
72
+ */
73
+ export function bodyIndexes(body, changes) {
74
+ const wanted = new Set(changes);
75
+ return body.flatMap((line, i) =>
76
+ line.op !== " " && line.change !== undefined && wanted.has(line.change) ? [i] : [],
77
+ );
78
+ }
79
+
80
+ /**
81
+ * The body line a concern's card sits under: below its last pinned line, else below the last
82
+ * line the commit changes in the unit. null when the commit shows none.
83
+ * @param {Concern} concern
84
+ * @param {readonly BodyLine[]} body
85
+ * @param {readonly number[]} own body indexes of the commit's own lines in the unit
86
+ */
87
+ export function anchorLine(concern, body, own) {
88
+ const pinned = bodyIndexes(body, concern.lines ?? []).filter((i) => own.includes(i));
89
+ return pinned.at(-1) ?? own.at(-1) ?? null;
90
+ }
91
+
92
+ /**
93
+ * The concerns that belong to a chunk: those whose pinned lines it holds, and unpinned ones on
94
+ * the first chunk of their unit.
95
+ * @param {readonly Concern[]} concerns the commit's
96
+ * @param {readonly Beat[]} beats
97
+ * @param {number} index the chunk's beat
98
+ * @param {readonly BodyLine[]} body of the chunk's unit
99
+ */
100
+ export function concernsInChunk(concerns, beats, index, body) {
101
+ const beat = beats[index];
102
+ if (beat?.kind !== "chunk") return [];
103
+ const first = beats.findIndex(
104
+ (b) => b.kind === "chunk" && b.commit === beat.commit && b.unit === beat.unit,
105
+ );
106
+ const held = new Set(beat.lines ?? []);
107
+ return concerns.filter((c) => {
108
+ if (c.unit !== beat.unit) return false;
109
+ if (c.lines === undefined || c.lines.length === 0) return first === index;
110
+ return bodyIndexes(body, c.lines).some((i) => held.has(i));
111
+ });
112
+ }
113
+
114
+ /**
115
+ * The concern y and x act on: the first undecided one of the chunk on screen, else of the commit.
116
+ * @param {readonly Concern[]} commitConcerns
117
+ * @param {readonly Concern[]} chunkConcerns
118
+ * @param {Readonly<Record<string, Decision>>} decisions
119
+ */
120
+ export function pickConcern(commitConcerns, chunkConcerns, decisions) {
121
+ return undecided(chunkConcerns, decisions)[0] ?? undecided(commitConcerns, decisions)[0] ?? null;
122
+ }
123
+
124
+ /**
125
+ * Undecided blockers to stop on, at the step that reveals their chunk, once each.
126
+ * @param {readonly Concern[]} inChunk
127
+ * @param {{ kind: string } | undefined} step
128
+ * @param {Readonly<Record<string, Decision>>} decisions
129
+ * @param {ReadonlySet<string>} seen
130
+ */
131
+ export function blockersToHold(inChunk, step, decisions, seen) {
132
+ if (step?.kind !== "show") return [];
133
+ return undecided(inChunk, decisions).filter(
134
+ (c) => severityOf(c, decisions) === "blocker" && !seen.has(c.id),
135
+ );
136
+ }
137
+
138
+ /**
139
+ * Extra milliseconds a chunk lingers so its undecided major and minor concerns can be read.
140
+ * Blockers pause playback instead.
141
+ * @param {readonly Concern[]} inChunk
142
+ * @param {Readonly<Record<string, Decision>>} decisions
143
+ * @param {number} speed
144
+ */
145
+ export function dwellExtra(inChunk, decisions, speed) {
146
+ return undecided(inChunk, decisions)
147
+ .filter((c) => severityOf(c, decisions) !== "blocker")
148
+ .reduce((sum, c) => sum + Math.round(readMs(c.text) / speed), 0);
149
+ }
150
+
151
+ /**
152
+ * The question asked when the review ends with concerns undecided.
153
+ * @param {number} count
154
+ */
155
+ export function undecidedPrompt(count) {
156
+ return `${count} agent concern${count === 1 ? "" : "s"} undecided. Undecided concerns are left out of the report.`;
157
+ }
158
+
159
+ /**
160
+ * The stage header's reminder, or null when nothing waits.
161
+ * @param {number} count
162
+ */
163
+ export function toDecide(count) {
164
+ return count === 0 ? null : `${count} agent concern${count === 1 ? "" : "s"} to decide`;
165
+ }
@@ -0,0 +1,221 @@
1
+ // @ts-check
2
+ // Pure syntax highlighting logic. The browser imports it from /highlight.js and the tests import
3
+ // it directly, so nothing here may touch the DOM. Prism itself is injected as a tokenize function.
4
+
5
+ /**
6
+ * @typedef {{ text: string, tone: string | null }} Span
7
+ * @typedef {{ type: string, content: unknown, alias?: string | string[] }} PrismToken
8
+ * @typedef {{ op: " " | "+" | "-", text: string }} BodyLine
9
+ */
10
+
11
+ /** Prism language per file extension. Anything else is shown plain. */
12
+ const BY_EXTENSION = new Map([
13
+ ["js", "javascript"],
14
+ ["mjs", "javascript"],
15
+ ["cjs", "javascript"],
16
+ ["jsx", "jsx"],
17
+ ["ts", "typescript"],
18
+ ["tsx", "tsx"],
19
+ ["json", "json"],
20
+ ["md", "markdown"],
21
+ ["css", "css"],
22
+ ["html", "markup"],
23
+ ["sh", "bash"],
24
+ ["bash", "bash"],
25
+ ["py", "python"],
26
+ ["go", "go"],
27
+ ["rs", "rust"],
28
+ ["rb", "ruby"],
29
+ ["java", "java"],
30
+ ["kt", "kotlin"],
31
+ ["c", "c"],
32
+ ["cpp", "cpp"],
33
+ ["h", "c"],
34
+ ["yaml", "yaml"],
35
+ ["yml", "yaml"],
36
+ ["toml", "toml"],
37
+ ["sql", "sql"],
38
+ ["swift", "swift"],
39
+ ["php", "php"],
40
+ ]);
41
+
42
+ /**
43
+ * Prism components to load for a language, dependencies first. html also takes the script and
44
+ * style grammars so inline code in a page is coloured.
45
+ */
46
+ const COMPONENTS = new Map([
47
+ ["javascript", ["clike", "javascript"]],
48
+ ["jsx", ["markup", "clike", "javascript", "jsx"]],
49
+ ["typescript", ["clike", "javascript", "typescript"]],
50
+ ["tsx", ["markup", "clike", "javascript", "jsx", "typescript", "tsx"]],
51
+ ["json", ["json"]],
52
+ ["markdown", ["markup", "markdown"]],
53
+ ["css", ["css"]],
54
+ ["markup", ["markup", "clike", "javascript", "css"]],
55
+ ["bash", ["bash"]],
56
+ ["python", ["python"]],
57
+ ["go", ["clike", "go"]],
58
+ ["rust", ["rust"]],
59
+ ["ruby", ["clike", "ruby"]],
60
+ ["java", ["clike", "java"]],
61
+ ["kotlin", ["clike", "kotlin"]],
62
+ ["c", ["clike", "c"]],
63
+ ["cpp", ["clike", "c", "cpp"]],
64
+ ["yaml", ["yaml"]],
65
+ ["toml", ["toml"]],
66
+ ["sql", ["sql"]],
67
+ ["swift", ["swift"]],
68
+ ["php", ["markup", "markup-templating", "php"]],
69
+ ]);
70
+
71
+ /**
72
+ * The Prism language for a repo path, or null when it should stay plain.
73
+ * @param {string} path
74
+ * @returns {string | null}
75
+ */
76
+ export function languageFor(path) {
77
+ const name = path.slice(path.lastIndexOf("/") + 1);
78
+ const dot = name.lastIndexOf(".");
79
+ if (dot <= 0) return null;
80
+ return BY_EXTENSION.get(name.slice(dot + 1).toLowerCase()) ?? null;
81
+ }
82
+
83
+ /**
84
+ * Prism component names to load, in order, to highlight a language. Empty for an unknown one.
85
+ * @param {string} language
86
+ * @returns {readonly string[]}
87
+ */
88
+ export function componentsFor(language) {
89
+ return COMPONENTS.get(language) ?? [];
90
+ }
91
+
92
+ /**
93
+ * Every component needed for a set of languages, each once, in an order that keeps dependencies
94
+ * first.
95
+ * @param {Iterable<string>} languages
96
+ */
97
+ export function componentsForAll(languages) {
98
+ const seen = new Set();
99
+ for (const language of languages) for (const name of componentsFor(language)) seen.add(name);
100
+ // Each list is dependency ordered, and a Set keeps first insertion, so dependencies stay first.
101
+ return [...seen];
102
+ }
103
+
104
+ /** Prism token types, and aliases, grouped under the few tones the page colours. */
105
+ const TONES = new Map([
106
+ ["comment", "comment"],
107
+ ["prolog", "comment"],
108
+ ["doctype", "comment"],
109
+ ["cdata", "comment"],
110
+ ["keyword", "keyword"],
111
+ ["atrule", "keyword"],
112
+ ["important", "keyword"],
113
+ ["string", "string"],
114
+ ["char", "string"],
115
+ ["attr-value", "string"],
116
+ ["template-string", "string"],
117
+ ["url", "string"],
118
+ ["regex", "string"],
119
+ ["number", "number"],
120
+ ["boolean", "number"],
121
+ ["constant", "number"],
122
+ ["symbol", "number"],
123
+ ["unit", "number"],
124
+ ["function", "function"],
125
+ ["class-name", "type"],
126
+ ["builtin", "type"],
127
+ ["namespace", "type"],
128
+ ["property", "property"],
129
+ ["attr-name", "property"],
130
+ ["variable", "property"],
131
+ ["parameter", "property"],
132
+ ["tag", "tag"],
133
+ ["selector", "tag"],
134
+ ["entity", "tag"],
135
+ ]);
136
+
137
+ /**
138
+ * The tone a run of text takes from its token types, innermost first. null leaves it plain.
139
+ * @param {readonly string[]} types
140
+ * @returns {string | null}
141
+ */
142
+ export function toneOf(types) {
143
+ for (const type of types) {
144
+ const tone = TONES.get(type);
145
+ if (tone !== undefined) return tone;
146
+ }
147
+ return null;
148
+ }
149
+
150
+ /** @param {unknown} value @returns {value is PrismToken} */
151
+ function isToken(value) {
152
+ return typeof value === "object" && value !== null && "type" in value && "content" in value;
153
+ }
154
+
155
+ /**
156
+ * @param {readonly unknown[]} stream
157
+ * @param {readonly string[]} inherited
158
+ * @param {{ text: string, types: readonly string[] }[]} out
159
+ */
160
+ function walk(stream, inherited, out) {
161
+ for (const item of stream) {
162
+ if (typeof item === "string") out.push({ text: item, types: inherited });
163
+ else if (isToken(item)) {
164
+ const alias = item.alias === undefined ? [] : [item.alias].flat();
165
+ const types = [item.type, ...alias, ...inherited];
166
+ walk(Array.isArray(item.content) ? item.content : [item.content], types, out);
167
+ }
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Splits a Prism token stream into lines of spans. Multi-line tokens, such as block comments,
173
+ * are cut at each newline so every line carries its own spans. Line ends are dropped.
174
+ * @param {readonly unknown[]} stream
175
+ * @returns {Span[][]}
176
+ */
177
+ export function spansByLine(stream) {
178
+ /** @type {{ text: string, types: readonly string[] }[]} */
179
+ const pieces = [];
180
+ walk(stream, [], pieces);
181
+ /** @type {Span[][]} */
182
+ const lines = [[]];
183
+ for (const piece of pieces) {
184
+ const tone = toneOf(piece.types);
185
+ piece.text.split("\n").forEach((part, i) => {
186
+ if (i > 0) lines.push([]);
187
+ const line = lines[lines.length - 1];
188
+ if (line === undefined || part === "") return;
189
+ const last = line.at(-1);
190
+ if (last?.tone === tone) last.text += part;
191
+ else line.push({ text: part, tone });
192
+ });
193
+ }
194
+ return lines;
195
+ }
196
+
197
+ /**
198
+ * Highlights every line of a hunk body, or returns null when the tokens do not add up to the
199
+ * text, in which case the caller shows plain lines. The old side (context and removed lines) and
200
+ * the new side (context and added lines) are tokenized apart, so a removed line never changes
201
+ * how an added one reads. Context takes its colours from the new side.
202
+ * @param {readonly BodyLine[]} body
203
+ * @param {(text: string) => readonly unknown[]} tokenize
204
+ * @returns {Span[][] | null}
205
+ */
206
+ export function highlightBody(body, tokenize) {
207
+ /** @param {(line: BodyLine) => boolean} keep */
208
+ const side = (keep) => {
209
+ const indexes = body.flatMap((line, i) => (keep(line) ? [i] : []));
210
+ const texts = indexes.map((i) => (body[i]?.text ?? "").replace(/\n$/, ""));
211
+ if (texts.length === 0) return new Map();
212
+ const lines = spansByLine(tokenize(texts.join("\n")));
213
+ if (lines.length !== texts.length) return null;
214
+ const ok = lines.every((spans, k) => spans.map((s) => s.text).join("") === texts[k]);
215
+ return ok ? new Map(indexes.map((i, k) => [i, lines[k] ?? []])) : null;
216
+ };
217
+ const before = side((line) => line.op !== "+");
218
+ const after = side((line) => line.op !== "-");
219
+ if (before === null || after === null) return null;
220
+ return body.map((line, i) => (line.op === "-" ? before.get(i) : after.get(i)) ?? []);
221
+ }
@@ -18,23 +18,77 @@
18
18
  </button>
19
19
  <button id="end" type="button" class="primary">End review</button>
20
20
  </header>
21
+ <div id="banner" class="banner" role="status" hidden>
22
+ <span id="banner-text"></span>
23
+ <button id="banner-show" type="button" hidden>Show answers</button>
24
+ <button id="banner-close" type="button">Dismiss</button>
25
+ </div>
21
26
  <section id="player" class="player" aria-label="Playback" hidden>
22
- <button id="play" type="button" class="primary" title="Play or pause (space)">Play</button>
23
- <button id="prev" type="button" title="Previous commit (p)">Previous</button>
24
- <button id="next" type="button" title="Next commit (n)">Next</button>
27
+ <button id="play" type="button" class="primary play" title="Play or pause (space)">
28
+ <span class="icon" aria-hidden="true"></span><span class="label"></span>
29
+ </button>
30
+ <fieldset class="steps" aria-label="Step">
31
+ <button id="prev-commit" type="button" title="Restart this commit, press again for the previous one (p)">⏮ Commit</button>
32
+ <button id="prev-beat" type="button" title="Previous chunk (left arrow)">◀ Chunk</button>
33
+ <button id="next-beat" type="button" title="Next chunk (right arrow)">Chunk ▶</button>
34
+ <button id="next-commit" type="button" title="Next commit (n)">Commit ⏭</button>
35
+ </fieldset>
25
36
  <label>Speed <select id="speed"></select></label>
26
- <label>Voice <select id="voice"></select></label>
27
- <button id="mute" type="button" aria-pressed="false">Mute</button>
28
- <p id="caption" class="caption" aria-live="polite"></p>
37
+ <label id="voice-label">Voice <select id="voice"></select></label>
38
+ <button id="mute" type="button" aria-pressed="false" title="Mute (m)">Mute</button>
39
+ <button id="keys-button" type="button" title="Keyboard shortcuts (?)" aria-label="Keyboard shortcuts">?</button>
40
+ <fieldset id="strip" class="strip" aria-label="Progress"></fieldset>
29
41
  </section>
30
42
  <div id="app" class="layout">
31
- <nav class="sidebar" aria-label="Commits">
43
+ <nav id="sidebar" class="sidebar" aria-label="Commits">
44
+ <button id="commits-toggle" type="button" class="commits-toggle" aria-expanded="false" aria-controls="commits">
45
+ Commits
46
+ </button>
32
47
  <ol id="commits" class="commit-list"></ol>
48
+ <section id="answers" class="answers" aria-label="Answers" hidden></section>
33
49
  <section id="plan-note" class="plan-note"></section>
34
50
  </nav>
35
51
  <main id="main" class="main"></main>
52
+ <aside id="diagram" class="diagram-panel" aria-label="Diagram" hidden>
53
+ <div class="diagram-head">
54
+ <button id="diagram-toggle" type="button" aria-expanded="true" aria-controls="diagram-body">
55
+ Diagram
56
+ </button>
57
+ <span id="diagram-from" class="muted"></span>
58
+ </div>
59
+ <div id="diagram-body" class="diagram-body"></div>
60
+ </aside>
36
61
  </div>
62
+ <div id="rail-tip" class="rail-tip" role="tooltip" hidden></div>
37
63
  <div id="done" class="done" hidden></div>
64
+ <dialog id="confirm-end" class="keys" aria-labelledby="confirm-title">
65
+ <h2 id="confirm-title">End this review?</h2>
66
+ <p id="confirm-text"></p>
67
+ <form method="dialog" class="row">
68
+ <button id="confirm-ok" type="submit" value="end" class="primary">End review</button>
69
+ <button id="confirm-keep-all" type="submit" value="keep-all" hidden>Keep all</button>
70
+ <button id="confirm-dismiss-all" type="submit" value="dismiss-all" hidden>Dismiss all</button>
71
+ <button id="confirm-back" type="submit" value="back">Keep reviewing</button>
72
+ </form>
73
+ </dialog>
74
+ <dialog id="keys" class="keys" aria-labelledby="keys-title">
75
+ <h2 id="keys-title">Keyboard shortcuts</h2>
76
+ <dl>
77
+ <dt><kbd>Space</kbd></dt><dd>Play or pause</dd>
78
+ <dt><kbd>→</kbd> <kbd>l</kbd></dt><dd>Next chunk</dd>
79
+ <dt><kbd>←</kbd> <kbd>h</kbd></dt><dd>Previous chunk</dd>
80
+ <dt><kbd>n</kbd> <kbd>j</kbd> <kbd>Shift →</kbd></dt><dd>Next commit</dd>
81
+ <dt><kbd>p</kbd> <kbd>k</kbd> <kbd>Shift ←</kbd></dt><dd>Restart this commit, twice for the previous one</dd>
82
+ <dt><kbd>r</kbd></dt><dd>Replay the narration</dd>
83
+ <dt><kbd>c</kbd></dt><dd>Request a change on the current chunk</dd>
84
+ <dt><kbd>q</kbd></dt><dd>Ask a question about the current chunk</dd>
85
+ <dt><kbd>y</kbd></dt><dd>Keep the agent's concern on this chunk</dd>
86
+ <dt><kbd>x</kbd></dt><dd>Dismiss it</dd>
87
+ <dt><kbd>m</kbd></dt><dd>Mute</dd>
88
+ <dt><kbd>⌘ Enter</kbd></dt><dd>Save a comment, <kbd>Esc</kbd> cancels</dd>
89
+ </dl>
90
+ <form method="dialog"><button type="submit">Close</button></form>
91
+ </dialog>
38
92
  <script src="/app.js" type="module"></script>
39
93
  </body>
40
94
  </html>
@@ -0,0 +1,184 @@
1
+ // @ts-check
2
+ // Pure logic for live updates: folding server events into the page state, deciding which commit
3
+ // to stay on across a plan swap, and the reconnect delay. No DOM, no fetch.
4
+
5
+ /**
6
+ * @typedef {{ id: string, author: string, body: string, diagram?: string, createdAt: string }} Reply
7
+ * @typedef {{ id: string, replies?: Reply[], kind?: string, body?: string }} LiveComment
8
+ * @typedef {{ concern: string, decision: "kept" | "dismissed", severity?: string, note?: string }} Decision
9
+ * @typedef {{ comments: LiveComment[], decisions?: Record<string, Decision>, resolved?: string[],
10
+ * version: number, [key: string]: unknown }} LiveState
11
+ * @typedef {{ type: string, seq?: number, data?: unknown, [key: string]: unknown }} LiveEvent
12
+ * @typedef {null | "refetch" | "ended"} Effect
13
+ */
14
+
15
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
16
+ function isRecord(value) {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+
20
+ /** @param {unknown} value @returns {value is Reply} */
21
+ function isReply(value) {
22
+ return isRecord(value) && typeof value["id"] === "string";
23
+ }
24
+
25
+ /** @param {unknown} value @returns {value is LiveComment} */
26
+ function isComment(value) {
27
+ return (
28
+ isRecord(value) &&
29
+ typeof value["id"] === "string" &&
30
+ (value["replies"] === undefined || Array.isArray(value["replies"]))
31
+ );
32
+ }
33
+
34
+ /**
35
+ * The decisions the server stores as a list, keyed by concern id.
36
+ * @param {readonly Decision[]} list
37
+ * @returns {Record<string, Decision>}
38
+ */
39
+ export function decisionsById(list) {
40
+ return Object.fromEntries(list.map((d) => [d.concern, d]));
41
+ }
42
+
43
+ /**
44
+ * An event's fields. The server may nest them under data or flatten them beside type.
45
+ * @param {LiveEvent} event
46
+ * @returns {Record<string, unknown>}
47
+ */
48
+ function payload(event) {
49
+ return isRecord(event.data) ? event.data : event;
50
+ }
51
+
52
+ /**
53
+ * Inserts or replaces by id, keeping the position of an existing item. Replaying an event is
54
+ * therefore harmless, which matters because the first poll asks for everything.
55
+ * @template {{ id: string }} T
56
+ * @param {readonly T[]} list
57
+ * @param {T} item
58
+ * @returns {T[]}
59
+ */
60
+ function upsert(list, item) {
61
+ const at = list.findIndex((x) => x.id === item.id);
62
+ if (at === -1) return [...list, item];
63
+ return list.map((x, i) => (i === at ? item : x));
64
+ }
65
+
66
+ /**
67
+ * Folds one server event into the page state. Returns a new state, and an effect the caller must
68
+ * carry out: "refetch" when a newer plan version arrived, "ended" when the review is over.
69
+ * @param {LiveState} state
70
+ * @param {LiveEvent} event
71
+ * @returns {{ state: LiveState, effect: Effect }}
72
+ */
73
+ export function applyEvent(state, event) {
74
+ const body = payload(event);
75
+ switch (event.type) {
76
+ case "comment":
77
+ case "comment-updated": {
78
+ const comment = body["comment"] ?? body;
79
+ if (!isComment(comment)) return { state, effect: null };
80
+ const existing = state.comments.find((c) => c.id === comment.id);
81
+ // An update that omits the thread must not erase replies the page already holds.
82
+ const replies = comment.replies ?? existing?.replies;
83
+ const next = replies === undefined ? comment : { ...comment, replies };
84
+ return { state: { ...state, comments: upsert(state.comments, next) }, effect: null };
85
+ }
86
+ case "comment-deleted": {
87
+ const id = body["id"] ?? body["commentId"];
88
+ return {
89
+ state: { ...state, comments: state.comments.filter((c) => c.id !== id) },
90
+ effect: null,
91
+ };
92
+ }
93
+ case "reply": {
94
+ const reply = body["reply"];
95
+ const target = body["commentId"];
96
+ if (!isReply(reply)) return { state, effect: null };
97
+ const comments = state.comments.map((c) =>
98
+ c.id === target ? { ...c, replies: upsert(c.replies ?? [], reply) } : c,
99
+ );
100
+ return { state: { ...state, comments }, effect: null };
101
+ }
102
+ case "concern": {
103
+ const id = body["concernId"];
104
+ const verdict = body["decision"];
105
+ if (typeof id !== "string" || (verdict !== "kept" && verdict !== "dismissed")) {
106
+ return { state, effect: null };
107
+ }
108
+ /** @type {Decision} */
109
+ const decision = { concern: id, decision: verdict };
110
+ if (typeof body["severity"] === "string") decision.severity = body["severity"];
111
+ if (typeof body["note"] === "string") decision.note = body["note"];
112
+ return {
113
+ state: { ...state, decisions: { ...state.decisions, [id]: decision } },
114
+ effect: null,
115
+ };
116
+ }
117
+ case "resolved": {
118
+ const id = body["commentId"];
119
+ if (typeof id !== "string" || typeof body["resolved"] !== "boolean") {
120
+ return { state, effect: null };
121
+ }
122
+ const others = (state.resolved ?? []).filter((x) => x !== id);
123
+ return {
124
+ state: { ...state, resolved: body["resolved"] ? [...others, id] : others },
125
+ effect: null,
126
+ };
127
+ }
128
+ case "version": {
129
+ const version = body["version"];
130
+ const newer = typeof version === "number" && version > state.version;
131
+ return { state, effect: newer ? "refetch" : null };
132
+ }
133
+ case "ended":
134
+ return { state, effect: "ended" };
135
+ default:
136
+ return { state, effect: null };
137
+ }
138
+ }
139
+
140
+ /**
141
+ * The seq to ask for next: the highest the server reported, never going backwards.
142
+ * @param {number} since
143
+ * @param {{ seq?: unknown, events?: readonly LiveEvent[] }} reply
144
+ */
145
+ export function nextSince(since, reply) {
146
+ const seqs = (reply.events ?? []).map((e) => e.seq).filter((n) => typeof n === "number");
147
+ const reported = typeof reply.seq === "number" ? reply.seq : 0;
148
+ return Math.max(since, reported, ...seqs);
149
+ }
150
+
151
+ const BACKOFF_FIRST_MS = 500;
152
+ const BACKOFF_MAX_MS = 15_000;
153
+
154
+ /**
155
+ * How long to wait before reconnecting after the given number of failures in a row.
156
+ * @param {number} failures at least 1
157
+ */
158
+ export function backoffMs(failures) {
159
+ return Math.min(BACKOFF_MAX_MS, BACKOFF_FIRST_MS * 2 ** Math.max(0, failures - 1));
160
+ }
161
+
162
+ /**
163
+ * The commit to stay on after a plan swap: the one with the same title, nearest the old index
164
+ * when titles repeat. Falls back to the first commit when it no longer exists.
165
+ * @param {string | null} title first line of the commit on screen
166
+ * @param {number} index its old position
167
+ * @param {readonly { message: string }[]} commits
168
+ */
169
+ export function keepCommit(title, index, commits) {
170
+ if (title === null) return 0;
171
+ const same = commits.flatMap((c, i) => (c.message.split("\n")[0] === title ? [i] : []));
172
+ if (same.length === 0) return 0;
173
+ return same.reduce((best, i) => (Math.abs(i - index) < Math.abs(best - index) ? i : best));
174
+ }
175
+
176
+ /**
177
+ * The banner for a new plan version: "Plan v6 arrived, 3 questions answered".
178
+ * @param {number} version
179
+ * @param {number} answered
180
+ */
181
+ export function bannerText(version, answered) {
182
+ const tail = answered === 0 ? "" : `, ${answered} question${answered === 1 ? "" : "s"} answered`;
183
+ return `Plan v${version} arrived${tail}`;
184
+ }