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,217 @@
1
+ // The changes, grouped in tour order with the reason for each group; files no group covers come last.
2
+ import { useEffect, useState, type ReactNode } from "react";
3
+ import { diffs, pr, rows, type Row as DiffRow } from "./pr";
4
+ import { useNav, type Group } from "./review";
5
+ import { addComment, anchorOf, askInChat, Composer, dedent, rangeLabel, rowAt, ThreadView, threadsFor, useComments, type Anchor, type Thread } from "./comments";
6
+ import { FileLine, Icon } from "./ui";
7
+
8
+ // a small highlighter for C-like languages, enough to read a diff
9
+ const KW = /^(const|let|var|function|return|if|else|export|import|from|async|await|for|of|new|type|interface|try|catch|finally|throw|as|in|class|extends|def|fn|func|pub|struct|enum|match|switch|case|break|continue|while|default|package|static|val)$/;
10
+ const TY = /^(string|number|boolean|null|undefined|true|false|void|None|True|False|self|this)$/;
11
+ export function highlight(src: string): ReactNode[] {
12
+ const re = /(\/\/.*$|#.*$)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\b0x[0-9a-f]+\b|\b\d+(?:\.\d+)?\b)|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)/gi;
13
+ const out: ReactNode[] = [];
14
+ let last = 0, m: RegExpExecArray | null;
15
+ while ((m = re.exec(src))) {
16
+ out.push(src.slice(last, m.index));
17
+ const tok = m[0];
18
+ const cls = m[1] ? "com" : m[2] ? "str" : m[3] ? "num" : KW.test(tok) ? "kw" : m[4] ? "fn" : TY.test(tok) ? "ty" : "";
19
+ out.push(cls ? <span key={m.index} className={`tok-${cls}`}>{tok}</span> : tok);
20
+ last = m.index + tok.length;
21
+ }
22
+ out.push(src.slice(last));
23
+ return out;
24
+ }
25
+
26
+ function Line({ r, sel, onPick, onEnter }: { r: DiffRow; sel?: boolean; onPick?: (e: React.MouseEvent) => void; onEnter?: () => void }) {
27
+ const c = r.t === "c" ? "" : r.t;
28
+ return (
29
+ <div className={`lr${sel ? " sel" : ""}`} onMouseEnter={onEnter}>
30
+ <div className={`ln ${c}`}>
31
+ {onPick && <button className="add-c" aria-label={`Comment on line ${r.t === "d" ? r.o : r.n}`} title="Comment, or drag for several lines" onMouseDown={onPick}>{Icon.plus}</button>}
32
+ {r.t === "d" ? "\u2212" : r.n}
33
+ </div>
34
+ <div className={`code ${c}`} data-line={r.t === "d" ? undefined : r.n}>{highlight(r.s)}{r.s ? "" : " "}</div>
35
+ </div>
36
+ );
37
+ }
38
+
39
+ // the lines in range; everything else folds behind an expander. PR files take comments: press or drag in the gutter.
40
+ // Threads show where their line is in range; with strays, also those no other range of the file holds.
41
+ export function Diff({ file, lines, strays }: { file: string; lines?: [number, number][]; strays?: boolean }) {
42
+ const all = rows(diffs[file] ?? "");
43
+ const commentable = true;
44
+ const comments = useComments();
45
+ const threads = commentable ? threadsFor(file, comments) : [];
46
+ const [open, setOpen] = useState<Set<number>>(new Set());
47
+ // the rows being picked by a drag in the gutter, then commented on
48
+ const [pick, setPick] = useState<{ from: number; to: number; dragging: boolean }>();
49
+ useEffect(() => {
50
+ if (!pick?.dragging) return;
51
+ const up = () => setPick((p) => p && { ...p, dragging: false });
52
+ addEventListener("mouseup", up);
53
+ return () => removeEventListener("mouseup", up);
54
+ }, [pick?.dragging]);
55
+
56
+ // where each thread sits; one whose line left the diff shows at the end
57
+ const at = new Map<number, Thread[]>();
58
+ const loose: Thread[] = [];
59
+ const within = (i: number, ranges = lines) => !ranges || ranges.some(([a, b]) => (all[i].n ?? all[i].at!) >= a && (all[i].n ?? all[i].at!) <= b);
60
+ const { review } = useNav();
61
+ const others = strays ? review.groups.filter((g) => g.file === file && g.lines !== lines) : [];
62
+ for (const t of threads) {
63
+ const i = t.outdated ? -1 : rowAt(all, t.side!, t.line!);
64
+ const mine = i >= 0 ? within(i) || (!!strays && !others.some((g) => within(i, g.lines))) : !lines || !!strays;
65
+ if (!mine) continue;
66
+ if (i < 0) loose.push(t);
67
+ else at.set(i, [...(at.get(i) ?? []), t]);
68
+ }
69
+ const lo = pick ? Math.min(pick.from, pick.to) : -1;
70
+ const hi = pick ? Math.max(pick.from, pick.to) : -1;
71
+ const linesOf = (a: Partial<Anchor>) => {
72
+ if (!a.side || !a.line) return undefined;
73
+ const end = rowAt(all, a.side, a.line);
74
+ const begin = a.startLine ? rowAt(all, a.startSide ?? a.side, a.startLine) : end;
75
+ return begin < 0 || end < 0 ? undefined : all.slice(begin, end + 1).filter((r) => r.t !== "d").map((r) => r.s);
76
+ };
77
+ const picked = (): Anchor => {
78
+ const end = anchorOf(all[hi]);
79
+ const begin = anchorOf(all[lo]);
80
+ return lo === hi ? end : { ...end, startSide: begin.side, startLine: begin.line };
81
+ };
82
+
83
+ const out: ReactNode[] = [];
84
+ const after = (i: number) => {
85
+ for (const t of at.get(i) ?? []) out.push(<div key={`t${t.key}`} className="dl-span"><ThreadView thread={t} original={linesOf(t)} /></div>);
86
+ if (pick && !pick.dragging && i === hi) {
87
+ const a = picked();
88
+ const rs = all.slice(lo, hi + 1);
89
+ out.push(
90
+ <div key="composer" className="dl-span">
91
+ <div className="thread">
92
+ <Composer github label={rangeLabel(a)} suggest={rs.every((r) => r.t !== "d") ? rs.map((r) => r.s) : undefined} onCancel={() => setPick(undefined)}
93
+ onSave={async (body, d) => {
94
+ const e = await addComment({ ...d, path: file, ...a, body });
95
+ if (!e) setPick(undefined);
96
+ return e;
97
+ }}
98
+ onAsk={async (body) => {
99
+ const e = await askInChat({ body, quotes: [{ kind: "selection", text: dedent(rs.map((r) => r.s)), where: `${file.split("/").pop()}, ${rangeLabel(a).toLowerCase()}` }] });
100
+ if (!e) setPick(undefined);
101
+ return e;
102
+ }} />
103
+ </div>
104
+ </div>,
105
+ );
106
+ }
107
+ };
108
+ let hidden: [DiffRow, number][] = [];
109
+ const show = (r: DiffRow, i: number) => {
110
+ out.push(<Line key={i} r={r} sel={i >= lo && i <= hi} onPick={commentable ? (e) => { e.preventDefault(); setPick({ from: i, to: i, dragging: true }); } : undefined} onEnter={pick?.dragging ? () => setPick({ ...pick, to: i }) : undefined} />);
111
+ after(i);
112
+ };
113
+ const flush = (key: number) => {
114
+ if (!hidden.length) return;
115
+ const rs = hidden;
116
+ hidden = [];
117
+ if (open.has(key)) rs.forEach(([r, i]) => show(r, i));
118
+ else out.push(<button key={`f${key}`} className="more" onClick={() => setOpen(new Set(open).add(key))}>{Icon.expand}<span>{rs.length} {rs.every(([r]) => r.t === "c") ? (rs.length === 1 ? "unchanged line" : "unchanged lines") : `${rs.length === 1 ? "line" : "lines"} from other parts of the tour`}</span></button>);
119
+ };
120
+ const inRange = (_: DiffRow, i: number) => at.has(i) || within(i);
121
+ all.forEach((r, i) => {
122
+ if (inRange(r, i)) {
123
+ flush(i);
124
+ show(r, i);
125
+ } else hidden.push([r, i]);
126
+ });
127
+ flush(all.length);
128
+ return (
129
+ <div className={`diff${pick?.dragging ? " picking" : ""}`} data-file={file}>
130
+ <div className="dl">{out}</div>
131
+ {loose.length > 0 && <div className="loose">{loose.map((t) => <ThreadView key={t.key} thread={t} />)}</div>}
132
+ </div>
133
+ );
134
+ }
135
+
136
+ const reviewed = new Set<string>();
137
+
138
+ function GroupView({ g, strays }: { g: Group; strays?: boolean }) {
139
+ const { review, go } = useNav();
140
+ const [done, setDone] = useState(reviewed.has(g.id));
141
+ // reviewing a file folds its diff; the file line opens it again
142
+ const [open, setOpen] = useState(!done);
143
+ const ci = review.chapters.findIndex((c) => c.id === g.chapter);
144
+ const f = pr.files.find((x) => x.path === g.file);
145
+ return (
146
+ <section className={`group${done ? " done" : ""}`} id={`g-${g.id}`}>
147
+ <div className="head">
148
+ <h2 className="h">{g.title}</h2>
149
+ <p className="prose">{g.why}</p>
150
+ </div>
151
+ <div className={`file${open ? "" : " closed"}`}>
152
+ <div className="fh">
153
+ <button className="fh-toggle" aria-expanded={open} onClick={() => setOpen(!open)}><FileLine path={g.file} add={f?.add} del={f?.del} /></button>
154
+ {ci >= 0 && <button className="btn ghost sm" onClick={() => go({ chapter: g.chapter! })}>{Icon.play} Chapter {ci + 1}</button>}
155
+ <label className="reviewed">
156
+ <input type="checkbox" checked={done} onChange={(e) => { e.target.checked ? reviewed.add(g.id) : reviewed.delete(g.id); setDone(e.target.checked); setOpen(!e.target.checked); window.dispatchEvent(new Event("reviewed")); }} />
157
+ Reviewed
158
+ </label>
159
+ </div>
160
+ {open && <Diff file={g.file} lines={g.lines} strays={strays} />}
161
+ </div>
162
+ </section>
163
+ );
164
+ }
165
+
166
+ export const isReviewed = (id: string) => reviewed.has(id);
167
+
168
+ // a changed file the tour does not talk about: one row, as in GitHub's list of changed files, its diff opening in place
169
+ function OtherFile({ path, focus }: { path: string; focus?: string }) {
170
+ const id = `file-${path}`;
171
+ const f = pr.files.find((x) => x.path === path);
172
+ const threads = threadsFor(path, useComments()).length;
173
+ const [done, setDone] = useState(reviewed.has(id));
174
+ const [open, setOpen] = useState(focus === id);
175
+ useEffect(() => {
176
+ if (focus === id) setOpen(true);
177
+ }, [focus]);
178
+ return (
179
+ <div className={`file other${open ? "" : " closed"}${done ? " done" : ""}`} id={`g-${id}`}>
180
+ <div className="fh">
181
+ <button className="fh-toggle" aria-expanded={open} onClick={() => setOpen(!open)}><span className="chev">{Icon.chevron}</span><FileLine path={path} add={f?.add} del={f?.del} /></button>
182
+ {threads > 0 && <span className="fh-threads" title={`${threads} ${threads === 1 ? "thread" : "threads"}`}>{Icon.comment}{threads}</span>}
183
+ <label className="reviewed">
184
+ <input type="checkbox" checked={done} onChange={(e) => { e.target.checked ? reviewed.add(id) : reviewed.delete(id); setDone(e.target.checked); if (e.target.checked) setOpen(false); window.dispatchEvent(new Event("reviewed")); }} />
185
+ Reviewed
186
+ </label>
187
+ </div>
188
+ {open && <Diff file={path} />}
189
+ </div>
190
+ );
191
+ }
192
+
193
+ // every group in tour order, then the changed files no group mentions, as a list
194
+ export function Changes({ focus }: { focus?: string }) {
195
+ const { review, go } = useNav();
196
+ const covered = new Set(review.groups.map((g) => g.file));
197
+ const rest = pr.files.filter((f) => !covered.has(f.path) && diffs[f.path]).map((f) => f.path).sort();
198
+ useEffect(() => {
199
+ if (focus) document.getElementById(`g-${focus}`)?.scrollIntoView({ block: "start" });
200
+ }, [focus]);
201
+ return (
202
+ <section className="view changes">
203
+ <div className="groups">
204
+ {review.groups.map((g, i) => <GroupView key={g.id} g={g} strays={review.groups.findIndex((x) => x.file === g.file) === i} />)}
205
+ {rest.length > 0 && (
206
+ <section className="group" id="g-other">
207
+ <div className="head"><h2 className="h">Not in the tour <span className="count">{rest.length}</span></h2></div>
208
+ <div className="file-stack">{rest.map((p) => <OtherFile key={p} path={p} focus={focus} />)}</div>
209
+ </section>
210
+ )}
211
+ <div className="cta">
212
+ <button className="btn primary" onClick={() => go("finish")}>Finish the review {Icon.arrow}</button>
213
+ </div>
214
+ </div>
215
+ </section>
216
+ );
217
+ }
@@ -0,0 +1,377 @@
1
+ // Comments: threads already on GitHub, and the reviewer's own, kept by the review server. A comment goes to GitHub
2
+ // (the reviewer's pending review, or a single comment, as on github.com) or to the agent: the box agent in a PR
3
+ // review, the developer's own agent in a review of local changes. Lines take both; chapters and pictures the agent only.
4
+ import { useEffect, useRef, useState, type ReactNode } from "react";
5
+ import Markdown, { type Components } from "react-markdown";
6
+ import remarkGfm from "remark-gfm";
7
+ import { setComments, liveComments, useLive } from "./live";
8
+ import { pr, type GhComment, type Row } from "./pr";
9
+ import { highlight } from "./code";
10
+ import { Icon } from "./ui";
11
+
12
+ export type Side = "LEFT" | "RIGHT";
13
+ // the same shape as ReviewComment in src/review/contract.ts
14
+ export interface ReviewComment {
15
+ id: string;
16
+ at: string;
17
+ by: "reviewer" | "agent";
18
+ to: "github" | "agent";
19
+ body: string;
20
+ path?: string;
21
+ side?: Side;
22
+ line?: number;
23
+ startSide?: Side;
24
+ startLine?: number;
25
+ topic?: Topic;
26
+ quotes?: Quote[];
27
+ inReplyTo?: number;
28
+ replyTo?: string;
29
+ github?: { state: "pending" | "posted"; node: string; id: number; url?: string; thread?: string };
30
+ agent?: true;
31
+ status?: "sent" | "working" | "done" | "stopped";
32
+ commit?: string;
33
+ }
34
+ // what the reviewer had selected on the page, and where, e.g. "unread-emails.ts, lines 37 to 39"
35
+ export interface Quote { text: string; where?: string; kind?: "selection" | "comment" }
36
+
37
+ // code as it reads quoted on its own: without the indent its lines share
38
+ export function dedent(lines: string[]): string {
39
+ const indent = Math.min(...lines.filter((l) => l.trim()).map((l) => l.match(/^[ \t]*/)![0].length));
40
+ return lines.map((l) => l.slice(Number.isFinite(indent) ? indent : 0)).join("\n");
41
+ }
42
+
43
+ // things the reviewer brings into the chat from elsewhere on the page, e.g. a comment; the chat (agent.tsx) listens
44
+ const toChat = new Set<(q: Quote) => void>();
45
+ export const onReferToChat = (f: (q: Quote) => void) => (toChat.add(f), () => void toChat.delete(f));
46
+ export const referToChat = (q: Quote) => toChat.forEach((f) => f(q));
47
+ // a message sent to the chat from elsewhere, e.g. Ask agent in a comment; the chat opens and sends it
48
+ type Ask = (m: { body: string; quotes: Quote[] }) => Promise<string | undefined>;
49
+ let asker: Ask | undefined;
50
+ export const onAskInChat = (f: Ask) => ((asker = f), () => void (asker === f && (asker = undefined)));
51
+ export const askInChat: Ask = (m) => (asker ? asker(m) : Promise.resolve("The chat is not open on this page."));
52
+ // a part of the tour a comment is on: a chapter, or a picture in it
53
+ export interface Topic { chapter?: string; figure?: string; chat?: boolean; label: string }
54
+ export type CommentInput = Pick<ReviewComment, "to" | "body" | "path" | "side" | "line" | "startSide" | "startLine" | "topic" | "quotes" | "inReplyTo" | "replyTo"> & { mode?: "single" | "review" };
55
+
56
+ // where a comment sits: a line on one side, or a range ending there
57
+ export interface Anchor { side: Side; line: number; startSide?: Side; startLine?: number }
58
+
59
+ async function call(path: string, method: string, body?: unknown): Promise<string | undefined> {
60
+ try {
61
+ const r = await fetch(path, { method, headers: { "content-type": "application/json" }, body: JSON.stringify(body ?? {}) });
62
+ if (r.ok) {
63
+ if (r.status !== 204 && /^api\/comments/.test(path)) {
64
+ const c = (await r.json()) as ReviewComment;
65
+ setComments(method === "POST" ? [...liveComments().filter((d) => d.id !== c.id), c] : liveComments().map((d) => (d.id === c.id ? c : d)));
66
+ }
67
+ return undefined;
68
+ }
69
+ const e = (await r.json().catch(() => ({}))) as { errors?: string[] };
70
+ return e.errors?.join(", ") ?? `The review server answered ${r.status}.`;
71
+ } catch {
72
+ return "The review server is not answering.";
73
+ }
74
+ }
75
+ export const addComment = (c: CommentInput) => call("api/comments", "POST", c);
76
+ export const editComment = (id: string, body: string) => call(`api/comments/${id}`, "PATCH", { body });
77
+ export async function dropComment(id: string) {
78
+ const err = await call(`api/comments/${id}`, "DELETE");
79
+ if (!err) setComments(liveComments().filter((d) => d.id !== id));
80
+ return err;
81
+ }
82
+ // local reviews: tells the developer's agent the reviewer is done
83
+ export const finishReview = (note?: string) => call("api/finish", "POST", { note });
84
+
85
+ // GitHub's rule: once a review is in progress, comments join it
86
+ export const reviewInProgress = (cs: ReviewComment[]) => cs.some((c) => c.github?.state === "pending");
87
+
88
+ interface Note { key: string; who: string; at: string; body: string; mine?: ReviewComment; agent?: ReviewComment; bot?: boolean }
89
+ export interface Thread extends Partial<Anchor> { key: string; path?: string; topic?: Topic; root?: number; local?: string; notes: Note[]; resolved: boolean; outdated: boolean; url?: string }
90
+
91
+ const fromGh = (c: GhComment): Note => ({ key: `g${c.id}`, who: c.user.login.replace(/\[bot\]$/, ""), at: c.created_at, body: c.body, bot: c.user.type === "Bot" || /\[bot\]$/.test(c.user.login) });
92
+ const fromHere = (c: ReviewComment): Note => (c.by === "agent" ? { key: c.id, who: "Agent", at: c.at, body: c.body, agent: c } : { key: c.id, who: "You", at: c.at, body: c.body, mine: c });
93
+
94
+ const rootOf = (c: ReviewComment, all: ReviewComment[]) => {
95
+ let r = c;
96
+ for (let i = 0; r.replyTo && i < all.length; i++) r = all.find((x) => x.id === r.replyTo) ?? r;
97
+ return r;
98
+ };
99
+ // the thread a comment belongs to: a GitHub thread by its first comment's id, else the comment it started from here
100
+ const keyOf = (c: ReviewComment, all: ReviewComment[]) => {
101
+ const r = rootOf(c, all);
102
+ return r.inReplyTo ? `g${r.inReplyTo}` : r.github ? `g${r.github.id}` : `l${r.id}`;
103
+ };
104
+ const byTime = (a: Note, b: Note) => a.at.localeCompare(b.at);
105
+
106
+ // every thread on a file: GitHub's, then the ones started here, each with what was said in it here
107
+ export function threadsFor(path: string, all: ReviewComment[]): Thread[] {
108
+ const posted = new Set(all.map((c) => c.github?.id));
109
+ const here = new Map<string, ReviewComment[]>();
110
+ for (const c of all) {
111
+ const k = keyOf(c, all);
112
+ here.set(k, [...(here.get(k) ?? []), c]);
113
+ }
114
+ const gh = pr.reviewComments.filter((c) => c.path === path && !posted.has(c.id));
115
+ const out: Thread[] = gh
116
+ .filter((c) => !c.in_reply_to_id)
117
+ .map((c) => ({
118
+ key: `g${c.id}`,
119
+ root: c.id,
120
+ path,
121
+ side: c.side ?? "RIGHT",
122
+ line: c.line ?? c.original_line ?? 0,
123
+ startSide: c.start_line ? (c.start_side ?? c.side ?? "RIGHT") : undefined,
124
+ startLine: c.start_line ?? undefined,
125
+ notes: [fromGh(c), ...[...gh.filter((r) => r.in_reply_to_id === c.id).map(fromGh), ...(here.get(`g${c.id}`) ?? []).map(fromHere)].sort(byTime)],
126
+ resolved: !!c.resolved,
127
+ outdated: c.line == null,
128
+ url: c.html_url,
129
+ }));
130
+ for (const r of all.filter((c) => c.path === path && !c.inReplyTo && !c.replyTo)) {
131
+ const k = keyOf(r, all);
132
+ out.push({ key: k, ...(r.github ? { root: r.github.id } : { local: r.id }), path, side: r.side, line: r.line, startSide: r.startSide, startLine: r.startLine, notes: (here.get(k) ?? [r]).map(fromHere).sort(byTime), resolved: false, outdated: false, url: r.github?.url });
133
+ }
134
+ return out;
135
+ }
136
+
137
+
138
+ // the row a comment on this side and line belongs under
139
+ export const rowAt = (rows: Row[], side: Side, line: number) => rows.findIndex((r) => (side === "LEFT" ? r.o === line && r.t !== "a" : r.n === line && r.t !== "d"));
140
+ export const anchorOf = (r: Row): { side: Side; line: number } => (r.t === "d" ? { side: "LEFT", line: r.o! } : { side: "RIGHT", line: r.n! });
141
+
142
+ const ago = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
143
+ function when(at: string) {
144
+ const s = (Date.parse(at) - Date.now()) / 1000;
145
+ for (const [unit, n] of [["year", 31536000], ["month", 2592000], ["day", 86400], ["hour", 3600], ["minute", 60]] as const)
146
+ if (Math.abs(s) >= n) return ago.format(Math.round(s / n), unit);
147
+ return "just now";
148
+ }
149
+
150
+ // GitHub markdown, read safely: raw HTML never renders and javascript: links are dropped (react-markdown's defaults).
151
+ // Suggestions show as a diff against the lines they replace; images show as links, so nothing loads from a comment.
152
+ type HastNode = { type: string; value?: string; tagName?: string; properties?: Record<string, unknown>; children?: HastNode[] };
153
+ const textOf = (n: HastNode): string => (n.type === "text" ? (n.value ?? "") : (n.children ?? []).map(textOf).join(""));
154
+
155
+ export function Body({ text, original }: { text: string; original?: string[] }) {
156
+ // HTML comments and the tags review bots wrap their notes in are dropped; any other HTML shows as text
157
+ const clean = text.replace(/<!--[\s\S]*?-->/g, "").replace(/<\/?(details|summary|p|br|div|span|sub|sup|b|i|strong|em)\b[^>]*>/gi, "").trim();
158
+ const components: Components = {
159
+ pre: ({ node }) => {
160
+ const code = (node as HastNode | undefined)?.children?.[0];
161
+ const lang = ((code?.properties?.className as string[] | undefined) ?? []).find((c) => c.startsWith("language-"))?.slice(9);
162
+ const src = code ? textOf(code).replace(/\n$/, "") : "";
163
+ if (lang === "suggestion")
164
+ return (
165
+ <div className="suggestion">
166
+ <div className="suggestion-head">Suggested change</div>
167
+ <div className="dl">
168
+ {(original ?? []).map((s, k) => <Code key={`o${k}`} t="d" s={s} />)}
169
+ {(src ? src.split("\n") : []).map((s, k) => <Code key={`n${k}`} t="a" s={s} />)}
170
+ </div>
171
+ </div>
172
+ );
173
+ return <pre className="code-block">{highlight(src)}</pre>;
174
+ },
175
+ a: ({ href, children }) => <a className="link" href={href} target="_blank" rel="noreferrer noopener">{children}</a>,
176
+ img: ({ src, alt }) => (typeof src === "string" && src ? <a className="link" href={src} target="_blank" rel="noreferrer noopener">{alt || "image"}</a> : null),
177
+ };
178
+ return <div className="note-body"><Markdown remarkPlugins={[remarkGfm]} components={components}>{clean}</Markdown></div>;
179
+ }
180
+ const Code = ({ t, s }: { t: "a" | "d"; s: string }) => <><div className={`ln ${t}`}>{t === "a" ? "+" : "−"}</div><div className={`code ${t}`}>{highlight(s)}{s ? "" : " "}</div></>;
181
+
182
+ // where a comment goes on GitHub: a single comment, or the review
183
+ export type Dest = { to: "github"; mode: "single" | "review" };
184
+
185
+ // writing a comment: GitHub's buttons on the right, Ask agent on the left, which sends it to the chat instead.
186
+ // ⌘↵ takes the last button, Esc cancels; ± in the corner fills in the lines to rewrite. edit: one Save button.
187
+ export function Composer({ initial = "", suggest, label, github = false, edit, onSave, onAsk, onCancel }: { initial?: string; suggest?: string[]; label?: ReactNode; github?: boolean; edit?: boolean; onSave?: (body: string, d: Dest) => Promise<string | undefined>; onAsk?: (body: string) => Promise<string | undefined>; onCancel: () => void }) {
188
+ const { comments } = useLive();
189
+ const [body, setBody] = useState(initial);
190
+ const [error, setError] = useState<string>();
191
+ const [busy, setBusy] = useState(false);
192
+ const ref = useRef<HTMLTextAreaElement>(null);
193
+ useEffect(() => {
194
+ const t = ref.current;
195
+ if (!t) return;
196
+ t.focus();
197
+ t.setSelectionRange(t.value.length, t.value.length);
198
+ }, []);
199
+ // grows with what is written, up to the cap in kit.css
200
+ useEffect(() => {
201
+ const t = ref.current;
202
+ if (t) {
203
+ t.style.height = "auto";
204
+ t.style.height = `${t.scrollHeight}px`;
205
+ }
206
+ }, [body]);
207
+ const text = body;
208
+ // GitHub's buttons where GitHub takes the comment; without them, asking the agent is the main action
209
+ const buttons: { label: string; dest: Dest }[] = edit
210
+ ? [{ label: "Save", dest: { to: "github", mode: "review" } }]
211
+ : github && pr.number && onSave
212
+ ? reviewInProgress(comments)
213
+ ? [{ label: "Add review comment", dest: { to: "github", mode: "review" } }]
214
+ : [{ label: "Add single comment", dest: { to: "github", mode: "single" } }, { label: "Start a review", dest: { to: "github", mode: "review" } }]
215
+ : [];
216
+ const ask = async () => {
217
+ if (!text.trim() || busy || !onAsk) return;
218
+ setBusy(true);
219
+ const err = await onAsk(text);
220
+ setBusy(false);
221
+ if (err) setError(err);
222
+ };
223
+ const save = async (d: Dest) => {
224
+ if (!text.trim() || busy || !onSave) return;
225
+ setBusy(true);
226
+ const err = await onSave(text, d);
227
+ setBusy(false);
228
+ if (err) setError(err);
229
+ };
230
+ const addSuggestion = () => {
231
+ const block = "```suggestion\n" + (suggest ?? []).join("\n") + "\n```";
232
+ setBody((b) => (b.trim() ? `${b.replace(/\s+$/, "")}\n\n${block}` : block));
233
+ ref.current?.focus();
234
+ };
235
+ const primary = () => (buttons.length ? void save(buttons[buttons.length - 1].dest) : void ask());
236
+ return (
237
+ <div className="composer" onKeyDown={(e) => {
238
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); primary(); }
239
+ else if (e.key === "Escape") { e.preventDefault(); onCancel(); }
240
+ }}>
241
+ {label && <div className="composer-label">{label}</div>}
242
+ <div className="composer-field">
243
+ <textarea ref={ref} value={body} rows={2} placeholder={buttons.length || edit ? "Leave a comment" : "Ask the agent about these lines"} aria-label="Comment" onChange={(e) => (setBody(e.target.value), setError(undefined))} />
244
+ {suggest && <button className="composer-tool" onClick={addSuggestion} title="Suggest a change to these lines" aria-label="Suggest a change">{Icon.suggest}</button>}
245
+ </div>
246
+ {error && <div className="composer-error" role="alert">{error}</div>}
247
+ <div className="composer-bar">
248
+ {onAsk && !edit && buttons.length > 0 && <button className="btn ghost sm ask" disabled={!text.trim() || busy} onClick={() => void ask()} title="Send it to the chat with the agent, with these lines">{Icon.cursor} Ask agent</button>}
249
+ <span className="grow" />
250
+ <button className="btn ghost sm" onClick={onCancel}>Cancel</button>
251
+ {buttons.map((b, i) => (
252
+ <button key={b.label} className={`btn sm${i === buttons.length - 1 ? " primary" : ""}`} disabled={!text.trim() || busy} onClick={() => void save(b.dest)}>{b.label}</button>
253
+ ))}
254
+ {onAsk && !edit && !buttons.length && <button className="btn sm primary ask" disabled={!text.trim() || busy} onClick={() => void ask()}>{Icon.cursor} Ask agent</button>}
255
+ </div>
256
+ </div>
257
+ );
258
+ }
259
+
260
+ export const rangeLabel = (a: Partial<Anchor>) => (a.startLine && a.startLine !== a.line ? `Lines ${a.startLine} to ${a.line}` : `Line ${a.line}`);
261
+
262
+ // how a comment is named when it is brought into the chat
263
+ const referWhere = (note: Note, t: Thread) => {
264
+ const whose = note.who === "You" ? "your comment" : note.agent ? "the agent's answer" : `${note.who}'s comment`;
265
+ return t.path ? `${whose} on ${t.path.split("/").pop()}, ${rangeLabel(t).toLowerCase()}` : t.topic ? `${whose} on ${t.topic.label}` : whose;
266
+ };
267
+
268
+ const STATUS = { sent: "Sent", working: "Working on it", done: "Answered", stopped: "Stopped" } as const;
269
+ const editable = (c: ReviewComment) => c.to === "github" || c.status === "sent" || c.status === "stopped";
270
+ export const retryComment = (id: string) => call(`api/comments/${id}/retry`, "POST");
271
+
272
+ function NoteView({ note, thread, original }: { note: Note; thread: Thread; original?: string[] }) {
273
+ const [editing, setEditing] = useState(false);
274
+ const [error, setError] = useState<string>();
275
+ const [adding, setAdding] = useState(false);
276
+ const live = useLive();
277
+ const d = note.mine;
278
+ const a = note.agent;
279
+ // an agent's suggested change goes into the reviewer's review on GitHub, on the lines the thread is on
280
+ const suggestion = a && !!pr.number && !!thread.path && !!thread.line && /^```suggestion$/m.test(a.body) ? a.body : undefined;
281
+ const adopted = suggestion && live.comments.some((c) => c.to === "github" && c.body === suggestion);
282
+ const adopt = async () => {
283
+ setAdding(true);
284
+ const e = await addComment({ to: "github", mode: "review", path: thread.path, side: thread.side, line: thread.line, ...(thread.startLine && { startLine: thread.startLine, startSide: thread.startSide }), ...(thread.root && { inReplyTo: thread.root }), body: suggestion as string });
285
+ setAdding(false);
286
+ setError(e);
287
+ };
288
+ return (
289
+ <div className={`note${d && d.github?.state !== "posted" ? " draft" : ""}${a ? " from-agent" : ""}`}>
290
+ <div className="note-head">
291
+ <span className="avatar">{note.who[0]?.toUpperCase()}</span>
292
+ <b>{note.who}</b>
293
+ {note.bot && <span className="note-tag">bot</span>}
294
+ {d?.agent && <span className="note-tag">Drafted by the agent</span>}
295
+ {d?.github?.state === "pending" && <span className="note-tag pending">Pending</span>}
296
+ {d?.to === "agent" && d.status && <span className={`note-tag agent ${d.status}`}>{d.status === "working" && <span className="lamp" />}{STATUS[d.status]}</span>}
297
+ {a?.commit && <span className="note-tag mono">{a.commit.slice(0, 7)}</span>}
298
+ {!(d?.github?.state === "pending") && !(d?.to === "agent") && <span className="note-when">{when(note.at)}</span>}
299
+ <button className="icon-btn refer" onClick={() => referToChat({ text: note.body, where: referWhere(note, thread), kind: "comment" })} title="Add to the chat with the agent" aria-label="Add this comment to the chat with the agent">{Icon.cursorPlus}</button>
300
+ {d && editable(d) && !editing && (
301
+ <span className="note-actions">
302
+ <button className="btn ghost sm" onClick={() => setEditing(true)}>Edit</button>
303
+ <button className="btn ghost sm" onClick={async () => setError(await dropComment(d.id))}>Delete</button>
304
+ </span>
305
+ )}
306
+ </div>
307
+ {editing && d ? (
308
+ <Composer edit initial={d.body} suggest={d.side === "RIGHT" ? original : undefined} onCancel={() => setEditing(false)} onSave={async (b) => { const e = await editComment(d.id, b); if (!e) setEditing(false); return e; }} />
309
+ ) : (
310
+ <Body text={note.body} original={original} />
311
+ )}
312
+ {d?.status === "stopped" && (
313
+ <div className="note-foot stopped">
314
+ <span>The agent stopped without answering.</span>
315
+ <button className="btn sm" onClick={async () => setError(await retryComment(d.id))}>Send again</button>
316
+ </div>
317
+ )}
318
+ {suggestion && (
319
+ <div className="note-foot">
320
+ {adopted ? <span className="note-when">In your review</span> : <button className="btn sm" disabled={adding} onClick={() => void adopt()}>{adding ? "Adding" : "Add to your review"}</button>}
321
+ </div>
322
+ )}
323
+ {error && <div className="composer-error" role="alert">{error}</div>}
324
+ </div>
325
+ );
326
+ }
327
+
328
+ // a thread under the line or part of the tour it is on; resolved ones fold to a line
329
+ export function ThreadView({ thread, original }: { thread: Thread; original?: string[] }) {
330
+ const [open, setOpen] = useState(!thread.resolved);
331
+ const [replying, setReplying] = useState(false);
332
+ const onGitHub = thread.root !== undefined;
333
+ const first = thread.notes[0];
334
+ if (!open)
335
+ return (
336
+ <button className="thread folded" onClick={() => setOpen(true)} aria-expanded={false}>
337
+ {Icon.check}<span className="thread-state">Resolved</span><b>{first.who}</b><span className="thread-gist">{first.body.replace(/<[^>]*>|```[\s\S]*?```/g, "").replace(/\s+/g, " ").trim()}</span>
338
+ {thread.notes.length > 1 && <span className="thread-count">{thread.notes.length}</span>}
339
+ </button>
340
+ );
341
+ const at = thread.path ? { path: thread.path, side: thread.side, line: thread.line } : {};
342
+ return (
343
+ <div className="thread">
344
+ {(thread.resolved || thread.outdated) && (
345
+ <div className="thread-top">
346
+ {thread.resolved && <span className="thread-state resolved">{Icon.check} Resolved</span>}
347
+ {thread.outdated && <span className="thread-state">Outdated, was {rangeLabel(thread).toLowerCase()}</span>}
348
+ {thread.resolved && <button className="link thread-hide" onClick={() => setOpen(false)}>Hide</button>}
349
+ </div>
350
+ )}
351
+ {thread.notes.map((n) => <NoteView key={n.key} note={n} thread={thread} original={original} />)}
352
+ {replying ? (
353
+ <Composer github={onGitHub} onCancel={() => setReplying(false)}
354
+ onSave={async (body, d) => {
355
+ const e = await addComment({ ...d, ...at, inReplyTo: thread.root, body });
356
+ if (!e) setReplying(false);
357
+ return e;
358
+ }}
359
+ onAsk={async (body) => {
360
+ // the lines the thread is on, and how it started
361
+ const lines = original?.length && thread.path ? [{ kind: "selection" as const, text: dedent(original), where: `${thread.path.split("/").pop()}, ${rangeLabel(thread).toLowerCase()}` }] : [];
362
+ const e = await askInChat({ body, quotes: [...lines, { kind: "comment", text: first.body, where: referWhere(first, thread) }] });
363
+ if (!e) setReplying(false);
364
+ return e;
365
+ }} />
366
+ ) : (
367
+ <button className="reply" onClick={() => setReplying(true)}>Reply</button>
368
+ )}
369
+ </div>
370
+ );
371
+ }
372
+
373
+
374
+
375
+ export const useComments = () => useLive().comments;
376
+ // what the reviewer still has open: pending on GitHub, or not yet answered by the agent
377
+ export const openCount = (cs: ReviewComment[]) => cs.filter((c) => c.github?.state === "pending").length;