@shiyuphan/follow-me 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -56
- package/dist/src/anthropic.js +41 -9
- package/dist/src/cli.js +836 -82
- package/dist/src/comments.js +300 -0
- package/dist/src/git.js +103 -24
- package/dist/src/harness.js +74 -0
- package/dist/src/host.js +379 -0
- package/dist/src/install.js +131 -0
- package/dist/src/naive.js +26 -0
- package/dist/src/plan.js +281 -25
- package/dist/src/prompt.js +391 -82
- package/dist/src/server.js +710 -160
- package/dist/src/session.js +311 -104
- package/dist/src/syntax.js +199 -0
- package/dist/src/types.js +5 -2
- package/dist/ui/app.js +1703 -272
- package/dist/ui/concerns.js +165 -0
- package/dist/ui/highlight.js +221 -0
- package/dist/ui/index.html +62 -7
- package/dist/ui/live.js +184 -0
- package/dist/ui/playback.js +480 -76
- package/dist/ui/questions.js +189 -0
- package/dist/ui/style.css +975 -75
- package/dist/ui/summary.js +45 -0
- package/package.json +4 -2
- package/skills/follow-me/SKILL.md +34 -16
|
@@ -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
|
+
}
|
package/dist/ui/index.html
CHANGED
|
@@ -11,29 +11,84 @@
|
|
|
11
11
|
<header class="topbar">
|
|
12
12
|
<strong>follow-me</strong>
|
|
13
13
|
<span id="summary" class="muted"></span>
|
|
14
|
+
<nav id="sessions" class="sessions" aria-label="Sessions" hidden></nav>
|
|
14
15
|
<span id="status" class="status" role="status"></span>
|
|
15
16
|
<button id="view-toggle" type="button" title="Play the branch commit by commit (space)">
|
|
16
17
|
Play
|
|
17
18
|
</button>
|
|
18
19
|
<button id="end" type="button" class="primary">End review</button>
|
|
19
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>
|
|
20
26
|
<section id="player" class="player" aria-label="Playback" hidden>
|
|
21
|
-
<button id="play" type="button" class="primary" title="Play or pause (space)">
|
|
22
|
-
|
|
23
|
-
|
|
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>
|
|
24
36
|
<label>Speed <select id="speed"></select></label>
|
|
25
|
-
<label>Voice <select id="voice"></select></label>
|
|
26
|
-
<button id="mute" type="button" aria-pressed="false">Mute</button>
|
|
27
|
-
<
|
|
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>
|
|
28
41
|
</section>
|
|
29
42
|
<div id="app" class="layout">
|
|
30
|
-
<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>
|
|
31
47
|
<ol id="commits" class="commit-list"></ol>
|
|
48
|
+
<section id="answers" class="answers" aria-label="Answers" hidden></section>
|
|
32
49
|
<section id="plan-note" class="plan-note"></section>
|
|
33
50
|
</nav>
|
|
34
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>
|
|
35
61
|
</div>
|
|
62
|
+
<div id="rail-tip" class="rail-tip" role="tooltip" hidden></div>
|
|
36
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>
|
|
37
92
|
<script src="/app.js" type="module"></script>
|
|
38
93
|
</body>
|
|
39
94
|
</html>
|
package/dist/ui/live.js
ADDED
|
@@ -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
|
+
}
|