bottleship 0.1.0-prototype.1 → 0.1.0-prototype.2

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.
Files changed (165) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +118 -117
  3. package/dist/src/boxes.d.ts +41 -39
  4. package/dist/src/boxes.js +181 -179
  5. package/dist/src/cli.js +298 -84
  6. package/dist/src/config.d.ts +3 -6
  7. package/dist/src/config.js +19 -85
  8. package/dist/src/init.d.ts +2 -4
  9. package/dist/src/init.js +92 -228
  10. package/dist/src/local.d.ts +8 -0
  11. package/dist/src/local.js +24 -0
  12. package/dist/src/onboard.d.ts +5 -0
  13. package/dist/src/onboard.js +25 -0
  14. package/dist/src/paths.d.ts +0 -1
  15. package/dist/src/paths.js +0 -1
  16. package/dist/src/provider.d.ts +16 -0
  17. package/dist/src/provider.js +81 -0
  18. package/dist/src/repo.d.ts +21 -0
  19. package/dist/src/repo.js +75 -0
  20. package/dist/src/review/comments.d.ts +66 -0
  21. package/dist/src/review/comments.js +344 -0
  22. package/dist/src/review/contract.d.ts +82 -0
  23. package/dist/src/review/contract.js +189 -0
  24. package/dist/src/review/github.d.ts +120 -0
  25. package/dist/src/review/github.js +160 -0
  26. package/dist/src/review/local.d.ts +14 -0
  27. package/dist/src/review/local.js +70 -0
  28. package/dist/src/review/loop.d.ts +12 -0
  29. package/dist/src/review/loop.js +119 -0
  30. package/dist/src/review/pr.d.ts +14 -0
  31. package/dist/src/review/pr.js +68 -0
  32. package/dist/src/review/prompt.d.ts +15 -0
  33. package/dist/src/review/prompt.js +48 -0
  34. package/dist/src/review/serve.d.ts +1 -0
  35. package/dist/src/review/serve.js +8 -0
  36. package/dist/src/review/server.d.ts +14 -0
  37. package/dist/src/review/server.js +484 -0
  38. package/dist/src/review/setting-up.d.ts +4 -0
  39. package/dist/src/review/setting-up.js +74 -0
  40. package/dist/src/review/start.d.ts +32 -0
  41. package/dist/src/review/start.js +140 -0
  42. package/dist/src/review/target.d.ts +20 -0
  43. package/dist/src/review/target.js +66 -0
  44. package/dist/src/review/template.d.ts +3 -0
  45. package/dist/src/review/template.js +13 -0
  46. package/dist/src/review/terminal.d.ts +4 -0
  47. package/dist/src/review/terminal.js +91 -0
  48. package/dist/src/types.d.ts +1 -71
  49. package/dist/src/ui.d.ts +6 -1
  50. package/dist/src/ui.js +22 -23
  51. package/dist/src/util.d.ts +11 -0
  52. package/dist/src/util.js +40 -0
  53. package/package.json +7 -14
  54. package/presets/agents/claude.json +1 -5
  55. package/presets/agents/codex.json +1 -10
  56. package/presets/agents/cursor.json +1 -9
  57. package/presets/agents/opencode.json +1 -14
  58. package/presets/providers/docker/create +12 -0
  59. package/presets/providers/docker/destroy +2 -0
  60. package/presets/providers/docker/exec +2 -0
  61. package/presets/providers/docker/list +4 -0
  62. package/presets/providers/docker/status +2 -0
  63. package/presets/providers/docker/url +4 -0
  64. package/review-app/KIT.md +106 -0
  65. package/review-app/WRITING.md +93 -0
  66. package/review-app/examples/rply-16.tsx +98 -0
  67. package/review-app/index.html +15 -0
  68. package/review-app/package-lock.json +2539 -0
  69. package/review-app/package.json +24 -0
  70. package/review-app/playground/fixtures/excalidraw-12602/data/box.json +1 -0
  71. package/review-app/playground/fixtures/excalidraw-12602/data/pr.diff +91 -0
  72. package/review-app/playground/fixtures/excalidraw-12602/data/pr.json +62 -0
  73. package/review-app/playground/fixtures/excalidraw-12602/data/review-comments.json +44 -0
  74. package/review-app/playground/fixtures/excalidraw-12602/events.json +1 -0
  75. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-ar-zoom.png +0 -0
  76. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-en-US-zoom.png +0 -0
  77. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-IL-zoom.png +0 -0
  78. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-dark.png +0 -0
  79. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-hover.png +0 -0
  80. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-page.png +0 -0
  81. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-tip.png +0 -0
  82. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-zen.png +0 -0
  83. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-ar-zoom.png +0 -0
  84. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-en-US-zoom.png +0 -0
  85. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-IL-zoom.png +0 -0
  86. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-dark.png +0 -0
  87. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-hover.png +0 -0
  88. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-page.png +0 -0
  89. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-tip.png +0 -0
  90. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-zen.png +0 -0
  91. package/review-app/playground/fixtures/excalidraw-12602/instances.json +1 -0
  92. package/review-app/playground/fixtures/excalidraw-12602/review.tsx +103 -0
  93. package/review-app/playground/fixtures/rply-16/data/box.json +1 -0
  94. package/review-app/playground/fixtures/rply-16/data/hooks.diff +28 -0
  95. package/review-app/playground/fixtures/rply-16/data/pr.diff +114 -0
  96. package/review-app/playground/fixtures/rply-16/data/pr.json +68 -0
  97. package/review-app/playground/fixtures/rply-16/data/review-comments.json +1 -0
  98. package/review-app/playground/fixtures/rply-16/events.json +156 -0
  99. package/review-app/playground/fixtures/rply-16/instances.json +1 -0
  100. package/review-app/playground/fixtures/rply-16/review.tsx +98 -0
  101. package/review-app/playground/gallery.tsx +127 -0
  102. package/review-app/playground/index.html +15 -0
  103. package/review-app/playground/main.tsx +26 -0
  104. package/review-app/playground/mock.ts +215 -0
  105. package/review-app/playground/playground.css +18 -0
  106. package/review-app/playground/vite.config.ts +32 -0
  107. package/review-app/scripts/check.cjs +47 -0
  108. package/review-app/scripts/clip.cjs +104 -0
  109. package/review-app/scripts/shot.cjs +66 -0
  110. package/review-app/src/App.tsx +132 -0
  111. package/review-app/src/data/box.json +1 -0
  112. package/review-app/src/data/hooks.diff +28 -0
  113. package/review-app/src/data/pr.diff +114 -0
  114. package/review-app/src/data/pr.json +68 -0
  115. package/review-app/src/data/review-comments.json +1 -0
  116. package/review-app/src/kit/agent.tsx +287 -0
  117. package/review-app/src/kit/chapter.tsx +178 -0
  118. package/review-app/src/kit/code.tsx +217 -0
  119. package/review-app/src/kit/comments.tsx +377 -0
  120. package/review-app/src/kit/finish.tsx +274 -0
  121. package/review-app/src/kit/flow.tsx +184 -0
  122. package/review-app/src/kit/index.ts +10 -0
  123. package/review-app/src/kit/kit.css +696 -0
  124. package/review-app/src/kit/live.ts +91 -0
  125. package/review-app/src/kit/media.tsx +348 -0
  126. package/review-app/src/kit/overview.tsx +161 -0
  127. package/review-app/src/kit/pr.ts +100 -0
  128. package/review-app/src/kit/pullrequest.tsx +298 -0
  129. package/review-app/src/kit/review.ts +74 -0
  130. package/review-app/src/kit/theme.ts +30 -0
  131. package/review-app/src/kit/ui.tsx +127 -0
  132. package/review-app/src/main.tsx +6 -0
  133. package/review-app/src/review.tsx +22 -0
  134. package/review-app/tsconfig.json +17 -0
  135. package/review-app/vite.config.ts +12 -0
  136. package/dist/presets/providers/command.d.ts +0 -3
  137. package/dist/presets/providers/command.js +0 -39
  138. package/dist/presets/providers/daytona.d.ts +0 -3
  139. package/dist/presets/providers/daytona.js +0 -173
  140. package/dist/presets/providers/docker.d.ts +0 -3
  141. package/dist/presets/providers/docker.js +0 -31
  142. package/dist/presets/providers/e2b.d.ts +0 -3
  143. package/dist/presets/providers/e2b.js +0 -124
  144. package/dist/presets/providers/exe.d.ts +0 -3
  145. package/dist/presets/providers/exe.js +0 -28
  146. package/dist/presets/providers/http.example.d.ts +0 -3
  147. package/dist/presets/providers/http.example.js +0 -47
  148. package/dist/presets/providers/local.d.ts +0 -3
  149. package/dist/presets/providers/local.js +0 -52
  150. package/dist/presets/providers/modal.d.ts +0 -3
  151. package/dist/presets/providers/modal.js +0 -117
  152. package/dist/presets/providers/ssh.d.ts +0 -3
  153. package/dist/presets/providers/ssh.js +0 -49
  154. package/dist/presets/providers/util.d.ts +0 -13
  155. package/dist/presets/providers/util.js +0 -102
  156. package/presets/providers/command.ts +0 -43
  157. package/presets/providers/daytona.ts +0 -181
  158. package/presets/providers/docker.ts +0 -38
  159. package/presets/providers/e2b.ts +0 -130
  160. package/presets/providers/exe.ts +0 -35
  161. package/presets/providers/http.example.ts +0 -64
  162. package/presets/providers/local.ts +0 -56
  163. package/presets/providers/modal.ts +0 -121
  164. package/presets/providers/ssh.ts +0 -54
  165. package/presets/providers/util.ts +0 -112
@@ -0,0 +1,274 @@
1
+ // The end of the review. On a PR: the reviewer's pending comments on GitHub, each with its lines and editable here, and a
2
+ // panel with how far they got, a summary and a verdict, submitted.
3
+ // On local changes: what went to the developer's agent, and telling it the reviewer is done.
4
+ import { useEffect, useRef, useState } from "react";
5
+ import { Body, Composer, dropComment, editComment, finishReview, openCount, rowAt, useComments, type ReviewComment } from "./comments";
6
+ import { highlight, isReviewed } from "./code";
7
+ import { diffs, rows, type Row } from "./pr";
8
+ import { liveVerdict, saveVerdict, useLive, type Verdict } from "./live";
9
+ import { pr } from "./pr";
10
+ import { seenChapters, useNav } from "./review";
11
+ import { Section } from "./ui";
12
+
13
+ const VERDICTS: { event: NonNullable<Verdict["event"]>; name: string; hint: string }[] = [
14
+ { event: "COMMENT", name: "Comment", hint: "Feedback without a verdict." },
15
+ { event: "APPROVE", name: "Approve", hint: "Ready to merge." },
16
+ { event: "REQUEST_CHANGES", name: "Request changes", hint: "Needs another pass before it merges." },
17
+ ];
18
+
19
+ // the lines a comment is on, as the diff shows them; a few at most
20
+ function linesOf(c: ReviewComment): Row[] {
21
+ if (!c.path || !c.side || !c.line) return [];
22
+ const all = rows(diffs[c.path] ?? "");
23
+ const end = rowAt(all, c.side, c.line);
24
+ const start = c.startLine ? rowAt(all, c.startSide ?? c.side, c.startLine) : end;
25
+ return start < 0 || end < 0 ? [] : all.slice(Math.max(start, end - 5), end + 1);
26
+ }
27
+
28
+ // a comment of the reviewer's on GitHub, with its lines; pending ones are edited or deleted right here
29
+ function Card({ c }: { c: ReviewComment }) {
30
+ const [editing, setEditing] = useState(false);
31
+ const [error, setError] = useState<string>();
32
+ const lines = linesOf(c);
33
+ const pending = c.github?.state === "pending";
34
+ return (
35
+ <div className={`fin-c${pending ? "" : " posted"}`}>
36
+ {lines.length > 0 && (
37
+ <div className="fin-snip">
38
+ {lines.map((r, i) => <div key={i} className={r.t === "c" ? "" : r.t}><span className="n">{r.t === "d" ? r.o : r.n}</span><span className="s">{highlight(r.s)}{r.s ? "" : " "}</span></div>)}
39
+ </div>
40
+ )}
41
+ {editing ? (
42
+ <Composer edit initial={c.body} onCancel={() => setEditing(false)} onSave={async (b) => { const e = await editComment(c.id, b); if (!e) setEditing(false); return e; }} />
43
+ ) : (
44
+ <>
45
+ <Body text={c.body} />
46
+ <div className="fin-bar">
47
+ <span className="fin-tags">
48
+ <span className={`note-tag${pending ? " pending" : ""}`}>{pending ? "Pending" : "Posted"}</span>
49
+ {c.agent && <span className="note-tag">Drafted by the agent</span>}
50
+ {c.inReplyTo && <span className="note-tag">Reply</span>}
51
+ </span>
52
+ {pending ? (
53
+ <>
54
+ <button className="btn ghost sm" onClick={() => setEditing(true)}>Edit</button>
55
+ <button className="btn ghost sm" onClick={async () => setError(await dropComment(c.id))}>Delete</button>
56
+ </>
57
+ ) : c.github?.url ? <a className="link" href={c.github.url} target="_blank" rel="noreferrer">On GitHub</a> : null}
58
+ </div>
59
+ </>
60
+ )}
61
+ {error && <div className="composer-error" role="alert">{error}</div>}
62
+ </div>
63
+ );
64
+ }
65
+
66
+ export function Finish() {
67
+ const all = useComments();
68
+ if (!pr.number) return <LocalFinish />;
69
+ const mine = all.filter((c) => c.by === "reviewer" && c.to === "github");
70
+ const files = [...new Set(mine.map((c) => c.path!))];
71
+ return (
72
+ <section className="view fin">
73
+ <div className="fin-main">
74
+ <h1 className="title">Finish the review</h1>
75
+ {files.length ? files.map((f) => (
76
+ <div className="fin-file" key={f}>
77
+ <div className="fin-fh"><b>{f.split("/").pop()}</b><span>{mine.filter((c) => c.path === f).length}</span></div>
78
+ {mine.filter((c) => c.path === f).map((c) => <Card key={c.id} c={c} />)}
79
+ </div>
80
+ )) : <p className="prose">No comments yet. In Changes, press the + next to a line, or drag it over several.</p>}
81
+ </div>
82
+ <Submit pending={openCount(all)} />
83
+ </section>
84
+ );
85
+ }
86
+
87
+ // how far the reviewer got: chapters opened, and files marked reviewed in Changes
88
+ function Meters() {
89
+ const { review } = useNav();
90
+ const seen = review.chapters.filter((c) => seenChapters.has(c.id)).length;
91
+ const done = (path: string) => {
92
+ const gs = review.groups.filter((g) => g.file === path);
93
+ return gs.length ? gs.every((g) => isReviewed(g.id)) : isReviewed(`file-${path}`);
94
+ };
95
+ const reviewed = pr.files.filter((f) => done(f.path)).length;
96
+ const meter = (n: number, of: number, label: string) => (
97
+ <div className="meter">
98
+ <div>{n} / {of} {label}</div>
99
+ <div className="track">{Array.from({ length: of }, (_, i) => <i key={i} className={i < n ? "on" : ""} />)}</div>
100
+ </div>
101
+ );
102
+ return <div className="meters">{meter(seen, review.chapters.length, "chapters")}{meter(reviewed, pr.files.length, "files")}</div>;
103
+ }
104
+
105
+ // the summary saves as it is written; a summary saved elsewhere fills an empty field
106
+ function useSummary() {
107
+ const { verdict } = useLive();
108
+ const [body, setBody] = useState(verdict.body ?? "");
109
+ const typed = useRef(false);
110
+ useEffect(() => {
111
+ if (!typed.current) setBody(verdict.body ?? "");
112
+ }, [verdict.body]);
113
+ useEffect(() => {
114
+ if (!typed.current) return;
115
+ // onto the verdict as it is when the save lands, so a verdict chosen meanwhile is kept
116
+ const t = setTimeout(() => void saveVerdict({ ...liveVerdict(), body: body || undefined }), 400);
117
+ return () => clearTimeout(t);
118
+ }, [body]);
119
+ return { body, setBody: (b: string) => ((typed.current = true), setBody(b)), verdict };
120
+ }
121
+
122
+ async function post(path: string): Promise<{ ok: true; data: any } | { ok: false; error: string }> {
123
+ try {
124
+ const r = await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
125
+ const data = await r.json().catch(() => ({}));
126
+ return r.ok ? { ok: true, data } : { ok: false, error: (data as { errors?: string[] }).errors?.join(", ") ?? `The review server answered ${r.status}.` };
127
+ } catch {
128
+ return { ok: false, error: "The review server is not answering." };
129
+ }
130
+ }
131
+
132
+ // submitting takes a second click: it publishes the review under the reviewer's name
133
+ function Submit({ pending }: { pending: number }) {
134
+ const { body, setBody, verdict } = useSummary();
135
+ const [busy, setBusy] = useState(false);
136
+ const [confirm, setConfirm] = useState(false);
137
+ const [done, setDone] = useState<string>();
138
+ const [error, setError] = useState<string>();
139
+ const ref = useRef<HTMLTextAreaElement>(null);
140
+ // the summary grows with what is written
141
+ useEffect(() => {
142
+ const t = ref.current;
143
+ if (t) {
144
+ t.style.height = "auto";
145
+ t.style.height = `${Math.max(t.scrollHeight, 96)}px`;
146
+ }
147
+ }, [body]);
148
+ const chosen = VERDICTS.find((v) => v.event === verdict.event);
149
+ const go = async () => {
150
+ setBusy(true);
151
+ setError(undefined);
152
+ await saveVerdict({ ...verdict, body: body || undefined });
153
+ const r = await post("api/submit");
154
+ setBusy(false);
155
+ setConfirm(false);
156
+ if (r.ok) setDone(r.data.url);
157
+ else setError(r.error);
158
+ };
159
+ if (done)
160
+ return (
161
+ <aside className="fin-panel">
162
+ <h2>Submitted{chosen ? ` as ${chosen.name}` : ""}</h2>
163
+ <p className="prose">Your comments and summary are on the PR. <a className="link" href={done} target="_blank" rel="noreferrer">Open it on GitHub</a></p>
164
+ </aside>
165
+ );
166
+ return (
167
+ <aside className="fin-panel">
168
+ <h2>Finish your review</h2>
169
+ <Meters />
170
+ <textarea ref={ref} className="field" value={body} placeholder="Leave a summary" aria-label="Summary" onChange={(e) => setBody(e.target.value)} />
171
+ <div className="vswitch" role="radiogroup" aria-label="Verdict">
172
+ {VERDICTS.map((v) => (
173
+ <button key={v.event} role="radio" aria-checked={verdict.event === v.event} title={v.hint} onClick={() => { setConfirm(false); void saveVerdict({ ...verdict, body: body || undefined, event: v.event }); }}>{v.name}</button>
174
+ ))}
175
+ </div>
176
+ {error && <p className="prose post-error" role="alert">{error}</p>}
177
+ <div className="fin-actions">
178
+ {confirm ? (
179
+ <>
180
+ <button className="btn" disabled={busy} onClick={() => setConfirm(false)}>Cancel</button>
181
+ <button className="btn primary" disabled={busy} onClick={() => void go()}>{busy ? "Submitting" : `Yes, submit as ${chosen?.name}`}</button>
182
+ </>
183
+ ) : (
184
+ <button className="btn primary" disabled={!chosen} title={chosen ? undefined : "Choose a verdict first"} onClick={() => setConfirm(true)}>Submit review{pending > 0 && <span className="count">{pending}</span>}</button>
185
+ )}
186
+ </div>
187
+ </aside>
188
+ );
189
+ }
190
+
191
+
192
+ // a message on one line, as plain text: code blocks folded, markdown marks and list bullets dropped
193
+ const gist = (text: string) => text.replace(/```[\s\S]*?```/g, "(code)").replace(/^\s*(?:[-*+]|\d+\.|>|#+)\s+/gm, "").replace(/[*_`]/g, "").replace(/\s+/g, " ").trim();
194
+ const ASKED = { sent: "Sent", working: "Working on it", done: "Answered", stopped: "Stopped" } as const;
195
+
196
+ // local changes: what the developer's agent did after the reviewer asked, then telling it they are done
197
+ function LocalFinish() {
198
+ const all = useComments();
199
+ const { updates } = useLive();
200
+ const asked = all.filter((c) => c.by === "reviewer" && c.to === "agent");
201
+ const answerOf = (c: ReviewComment) => all.find((x) => x.by === "agent" && x.replyTo === c.id);
202
+ return (
203
+ <section className="view fin">
204
+ <div className="fin-main">
205
+ <h1 className="title">Finish the review</h1>
206
+ {updates.length > 0 && (
207
+ <Section label="Updates" count={updates.length}>
208
+ <div className="rows">
209
+ {[...updates].reverse().map((u) => (
210
+ <div key={u.n} className="row">
211
+ <span className="grow">{u.note}</span>
212
+ <span className={`note-tag${u.refreshed ? "" : " pending"}`}>{u.refreshed ? "In the tour" : "Updating the tour"}</span>
213
+ </div>
214
+ ))}
215
+ </div>
216
+ </Section>
217
+ )}
218
+ {asked.length ? (
219
+ <div className="rows">
220
+ {asked.map((c) => {
221
+ const a = answerOf(c);
222
+ return (
223
+ <div key={c.id} className="row fin-ask">
224
+ <span className="grow">
225
+ <span className="fin-q">{gist(c.body)}</span>
226
+ {a && <span className="fin-a">{gist(a.body)}</span>}
227
+ </span>
228
+ <span className={`note-tag agent ${c.status}`}>{ASKED[c.status ?? "sent"]}</span>
229
+ </div>
230
+ );
231
+ })}
232
+ </div>
233
+ ) : <p className="prose">Nothing asked yet. The Agent button in the footer opens the chat.</p>}
234
+ </div>
235
+ <Done />
236
+ </section>
237
+ );
238
+ }
239
+
240
+ // tells the developer's agent the reviewer is done, with a last word
241
+ function Done() {
242
+ const { body, setBody } = useSummary();
243
+ const [state, setState] = useState<"idle" | "busy" | "done">("idle");
244
+ const [error, setError] = useState<string>();
245
+ const { agent } = useLive();
246
+ const finish = async () => {
247
+ setState("busy");
248
+ const e = await finishReview(body.trim() || undefined);
249
+ setError(e);
250
+ setState(e ? "idle" : "done");
251
+ };
252
+ if (state === "done")
253
+ return (
254
+ <aside className="fin-panel">
255
+ <h2>Done</h2>
256
+ <p className="prose">{agent.listening ? "Your agent has been told you are done." : "Your agent hears it the next time it waits."}</p>
257
+ </aside>
258
+ );
259
+ return (
260
+ <aside className="fin-panel">
261
+ <h2>Finish your review</h2>
262
+ <Meters />
263
+ <textarea className="field" value={body} placeholder="A note for your agent, e.g. commit it and open a PR" aria-label="Note for your agent" onChange={(e) => setBody(e.target.value)} />
264
+ <Listening />
265
+ {error && <p className="prose post-error" role="alert">{error}</p>}
266
+ <div className="fin-actions"><button className="btn primary" disabled={state === "busy"} onClick={() => void finish()}>Looks good</button></div>
267
+ </aside>
268
+ );
269
+ }
270
+
271
+ export function Listening() {
272
+ const { agent } = useLive();
273
+ return <span className={`listening${agent.listening ? " on" : ""}`}><span className="lamp" />{agent.listening ? "Your agent is listening" : "No agent is listening"}</span>;
274
+ }
@@ -0,0 +1,184 @@
1
+ // A diagram of the parts a change runs through, lit by review hook events as a step runs.
2
+ import { useEffect, useMemo, useRef } from "react";
3
+ import type { ReviewEvent, Status } from "./live";
4
+ import type { Instance } from "./review";
5
+
6
+ export interface FlowDef {
7
+ id: string;
8
+ title: string;
9
+ // nodes sit on a grid, col and row from 0
10
+ nodes: { id: string; title: string; sub?: string; col: number; row: number }[];
11
+ edges: [string, string][];
12
+ // the nodes this PR changes, drawn dashed
13
+ changed?: string[];
14
+ }
15
+
16
+ type P = { x: number; y: number };
17
+ const R = 8;
18
+ // an edge between two nodes: straight along a row or column, otherwise out sideways and around one rounded corner
19
+ function route(a: P, b: P, wa: number, wb: number, h: number): { d: string; tip: string } {
20
+ if (a.y === b.y) {
21
+ const [x1, x2, dir] = b.x > a.x ? [a.x + wa, b.x - 2, 1] : [a.x, b.x + wb + 2, -1];
22
+ const y = a.y + h / 2;
23
+ return { d: `M${x1} ${y} H${x2}`, tip: `M${x2} ${y} l${-5 * dir} -3.5 v7 z` };
24
+ }
25
+ const down = b.y > a.y ? 1 : -1;
26
+ if (a.x === b.x) {
27
+ const x = a.x + wa / 2, y1 = down > 0 ? a.y + h : a.y, y2 = down > 0 ? b.y - 2 : b.y + h + 2;
28
+ return { d: `M${x} ${y1} V${y2}`, tip: `M${x} ${y2} l-3.5 ${-5 * down} h7 z` };
29
+ }
30
+ // leave from the side facing the target, turn once, enter from above or below
31
+ const right = b.x > a.x ? 1 : -1;
32
+ const x1 = right > 0 ? a.x + wa : a.x, y1 = a.y + h / 2, x2 = b.x + wb / 2, y2 = down > 0 ? b.y - 2 : b.y + h + 2;
33
+ return {
34
+ d: `M${x1} ${y1} H${x2 - R * right} Q${x2} ${y1} ${x2} ${y1 + R * down} V${y2}`,
35
+ tip: `M${x2} ${y2} l-3.5 ${-5 * down} h7 z`,
36
+ };
37
+ }
38
+
39
+ // text width in the page's font, to size columns and cut what does not fit
40
+ let ctx: CanvasRenderingContext2D | null | undefined;
41
+ function measure(text: string, font: string): number {
42
+ ctx ??= document.createElement("canvas").getContext("2d");
43
+ if (!ctx) return text.length * 7;
44
+ const css = getComputedStyle(document.documentElement);
45
+ ctx.font = font.replace("SANS", css.getPropertyValue("--sans")).replace("MONO", css.getPropertyValue("--mono"));
46
+ return ctx.measureText(text).width;
47
+ }
48
+ function fit(text: string, font: string, max: number): string {
49
+ if (measure(text, font) <= max) return text;
50
+ let lo = 0, hi = text.length;
51
+ while (lo < hi) {
52
+ const mid = Math.ceil((lo + hi) / 2);
53
+ if (measure(text.slice(0, mid) + "\u2026", font) <= max) lo = mid;
54
+ else hi = mid - 1;
55
+ }
56
+ return text.slice(0, lo).trimEnd() + "\u2026";
57
+ }
58
+ const TITLE = "500 13px SANS", SUB = "400 12px MONO", LAB = "500 12px SANS";
59
+ const PAD = 12, MIN = 120, MAX = 220, GAP = 48, NH = 58, ROWGAP = 30;
60
+
61
+ // the latest state each node reported in these events
62
+ function useNodeState(mine: ReviewEvent[]) {
63
+ return useMemo(() => {
64
+ const s: Record<string, { status?: Status; label?: string }> = {};
65
+ for (const e of mine) if (e.flow?.node) s[e.flow.node] = { status: e.flow.status ?? s[e.flow.node]?.status, label: e.flow.label ?? s[e.flow.node]?.label };
66
+ return s;
67
+ }, [mine.length]);
68
+ }
69
+
70
+ // the latest state of each node, and whether anything reached it, in one instance's events
71
+ function nodeStates(events: ReviewEvent[], flow: string) {
72
+ const s: Record<string, { status?: Status; label?: string; reached?: boolean }> = {};
73
+ for (const e of events) {
74
+ if (e.flow?.id !== flow) continue;
75
+ if (e.flow.edge) s[e.flow.edge[1]] = { ...s[e.flow.edge[1]], reached: true };
76
+ if (e.flow.node) s[e.flow.node] = { reached: true, status: e.flow.status ?? s[e.flow.node]?.status, label: e.flow.label ?? s[e.flow.node]?.label };
77
+ }
78
+ return s;
79
+ }
80
+
81
+ // beside a chapter's text: the flow's parts down the side, joined by a line, and a column per instance with what
82
+ // happened at each part, so base and head compare row by row
83
+ export function FlowCompare({ def, here, instances, events }: { def: FlowDef; here?: string; instances: Instance[]; events: ReviewEvent[] }) {
84
+ const nodes = [...def.nodes].sort((a, b) => a.col - b.col || a.row - b.row);
85
+ const states = Object.fromEntries(instances.map((i) => [i.id, nodeStates(events.filter((e) => e.instance === i.id), def.id)]));
86
+ return (
87
+ <div className="fcmp" role="table" aria-label={def.title} style={{ gridTemplateColumns: `minmax(0, 1.25fr) repeat(${instances.length}, minmax(0, 1fr))` }}>
88
+ <div role="row" className="fcmp-row fcmp-head">
89
+ <span role="columnheader" />
90
+ {instances.map((i) => <span role="columnheader" key={i.id}>{i.label}</span>)}
91
+ </div>
92
+ {nodes.map((n, k) => (
93
+ <div role="row" key={n.id} className={`fcmp-row${here === n.id ? " here" : ""}${k === 0 ? " first" : ""}${k === nodes.length - 1 ? " last" : ""}`}>
94
+ <span role="rowheader" className={`fcmp-node${def.changed?.includes(n.id) ? " changed" : ""}`} title={def.changed?.includes(n.id) ? `${n.title}, changed by this PR` : n.title}>
95
+ <i aria-hidden="true" />
96
+ <span>{n.title}</span>
97
+ </span>
98
+ {instances.map((i) => {
99
+ const s = states[i.id][n.id];
100
+ return (
101
+ <span role="cell" key={i.id} className={`fcmp-cell ${s?.status ?? ""}`} title={s?.label}>
102
+ {s?.label ? <span key={s.label}>{s.label}</span> : s?.reached ? <b key="reached" aria-label="reached" /> : null}
103
+ </span>
104
+ );
105
+ })}
106
+ </div>
107
+ ))}
108
+ </div>
109
+ );
110
+ }
111
+
112
+ // events for this flow from one instance: a packet along an edge, a node's state and label
113
+ export function Flow(props: { def: FlowDef; here?: string; events?: ReviewEvent[] }) {
114
+ return <FlowDiagram def={props.def} here={props.here} mine={(props.events ?? []).filter((e) => e.flow?.id === props.def.id)} />;
115
+ }
116
+
117
+ // the full diagram: each column as wide as its longest title or sub, up to a cap, so labels arriving never move it;
118
+ // what does not fit is cut and shown on hover
119
+ function FlowDiagram({ def, here, mine }: { def: FlowDef; here?: string; mine: ReviewEvent[] }) {
120
+ const state = useNodeState(mine);
121
+ const cols = Math.max(...def.nodes.map((n) => n.col)) + 1;
122
+ const widths = Array.from({ length: cols }, (_, c) => {
123
+ const texts = def.nodes.filter((n) => n.col === c).flatMap((n) => [measure(n.title, TITLE), n.sub ? measure(n.sub, SUB) : 0]);
124
+ return Math.min(MAX, Math.max(MIN, Math.ceil(Math.max(...texts)) + PAD * 2));
125
+ });
126
+ const xs = widths.reduce<number[]>((acc, _, i) => [...acc, i ? acc[i - 1] + widths[i - 1] + GAP : 0], []);
127
+ const pos = Object.fromEntries(def.nodes.map((n) => [n.id, { x: xs[n.col], y: n.row * (NH + ROWGAP) }]));
128
+ const nw = (id: string) => widths[def.nodes.find((n) => n.id === id)!.col];
129
+ const w = xs[cols - 1] + widths[cols - 1];
130
+ const h = Math.max(...def.nodes.map((n) => n.row)) * (NH + ROWGAP) + NH;
131
+ const lit = new Set(mine.filter((e) => e.flow?.edge).map((e) => e.flow!.edge!.join(">")));
132
+
133
+ // a packet runs along an edge for each new edge event
134
+ const svg = useRef<SVGSVGElement>(null);
135
+ const seen = useRef(0);
136
+ useEffect(() => {
137
+ for (const e of mine.slice(seen.current)) {
138
+ const path = e.flow?.edge && svg.current?.querySelector<SVGPathElement>(`[data-e="${e.flow.edge.join(">")}"]`);
139
+ if (!path || matchMedia("(prefers-reduced-motion: reduce)").matches) continue;
140
+ const dot = document.createElementNS("http://www.w3.org/2000/svg", "circle");
141
+ dot.setAttribute("r", "3");
142
+ dot.setAttribute("class", "packet");
143
+ svg.current!.append(dot);
144
+ const len = path.getTotalLength(), t0 = performance.now();
145
+ const tick = (t: number) => {
146
+ const k = Math.min(1, (t - t0) / 460);
147
+ const p = path.getPointAtLength(len * (1 - (1 - k) ** 3));
148
+ dot.setAttribute("cx", String(p.x));
149
+ dot.setAttribute("cy", String(p.y));
150
+ k < 1 ? requestAnimationFrame(tick) : dot.remove();
151
+ };
152
+ requestAnimationFrame(tick);
153
+ }
154
+ seen.current = mine.length;
155
+ }, [mine.length]);
156
+
157
+ return (
158
+ <svg ref={svg} className="flow" viewBox={`-1 -1 ${w + 2} ${h + 2}`} style={{ maxWidth: w + 2 }} role="img" aria-label={def.title}>
159
+ {def.edges.map(([a, b]) => {
160
+ const { d, tip } = route(pos[a], pos[b], nw(a), nw(b), NH);
161
+ return (
162
+ <g key={`${a}>${b}`}>
163
+ <path className={`fedge${lit.has(`${a}>${b}`) ? " lit" : ""}`} data-e={`${a}>${b}`} d={d} />
164
+ <path className="arrow" d={tip} />
165
+ </g>
166
+ );
167
+ })}
168
+ {def.nodes.map((n) => {
169
+ const s = state[n.id];
170
+ const cls = ["fnode", def.changed?.includes(n.id) && "changed", here === n.id && "here", s?.status].filter(Boolean).join(" ");
171
+ const room = nw(n.id) - PAD * 2;
172
+ const second = s?.label ? { cls: "lab", text: s.label, font: LAB } : n.sub ? { cls: "sub", text: n.sub, font: SUB } : undefined;
173
+ return (
174
+ <g key={n.id} className={cls} transform={`translate(${pos[n.id].x},${pos[n.id].y})`}>
175
+ <title>{[n.title, s?.label ?? n.sub].filter(Boolean).join(": ")}</title>
176
+ <rect width={nw(n.id)} height={NH} rx={9} />
177
+ <text x={PAD} y={25}>{fit(n.title, TITLE, room)}</text>
178
+ {second && <text className={second.cls} x={PAD} y={42}>{fit(second.text, second.font, room)}</text>}
179
+ </g>
180
+ );
181
+ })}
182
+ </svg>
183
+ );
184
+ }
@@ -0,0 +1,10 @@
1
+ // Everything a review is built from. Use these first; write your own components next to them when they do not fit.
2
+ export { defineReview, useNav, useChapter, outcomeOf, type Review, type Instance, type ChapterDef, type Group, type Target } from "./review";
3
+ export { pr, shortTitle, diffs } from "./pr";
4
+ export { Overview, Lead, BeforeAfter, Attention, Card, type AttentionItem, type HeldClaim } from "./overview";
5
+ export { Chapter, Outcome } from "./chapter";
6
+ export { Flow, type FlowDef } from "./flow";
7
+ export { Live, Shot, Compare, Clip } from "./media";
8
+ export { Diff, Changes, highlight } from "./code";
9
+ export { Section, Label, Row, Rows, FileLine, Stat, Icon, InstanceName } from "./ui";
10
+ export { useLive, runStep, resetEvents, useUrl, file, type ReviewEvent, type Status } from "./live";