@fullstackhouse/cerber 0.29.0 → 0.29.1

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 CHANGED
@@ -96,9 +96,15 @@ running. It goes to whatever this machine already has (Notification Centre on
96
96
  macOS, `notify-send` on Linux) and stays quiet where there is neither. Off with
97
97
  `daemon.notify` in `~/.cerber/config.json`, or the checkbox in Settings.
98
98
 
99
- The cockpit has a bell of its own, and it is the better one where it can ring:
100
- its notification **opens that review when you click it**. But it is a page
101
- notification — it needs a tab that is open, alive and permitted, which is
99
+ **On macOS, clicking it opens that review** in the cockpit. It takes a small app
100
+ cerber builds once under `~/.cerber` — the first tap asks you to allow
101
+ notifications from "Cerber" — because a notification can only open the app that
102
+ posted it, and one posted the usual way belongs to Script Editor, which is
103
+ where the click would otherwise land. A `notify-send` tap on Linux announces
104
+ the PR but has no click to give.
105
+
106
+ The cockpit has a bell of its own, which does the same from the page. But it is
107
+ a page notification — it needs a tab that is open, alive and permitted, which is
102
108
  exactly what you don't have on the afternoons this feature is for. So it stands
103
109
  down while the machine's own tap is on, rather than making one PR two popups,
104
110
  and the bell in the top bar says so. Untick the machine one and this browser
@@ -0,0 +1,21 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
2
+ <!-- The app icon for the notifier bundle cerber builds under ~/.cerber, and
3
+ the source the committed assets/cerber.icns is rendered from (see the
4
+ note in src/core/notify.ts). Same paw as web/public/favicon.svg — an app
5
+ icon is a squircle on the shelf, so the mark is set on the brand tile
6
+ rather than floated on nothing, which at notification size reads as a
7
+ missing icon. -->
8
+ <defs>
9
+ <linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
10
+ <stop offset="0" stop-color="#5f6ef2" />
11
+ <stop offset="1" stop-color="#2f3bbd" />
12
+ </linearGradient>
13
+ </defs>
14
+ <rect x="100" y="100" width="824" height="824" rx="185" fill="url(#tile)" />
15
+ <g fill="#fff" transform="translate(200 188) scale(26)">
16
+ <circle cx="6.1" cy="8.9" r="2.5" />
17
+ <circle cx="12" cy="6.7" r="2.7" />
18
+ <circle cx="17.9" cy="8.9" r="2.5" />
19
+ <ellipse cx="12" cy="16.6" rx="5.4" ry="4.3" />
20
+ </g>
21
+ </svg>
Binary file
package/dist/cli/index.js CHANGED
@@ -13,7 +13,7 @@ import { cerberHome, listArtifacts, loadArtifact, readAutoSendLog, reconcileRunn
13
13
  import { pool, reviewPr } from "../runner/review.js";
14
14
  import { isReviewRunning } from "../runner/inflight.js";
15
15
  import { startDaemon } from "../server/daemon.js";
16
- import { startServer } from "../server/index.js";
16
+ import { cockpitUrl, startServer } from "../server/index.js";
17
17
  const program = new Command();
18
18
  program
19
19
  .name("cerber")
@@ -415,6 +415,13 @@ program
415
415
  trust: opts.trust ? undefined : false,
416
416
  autoReview: opts.autoReview,
417
417
  notify: opts.notify,
418
+ // The address as asked for, which the server corrects to the one it
419
+ // bound (`cockpitAt`) a moment later. Both, because neither alone
420
+ // is enough: the daemon's first poll runs while the port is still
421
+ // being bound, and a PR discovered on it would otherwise be
422
+ // announced with nowhere to click — while `--port 0` has no
423
+ // answer here at all, since the OS has not chosen yet.
424
+ cockpitUrl: cockpitUrl({ host: opts.host, port: Number(opts.port), token }) ?? undefined,
418
425
  autoSend: opts.autoSend ? "on" : "shadow",
419
426
  autoSendThreshold: threshold,
420
427
  })
@@ -7,11 +7,26 @@
7
7
  // all afternoon. This half rides the poll that discovers the PR instead, so the
8
8
  // tap survives a closed cockpit, a denied permission and a browser restart.
9
9
  //
10
+ // A click has to land in the same place either way, which on macOS is the whole
11
+ // difference between the two paths below. `osascript -e 'display notification'`
12
+ // posts as Script Editor — that is the app macOS sees making the call — so
13
+ // clicking the tap opens Script Editor, an app the user never asked for and a
14
+ // dead end from a notification about a PR. A notification can only open what
15
+ // posted it, so cerber posts its own: a small app bundle built once under
16
+ // `~/.cerber`, which opens the review in the cockpit when clicked. Where it
17
+ // can't be built, the plain `osascript` tap is still better than silence.
18
+ //
10
19
  // Everything here shells out to whatever the OS already has. No dependency, no
11
20
  // daemon of its own, and a machine with no notifier is a machine cerber stays
12
21
  // quiet on rather than one it fails on.
13
22
  import { execFile } from "node:child_process";
23
+ import { promises as fs } from "node:fs";
24
+ import os from "node:os";
25
+ import path from "node:path";
26
+ import { fileURLToPath } from "node:url";
14
27
  import { promisify } from "node:util";
28
+ import { artifactId, artifactKey } from "./artifact.js";
29
+ import { cerberHome } from "./state.js";
15
30
  const execFileAsync = promisify(execFile);
16
31
  /**
17
32
  * How long a notifier gets before the poll stops waiting for it.
@@ -25,23 +40,42 @@ const execFileAsync = promisify(execFile);
25
40
  */
26
41
  export const NOTIFY_TIMEOUT_MS = 5_000;
27
42
  const slug = (a) => `${a.repo}#${a.number}`;
43
+ /**
44
+ * Text a notification body or an AppleScript literal can actually hold. A PR
45
+ * title is somebody else's text and can carry anything; control characters
46
+ * collapse to a space rather than splitting a line the applet reads back by
47
+ * line, or ending a string literal early.
48
+ */
49
+ const flatten = (value) => value.replace(/[\u0000-\u001f\u007f]+/g, " ");
28
50
  /**
29
51
  * One popup for one poll's arrivals — a batch is one interruption, not five.
30
52
  * Deliberately the same shape and wording the cockpit's bell uses, so the two
31
53
  * channels never read as two different pieces of news about one PR.
54
+ *
55
+ * `cockpit` is where clicking it lands: the review itself when the notice is
56
+ * about exactly one PR, the queue when it is about several. It must be an
57
+ * origin with no fragment of its own — the deep link is a fragment, and the
58
+ * cockpit is a hash-routed page.
32
59
  */
33
- export function notice(arrived) {
60
+ export function notice(arrived, cockpit) {
34
61
  if (arrived.length === 0)
35
62
  return null;
63
+ const base = cockpit || null;
36
64
  if (arrived.length === 1) {
37
65
  const a = arrived[0];
38
- return { title: `${slug(a)} awaits your review`, body: `${a.title} — ${a.author}` };
66
+ const key = artifactKey(artifactId(a));
67
+ return {
68
+ title: `${slug(a)} awaits your review`,
69
+ body: `${a.title} — ${a.author}`,
70
+ url: base && `${base}#/r/${encodeURIComponent(key)}`,
71
+ };
39
72
  }
40
73
  const named = arrived.slice(0, 3).map(slug);
41
74
  const rest = arrived.length - named.length;
42
75
  return {
43
76
  title: `${arrived.length} PRs await your review`,
44
77
  body: rest > 0 ? `${named.join(", ")} and ${rest} more` : named.join(", "),
78
+ url: base,
45
79
  };
46
80
  }
47
81
  /**
@@ -54,13 +88,15 @@ export function notice(arrived) {
54
88
  * can't appear in a literal at all, so they collapse to a space.
55
89
  */
56
90
  export function appleScriptLiteral(value) {
57
- const flat = value.replace(/[\u0000-\u001f\u007f]+/g, " ");
58
- return `"${flat.replace(/[\\"]/g, (c) => `\\${c}`)}"`;
91
+ return `"${flatten(value).replace(/[\\"]/g, (c) => `\\${c}`)}"`;
59
92
  }
60
93
  /**
61
94
  * The command that taps this machine's notification centre, or null where there
62
95
  * is nothing to tap. macOS and the freedesktop notifiers cover what cerber runs
63
96
  * on; anywhere else it stays quiet rather than pretending.
97
+ *
98
+ * On macOS this is the fallback rather than the first choice — see
99
+ * `ensureNotifierApp` for the one whose click opens the review.
64
100
  */
65
101
  export function notifyCommand(n, platform = process.platform) {
66
102
  if (platform === "darwin") {
@@ -83,6 +119,275 @@ export function notifyCommand(n, platform = process.platform) {
83
119
  }
84
120
  return null;
85
121
  }
122
+ /**
123
+ * What the notification centre knows cerber by. Permission is granted to this
124
+ * identifier, once, by the user — so it has to survive every rebuild, or each
125
+ * new version of cerber would ask for notifications again as a stranger.
126
+ */
127
+ export const BUNDLE_ID = "house.fullstack.cerber";
128
+ /** Bumped whenever the applet or its plist changes, so an installed app is replaced. */
129
+ const APP_BUILD = "3";
130
+ const ICON = fileURLToPath(new URL("../../assets/cerber.icns", import.meta.url));
131
+ const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
132
+ /**
133
+ * The app's whole job, in the only language a bundle this small can be written
134
+ * in without a compiler on the user's machine.
135
+ *
136
+ * It is launched twice per notification and has to tell the two apart with no
137
+ * arguments, because a click gives it none: macOS just opens the app that
138
+ * posted the notification. The pending file is what distinguishes them, and
139
+ * reading it is destructive — a launch that finds a notice posts it, a launch
140
+ * that finds none is the click on the last one, and opens where that one
141
+ * pointed. Nothing else can put the app in either state, so the two can't be
142
+ * confused.
143
+ *
144
+ * The paths are baked in rather than derived from the home folder, so a cerber
145
+ * running under CERBER_HOME talks to the app it actually built.
146
+ */
147
+ export function appletSource(dir) {
148
+ const pending = appleScriptLiteral(path.join(dir, "pending.txt"));
149
+ const landing = appleScriptLiteral(path.join(dir, "url.txt"));
150
+ // `on run` is a launch from cold, `on reopen` a click while it is still up
151
+ // after posting — same job either way.
152
+ return `on run
153
+ tap()
154
+ end run
155
+
156
+ on reopen
157
+ tap()
158
+ end reopen
159
+
160
+ on tap()
161
+ set pendingFile to ${pending}
162
+ set urlFile to ${landing}
163
+ try
164
+ set raw to do shell script "cat " & quoted form of pendingFile & " 2>/dev/null; rm -f " & quoted form of pendingFile
165
+ on error
166
+ set raw to ""
167
+ end try
168
+ if raw is "" then
169
+ try
170
+ set target to do shell script "cat " & quoted form of urlFile & " 2>/dev/null"
171
+ on error
172
+ set target to ""
173
+ end try
174
+ -- Consumed on the way out, the same way the notice was: what is left
175
+ -- behind is what cerber reads as "an earlier notification is still
176
+ -- sitting there unclicked".
177
+ if target is not "" then
178
+ do shell script "open " & quoted form of target & "; rm -f " & quoted form of urlFile
179
+ else
180
+ do shell script "rm -f " & quoted form of urlFile
181
+ end if
182
+ return
183
+ end if
184
+ -- do shell script hands back the lines separated by return, not linefeed.
185
+ set AppleScript's text item delimiters to return
186
+ set parts to text items of raw
187
+ if (count of parts) < 2 then return
188
+ set target to ""
189
+ if (count of parts) > 2 then set target to item 3 of parts
190
+ -- umask, because the target carries the cockpit's token: nothing here is
191
+ -- another account's business to read.
192
+ do shell script "umask 077; printf %s " & quoted form of target & " > " & quoted form of urlFile
193
+ display notification (item 2 of parts) with title (item 1 of parts)
194
+ end tap
195
+ `;
196
+ }
197
+ /**
198
+ * Where a notice may point, given whether an earlier one is still sitting
199
+ * unclicked in Notification Centre.
200
+ *
201
+ * A click reaches the app with no arguments — macOS just opens whatever posted
202
+ * the notification — so one app can hold exactly one click target, and two
203
+ * outstanding notifications cannot be told apart. Pointing both at the newest
204
+ * review would open the wrong PR from the older notification, silently and
205
+ * convincingly. So the second one drops its fragment and both land on the
206
+ * queue, where every arrival is a row: less specific, never wrong.
207
+ *
208
+ * It heals itself. The target is consumed by the click that follows it, so the
209
+ * next notice after any click deep-links again; only a run of notifications
210
+ * nobody touches stays on the queue.
211
+ */
212
+ export function clickTarget(url, outstanding) {
213
+ if (!url || !outstanding)
214
+ return url;
215
+ const fragment = url.indexOf("#");
216
+ return fragment < 0 ? url : url.slice(0, fragment);
217
+ }
218
+ /**
219
+ * The notice, as the app reads it back: title, body and click target, one line
220
+ * each. Flattened first — a newline in a PR title would otherwise shift every
221
+ * line after it, and the body would arrive as the URL.
222
+ */
223
+ export function pendingPayload(n) {
224
+ return `${flatten(n.title)}\n${flatten(n.body)}\n${flatten(n.url ?? "")}\n`;
225
+ }
226
+ async function run(file, args) {
227
+ await execFileAsync(file, args, { timeout: NOTIFY_TIMEOUT_MS });
228
+ }
229
+ /**
230
+ * Set a key in the applet's Info.plist whether or not it is already there.
231
+ *
232
+ * `plutil -replace` is documented as overwriting an *existing* value — it does
233
+ * create a missing key in practice, but on undocumented behaviour, and every
234
+ * key this sets is one `osacompile` may or may not have written. A failure
235
+ * here fails the build and silently costs the click, so the documented
236
+ * inserting form is the fallback rather than the assumption.
237
+ */
238
+ async function setPlistValue(plist, key, type, value) {
239
+ const flags = [key, type, value, plist];
240
+ await run("plutil", ["-replace", ...flags]).catch(() => run("plutil", ["-insert", ...flags]));
241
+ }
242
+ /**
243
+ * Build the app cerber posts through, into `~/.cerber/Cerber.app`.
244
+ *
245
+ * Three things here are load-bearing, each learned the way macOS teaches them —
246
+ * by dropping the notification and saying nothing:
247
+ *
248
+ * - **A bundle identifier.** `osacompile` leaves the applet without one, and
249
+ * a bundle with no identity gets its notifications denied.
250
+ * - **A signature that matches.** Editing `Info.plist` invalidates the ad-hoc
251
+ * signature `osacompile` writes, and a bundle whose seal is broken is denied
252
+ * just as flatly. So it is re-signed, ad-hoc, after the edits.
253
+ * - **A path LaunchServices will register.** A bundle under `/tmp` is never
254
+ * resolved to an app, so the request for permission is never even asked.
255
+ * `~/.cerber` is fine, and `lsregister` makes sure it is known before the
256
+ * first notification rather than after it.
257
+ */
258
+ async function buildNotifierApp(app, dir) {
259
+ // 0700: the notices left here hold the cockpit's URL, token and all.
260
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
261
+ const source = path.join(dir, "applet.applescript");
262
+ await fs.writeFile(source, appletSource(dir));
263
+ // Staged in the one place LaunchServices refuses to register — the same
264
+ // refusal that makes a bundle under /tmp undeliverable also makes it
265
+ // unlaunchable, which is what a half-built app must be. Staged beside the
266
+ // real one it is a second app with cerber's own bundle identifier, and a
267
+ // launch that reaches it mid-build finds an applet with no script yet and
268
+ // puts up AppleScript's "Press Run to run this script" dialog.
269
+ //
270
+ // The name still ends in .app because osacompile picks what it writes from
271
+ // the extension, and anything else gets a bare script file with no bundle
272
+ // around it — no Info.plist, nothing to sign, nothing to post.
273
+ const stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), "cerber-notifier-"));
274
+ try {
275
+ await assemble(path.join(stagingDir, "Cerber.app"), app, source);
276
+ }
277
+ finally {
278
+ // Every attempt, not just the ones that worked: a machine with no
279
+ // osacompile fails here once per process, and each failure would otherwise
280
+ // leave its half-built bundle behind for good.
281
+ await fs.rm(stagingDir, { recursive: true, force: true });
282
+ }
283
+ }
284
+ /** Everything between an empty staging directory and an installed, signed app. */
285
+ async function assemble(staged, app, source) {
286
+ await run("osacompile", ["-o", staged, source]);
287
+ const plist = path.join(staged, "Contents", "Info.plist");
288
+ await setPlistValue(plist, "CFBundleIdentifier", "-string", BUNDLE_ID);
289
+ // An agent, not an app: cerber's notifier has no window and no business in
290
+ // the Dock or the app switcher for the second it spends posting.
291
+ await setPlistValue(plist, "LSUIElement", "-bool", "true");
292
+ // The applet ships an asset catalogue holding the generic script icon, and a
293
+ // catalogue wins over anything in Resources/ — so the paw only lands once the
294
+ // catalogue and the name it answers to are both gone.
295
+ const resources = path.join(staged, "Contents", "Resources");
296
+ await fs.rm(path.join(resources, "Assets.car"), { force: true });
297
+ await run("plutil", ["-remove", "CFBundleIconName", plist]).catch(() => { });
298
+ await fs.copyFile(ICON, path.join(resources, "applet.icns")).catch(() => {
299
+ // No icon is a generic icon, not a failure — the tap still works.
300
+ });
301
+ await run("codesign", ["--force", "--sign", "-", staged]);
302
+ // Not atomic, because a directory swap can't be: the window between these two
303
+ // is a few milliseconds in which a notification would fall back to osascript.
304
+ await fs.rm(app, { recursive: true, force: true });
305
+ try {
306
+ await fs.rename(staged, app);
307
+ }
308
+ catch {
309
+ // A CERBER_HOME on another volume: rename can't cross one, and a copy
310
+ // carries the signature with it — it lives in the bundle's own files.
311
+ await fs.cp(staged, app, { recursive: true });
312
+ }
313
+ await run(LSREGISTER, ["-f", app]).catch(() => {
314
+ // Best effort: `open` registers the bundle too, just later than we'd like.
315
+ });
316
+ }
317
+ /**
318
+ * One build attempt per home per process, whatever the answer.
319
+ *
320
+ * The poll awaits `announce`, so retrying a hopeless build every time a PR
321
+ * lands would spend a timeout apiece on a machine that is never going to have
322
+ * `osacompile`. A remembered failure falls back to the plain tap instead.
323
+ */
324
+ const builds = new Map();
325
+ export function ensureNotifierApp() {
326
+ // Resolved, because the paths go *into* the app: CERBER_HOME may be
327
+ // relative, and an app LaunchServices launches on a click inherits none of
328
+ // the daemon's working directory to resolve it against.
329
+ const home = path.resolve(cerberHome());
330
+ let building = builds.get(home);
331
+ if (!building) {
332
+ building = installNotifierApp(home).catch(() => null);
333
+ builds.set(home, building);
334
+ }
335
+ return building;
336
+ }
337
+ async function installNotifierApp(home) {
338
+ const app = path.join(home, "Cerber.app");
339
+ const dir = path.join(home, "notify");
340
+ const stamp = path.join(dir, "build");
341
+ const want = `${APP_BUILD} ${app}\n`;
342
+ const built = await fs
343
+ .stat(path.join(app, "Contents", "Info.plist"))
344
+ .then(() => true)
345
+ .catch(() => false);
346
+ if (built && (await fs.readFile(stamp, "utf8").catch(() => null)) === want)
347
+ return app;
348
+ await buildNotifierApp(app, dir);
349
+ await fs.writeFile(stamp, want);
350
+ return app;
351
+ }
352
+ /**
353
+ * Hand the notice to cerber's own app, so a click on it opens the review.
354
+ * False means the app couldn't take it and the caller should fall back — the
355
+ * tap matters more than which app posts it.
356
+ */
357
+ async function postThroughApp(n) {
358
+ const app = await ensureNotifierApp();
359
+ if (!app)
360
+ return false;
361
+ // Beside the app that was actually built, rather than wherever CERBER_HOME
362
+ // points now — the paths the applet reads were baked into it at build time.
363
+ const dir = path.join(path.dirname(app), "notify");
364
+ const file = path.join(dir, "pending.txt");
365
+ const tmp = path.join(dir, `pending.${process.pid}.tmp`);
366
+ try {
367
+ // A target the app hasn't consumed means the notification it belongs to is
368
+ // still there to be clicked, and two of them cannot be told apart.
369
+ const outstanding = await fs
370
+ .stat(path.join(dir, "url.txt"))
371
+ .then(() => true)
372
+ .catch(() => false);
373
+ // tmp+rename, so the app can never read half a notice: it reads the file
374
+ // the instant `open` wakes it, and a partial one would post a PR title cut
375
+ // in two or a URL that leads nowhere.
376
+ // 0600 for the same reason the app's own write is: this line is the
377
+ // cockpit URL, and the cockpit URL carries the token that gets in.
378
+ await fs.writeFile(tmp, pendingPayload({ ...n, url: clickTarget(n.url, outstanding) }), {
379
+ mode: 0o600,
380
+ });
381
+ await fs.rename(tmp, file);
382
+ // -g, so a PR landing doesn't pull focus out of whatever you're doing.
383
+ await run("open", ["-g", app]);
384
+ return true;
385
+ }
386
+ catch {
387
+ await fs.rm(tmp, { force: true }).catch(() => { });
388
+ return false;
389
+ }
390
+ }
86
391
  /**
87
392
  * Show it, and say whether it was shown. False is an ordinary answer here —
88
393
  * an unsupported platform, no `notify-send` installed, a headless box, a
@@ -91,8 +396,10 @@ export function notifyCommand(n, platform = process.platform) {
91
396
  * important thing a poll does, so it is also the last thing allowed to stop
92
397
  * one.
93
398
  */
94
- export async function notify(n) {
95
- const cmd = notifyCommand(n);
399
+ export async function notify(n, platform = process.platform) {
400
+ if (platform === "darwin" && (await postThroughApp(n)))
401
+ return true;
402
+ const cmd = notifyCommand(n, platform);
96
403
  if (!cmd)
97
404
  return false;
98
405
  try {
@@ -538,6 +538,8 @@ export function startDaemon(opts) {
538
538
  }
539
539
  /** Said once, not every poll: a machine with no notifier will never grow one. */
540
540
  let notifierMissing = false;
541
+ /** Null until `serve` is listening and says so — see `cockpitAt`. */
542
+ let cockpitUrl = opts.cockpitUrl ?? null;
541
543
  /**
542
544
  * What the last attempt to tap this machine actually did, or null before one
543
545
  * has been made.
@@ -560,7 +562,7 @@ export function startDaemon(opts) {
560
562
  * why it stands down when this is on and both are on one machine.
561
563
  */
562
564
  async function announce(arrivals) {
563
- const n = notice(arrivals);
565
+ const n = notice(arrivals, cockpitUrl);
564
566
  if (!n)
565
567
  return;
566
568
  notifierWorks = await notify(n);
@@ -756,6 +758,9 @@ export function startDaemon(opts) {
756
758
  void poll();
757
759
  return {
758
760
  status: () => ({ ...status }),
761
+ cockpitAt: (url) => {
762
+ cockpitUrl = url;
763
+ },
759
764
  stop: () => {
760
765
  stopped = true;
761
766
  if (timer)
@@ -690,6 +690,39 @@ export async function buildApp(opts) {
690
690
  }
691
691
  return app;
692
692
  }
693
+ /**
694
+ * The wildcard binds, and the loopback address each one answers on.
695
+ *
696
+ * Only these two are translated. A bind to `::1`, `localhost` or a particular
697
+ * interface is already an address that answers here, and rewriting it would
698
+ * point the click somewhere nothing is listening — `serve --host ::1` serves
699
+ * IPv6 loopback alone, where `127.0.0.1` does not connect at all. `::` maps to
700
+ * IPv6 loopback rather than IPv4, because a dual-stack socket is the only
701
+ * reason `127.0.0.1` would work on one and it is not guaranteed to be one.
702
+ */
703
+ const WILDCARD = new Map([
704
+ ["0.0.0.0", "127.0.0.1"],
705
+ ["::", "::1"],
706
+ ]);
707
+ /**
708
+ * The cockpit's own address, as reached from the machine `serve` runs on — what
709
+ * a desktop notification opens when you click it.
710
+ *
711
+ * It is the bound host only when that host is a particular interface; a
712
+ * wildcard bind is every interface, and the one that always answers from here
713
+ * is loopback. The token rides along because a click has to land on the review
714
+ * rather than on a 401, and it is the same token `serve` already prints to the
715
+ * console on startup. Null where there is no address to name yet — port 0 is
716
+ * whatever the OS picks, which isn't known until it has picked it.
717
+ */
718
+ export function cockpitUrl(opts) {
719
+ if (!Number.isInteger(opts.port) || opts.port <= 0)
720
+ return null;
721
+ const host = WILDCARD.get(opts.host) ?? opts.host;
722
+ const authority = host.includes(":") ? `[${host}]` : host;
723
+ const query = opts.token ? `?token=${encodeURIComponent(opts.token)}` : "";
724
+ return `http://${authority}:${opts.port}/${query}`;
725
+ }
693
726
  /**
694
727
  * Serve the cockpit. `reconcileRunning` is deliberately *not* called here: the
695
728
  * caller must have done it before starting the daemon, because the daemon polls
@@ -701,6 +734,9 @@ export async function startServer(opts) {
701
734
  serve({ fetch: app.fetch, port: opts.port, hostname: opts.host }, (info) => {
702
735
  const tokenHint = opts.token ? `/?token=${opts.token}` : "";
703
736
  console.log(`cerber cockpit: http://${opts.host}:${info.port}${tokenHint}`);
737
+ // The bound port, not the asked-for one: `--port 0` is the OS's to choose,
738
+ // and this callback is the first moment anyone knows what it chose.
739
+ opts.daemon?.cockpitAt(cockpitUrl({ ...opts, port: info.port }));
704
740
  if (opts.daemon) {
705
741
  const s = opts.daemon.status();
706
742
  console.log(`inbox: polling every ${Math.round(s.intervalMs / 60_000)}m` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.29.0",
3
+ "version": "0.29.1",
4
4
  "description": "AI code-review cockpit: Claude reviews your PRs into local artifacts; nothing reaches GitHub until you say so.",
5
5
  "license": "MIT",
6
6
  "author": "Full Stack House (https://fullstack.house)",
@@ -16,6 +16,7 @@
16
16
  "files": [
17
17
  "dist",
18
18
  "web/dist",
19
+ "assets",
19
20
  "README.md",
20
21
  "LICENSE"
21
22
  ],
@@ -238,4 +238,4 @@ Please report this to https://github.com/markedjs/marked.`,s){let l="<p>An error
238
238
  `)+`
239
239
  `;let p=g;for(const b of ft.lexer(m)){const v=b.raw,_=v.trimEnd();if(_===""){p+=eo(v);continue}const x=v.length-v.trimStart().length,N=p+eo(v.slice(0,x)),A=N+eo(_.slice(x));let w=!1;for(let U=N;U<=A&&!w;U++)w=y.has(U);l.push({kind:"block",raw:_.slice(x),from:N,to:A,changed:w}),p+=eo(v)}u=[]};for(const g of i){const y=u[u.length-1];if(y&&g.n!==y.n+1){const m=g.n-y.n-1;d(),l.push({kind:"gap",lines:m})}u.push(g)}return d(),{items:l,isNew:r,lines:i.length}}const lb={APPROVE:"approve",COMMENT:"comment",REQUEST_CHANGES:"request changes"},yT={APPROVE:"approve",COMMENT:"comment",REQUEST_CHANGES:"changes"};function vT(s){return(s==null?void 0:s.recommendation)==="approve"?"APPROVE":(s==null?void 0:s.recommendation)==="request_changes"?"REQUEST_CHANGES":"COMMENT"}function Kb(s,i){const r=WE(s);return i.filter(l=>{var u;return!l.drifted&&l.line!=null&&((u=r.get(l.path))==null?void 0:u.has(l.line))})}function sb(s,i){const r=d=>{const g=Number((d??"").trim());return Number.isInteger(g)&&g>0?g:null},l=r(i);if(l!=null)return{line:l,side:"new"};const u=r(s);return u!=null?{line:u,side:"old"}:null}function Pb(s){const i=s.comments.filter(u=>u.status==="dropped"),r=s.comments.filter(u=>u.status!=="dropped"),l=Kb(s.diff,r);return{inline:l,folded:r.filter(u=>!l.includes(u)),dropped:i}}function Ef(s){const i=s.comments.filter(u=>u.status!=="dropped");return["blocker","minor","nit"].map(u=>({tier:u,n:i.filter(d=>d.severity===u).length})).filter(({n:u})=>u>0).map(({tier:u,n:d})=>`${xb[u]} ${d} ${u}${d===1?"":"s"}`).join(" · ")}function ET(s){if(!s.comments.some(l=>l.severity!=null))return null;const r=s.comments.filter(l=>l.status!=="dropped"&&l.severity==="blocker").length;return r===0?"no blockers":`${r} blocker${r===1?"":"s"}`}function _T(s){if(!s.verdict||!s.comments.some(u=>u.severity!=null))return null;const r=s.comments.filter(u=>u.status!=="dropped"&&u.severity==="blocker").length,l=s.verdict.recommendation;return l==="approve"&&r>0?`the verdict says approve, but ${r} blocker${r===1?"":"s"} still stand${r===1?"s":""}`:l==="request_changes"&&r===0?"the verdict says request changes, but no blocker still stands":null}function rb(s){const{inline:i,folded:r}=Pb(s);if(s.bodyOverride!=null)return`${i.length===0?"no inline comments":`${i.length} inline`} · body you wrote`;if(i.length===0&&r.length===0)return"no inline comments · body only";const l=[`${i.length} inline`];return r.length>0&&l.push(`${r.length} folded into the body`),l.join(" · ")}const ob={approve:"approve",comment:"comment",request_changes:"changes"},ST={approve:"approve",comment:"comment",request_changes:"changes"};function TT(s,i){return s.status==="dropped"?"none":s.drifted?"comment":s.origin==="user"?"awaiting":i}function Zb(s,i=20){const r=document.getElementById(`c-${s}`);if(r){r.scrollIntoView({behavior:"smooth",block:"center"});return}i>0&&requestAnimationFrame(()=>Zb(s,i-1))}function NT(s){const i=s,r=i==null?void 0:i.tagName;return r==="INPUT"||r==="TEXTAREA"||r==="SELECT"||(i==null?void 0:i.isContentEditable)===!0}function cb(s,i){const r=document.createElement("tr");r.className=i;const l=document.createElement("td");l.colSpan=2;const u=document.createElement("div");return l.appendChild(u),r.appendChild(l),s.after(r),{tr:r,holder:u}}function Xb({pick:s,chatBusy:i,onAdd:r,onAsk:l,onClose:u}){const[d,g]=ie.useState(""),y=ie.useRef(null);ie.useEffect(()=>{var p;return(p=y.current)==null?void 0:p.focus()},[]);const m=d.trim();return f.jsxs("div",{className:"line-composer",children:[f.jsxs("div",{className:"line-composer-head",children:[f.jsxs("span",{className:"comment-loc",children:[s.path,":",s.line]}),s.side==="old"&&f.jsx("span",{className:"faint",title:"GitHub only takes inline comments on the new side of a diff.",children:"this line is gone from the new file — a comment on it posts on the file, not the line"})]}),f.jsx("textarea",{ref:y,className:"comment-edit",rows:3,placeholder:"what about this line?",value:d,onChange:p=>g(p.target.value),onKeyDown:p=>{p.key==="Escape"&&u(),p.key==="Enter"&&(p.metaKey||p.ctrlKey)&&m&&(p.preventDefault(),r(m))}}),f.jsx(Gs,{className:"comment-body",text:d}),f.jsxs("div",{className:"line-composer-actions",children:[f.jsxs("button",{className:"btn",disabled:!m,onClick:()=>r(m),children:[f.jsx(Ze,{name:"plus"}),"add comment",f.jsx(Va,{children:"⌘↵"})]}),f.jsxs("button",{className:"btn btn-accent",disabled:!m||i,title:i?"the reviewer is still answering the last question":"Ask the reviewer about this line — the answer lands in the chat below",onClick:()=>l(m),children:[f.jsx(Ze,{name:"comment"}),"ask the reviewer"]}),f.jsx("span",{className:"grow"}),f.jsx("button",{className:"btn btn-sm",onClick:u,children:"cancel"})]})]})}function xT({patch:s,comments:i,renderComment:r,readOnly:l,chatBusy:u,onAddLineComment:d,onAskAboutLine:g,onRead:y}){const m=ie.useMemo(()=>s.trim()?XE(s,{drawFileList:!1,matching:"lines",outputFormat:"line-by-line"}):"",[s]),p=ie.useMemo(()=>Ja(s).map(z=>z.path),[s]),b=ie.useMemo(()=>new Set(Ja(s).filter(z=>Xf(z.path)&&Qf(z.patch).lines>0).map(z=>z.path)),[s]),v=ie.useMemo(()=>i.map(z=>`${z.id}@${z.path}:${z.line}`).join("|"),[i]),_=ie.useRef(null),x=ie.useRef(y);x.current=y;const[N,A]=ie.useState([]),[w,U]=ie.useState([]),[G,L]=ie.useState(null),[re,H]=ie.useState(null);ie.useEffect(()=>{var I,ne;const z=_.current;if(!z)return;z.innerHTML=m,w0(z);const Q=new Map,K=[];z.querySelectorAll(".d2h-file-wrapper").forEach((oe,ve)=>{var D,q,ue,pe;const Se=(q=(D=oe.querySelector(".d2h-file-name"))==null?void 0:D.textContent)==null?void 0:q.trim(),S=Se&&p.includes(Se)?Se:p[ve];if(Se&&Q.set(Se,oe),S){Q.set(S,oe),oe.dataset.path=S;const xe=document.createElement("div");if(xe.className="file-comments",(ue=oe.querySelector(".d2h-file-header"))==null||ue.after(xe),K.push({path:S,el:xe}),b.has(S)){const me=document.createElement("button");me.type="button",me.className="btn btn-sm md-read-toggle",me.dataset.readPath=S,me.textContent="read as a document",me.title="Render this markdown instead of showing it line by line",(pe=oe.querySelector(".d2h-file-header"))==null||pe.appendChild(me)}}});const fe=[],we=new Map;for(const oe of i){if(oe.line==null)continue;const ve=Q.get(oe.path);if(!ve)continue;const Se=Array.from(ve.querySelectorAll(".d2h-diff-tbody > tr")).find(D=>{var q,ue;return((ue=(q=D.querySelector(".line-num2"))==null?void 0:q.textContent)==null?void 0:ue.trim())===String(oe.line)});if(!Se)continue;const S=cb(we.get(Se)??Se,"inline-comment-row");we.set(Se,S.tr),fe.push({id:oe.id,el:S.holder})}A(fe),U(K);const Ae=oe=>{const ve=oe.target.closest(".md-read-toggle");if(ve!=null&&ve.dataset.readPath){x.current(ve.dataset.readPath);return}const Se=oe.target.closest(".line-add");if(!Se)return;const{path:S,line:D,side:q}=Se.dataset;!S||!D||L({path:S,line:Number(D),side:q==="old"?"old":"new"})};z.addEventListener("click",Ae);const he=()=>z.removeEventListener("click",Ae);if(l)return L(null),H(null),he;for(const oe of z.querySelectorAll(".d2h-file-wrapper")){const ve=oe.dataset.path;if(ve)for(const Se of oe.querySelectorAll(".d2h-diff-tbody > tr > td.d2h-code-linenumber")){const S=sb((I=Se.querySelector(".line-num1"))==null?void 0:I.textContent,(ne=Se.querySelector(".line-num2"))==null?void 0:ne.textContent);if(!S)continue;const D=document.createElement("button");D.type="button",D.className="line-add";const q=`Say something about ${ve}:${S.line}`;D.title=q,D.setAttribute("aria-label",q),D.tabIndex=-1,D.dataset.path=ve,D.dataset.line=String(S.line),D.dataset.side=S.side,Se.appendChild(D)}}return H(null),he},[m,v,p,b,l]),ie.useEffect(()=>{const z=_.current;if(!z||!G||l){H(null);return}const Q=Array.from(z.querySelectorAll(".d2h-file-wrapper")).find(we=>we.dataset.path===G.path),K=Array.from((Q==null?void 0:Q.querySelectorAll(".d2h-diff-tbody > tr"))??[]).find(we=>{var I,ne;const Ae=we.querySelector(".d2h-code-linenumber"),he=sb((I=Ae==null?void 0:Ae.querySelector(".line-num1"))==null?void 0:I.textContent,(ne=Ae==null?void 0:Ae.querySelector(".line-num2"))==null?void 0:ne.textContent);return(he==null?void 0:he.line)===G.line&&he.side===G.side});if(!K){L(null);return}const fe=cb(K,"line-composer-row");return H(fe.holder),()=>fe.tr.remove()},[G,N,l]);const P=i.filter(z=>!N.some(Q=>Q.id===z.id)),le=w.map(z=>({...z,comments:P.filter(Q=>Q.path===z.path)})).filter(z=>z.comments.length>0),k=P.filter(z=>!w.some(Q=>Q.path===z.path));return f.jsxs(f.Fragment,{children:[f.jsx("div",{className:"diff",ref:_}),N.map(({id:z,el:Q})=>{const K=i.find(fe=>fe.id===z);return K?Nu.createPortal(r(K),Q,z):null}),le.map(({path:z,el:Q,comments:K})=>Nu.createPortal(f.jsx(f.Fragment,{children:K.map(r)}),Q,`file:${z}`)),G&&re&&Nu.createPortal(f.jsx(Xb,{pick:G,chatBusy:u,onAdd:z=>{d(G,z),L(null)},onAsk:z=>{g(G,z),L(null)},onClose:()=>L(null)}),re,`pick-${G.path}-${G.side}-${G.line}`),k.map(z=>r(z))]})}function wT({block:s,path:i,changed:r,readOnly:l,onPick:u}){const d=ie.useMemo(()=>Zf(s.raw),[s.raw]),g=`Say something about ${i}:${s.from}`;return f.jsxs("div",{className:`md-block${r?" md-block-changed":""}`,children:[!l&&f.jsx("button",{type:"button",className:"md-block-add",title:g,"aria-label":g,tabIndex:-1,onClick:u,children:f.jsx(Ze,{name:"plus"})}),f.jsx("div",{className:"md md-doc prose",dangerouslySetInnerHTML:{__html:d}})]})}function AT({path:s,doc:i,comments:r,renderComment:l,readOnly:u,chatBusy:d,onAddLineComment:g,onAskAboutLine:y,onShowDiff:m}){const[p,b]=ie.useState(null),v=ie.useMemo(()=>{const _=new Map,x=i.items.flatMap((A,w)=>A.kind==="block"?[{item:A,i:w}]:[]),N=[];for(const A of r){const w=A.line==null?void 0:x.filter(U=>U.item.from<=A.line).pop()??x[0];if(!w){N.push(A);continue}_.set(w.i,[..._.get(w.i)??[],A])}return{at:_,rest:N}},[i,r]);return f.jsxs("div",{className:"md-file",children:[f.jsxs("div",{className:"md-file-head",children:[f.jsx(Ze,{name:"file"}),f.jsx("span",{className:"md-file-name",children:s}),i.isNew?f.jsx("span",{className:"tag tag-added",children:"new file"}):f.jsx("span",{className:"faint",title:"A diff carries the lines around a change, not the file.",children:"the diff's lines only — what the PR removed isn't here"}),f.jsx("span",{className:"grow"}),f.jsx("button",{className:"btn btn-sm",onClick:m,title:"Back to the line-by-line diff",children:"show the diff"})]}),v.rest.length>0&&f.jsx("div",{className:"file-comments",children:v.rest.map(l)}),i.items.map((_,x)=>f.jsxs(ie.Fragment,{children:[_.kind==="gap"?f.jsxs("div",{className:"md-gap",title:"These lines are in the file but not in the diff.",children:["⋯ ",_.lines.toLocaleString()," line",_.lines===1?"":"s"," the diff skips"]}):f.jsx(wT,{block:_,path:s,changed:_.changed&&!i.isNew,readOnly:u,onPick:()=>b({path:s,line:_.from,side:"new"})}),(v.at.get(x)??[]).map(l),_.kind==="block"&&(p==null?void 0:p.line)===_.from&&f.jsx(Xb,{pick:p,chatBusy:d,onAdd:N=>{g(p,N),b(null)},onAsk:N=>{y(p,N),b(null)},onClose:()=>b(null)})]},x))]})}function RT({patch:s,comments:i,renderComment:r,readOnly:l,chatBusy:u,onAddLineComment:d,onAskAboutLine:g}){const y=ie.useMemo(()=>s.trim()?Ja(s):[],[s]),m=ie.useMemo(()=>new Map(y.filter(A=>Xf(A.path)).map(A=>[A.path,Qf(A.patch)]).filter(([,A])=>A.lines>0)),[y]),[p,b]=ie.useState({}),v=A=>{var w;return p[A]??((w=m.get(A))==null?void 0:w.isNew)??!1},_=[];for(const A of y){const w=_[_.length-1];if(m.has(A.path)&&v(A.path)){_.push({kind:"read",paths:[A.path],patch:A.patch});continue}(w==null?void 0:w.kind)==="diff"?(w.paths.push(A.path),w.patch+=`
240
240
  ${A.patch}`):_.push({kind:"diff",paths:[A.path],patch:A.patch})}if(y.length===0)return f.jsx("p",{className:"muted",children:"No diff for this chapter."});const x=new Set(y.map(A=>A.path)),N=A=>i.filter(w=>A(w.path));return f.jsxs(f.Fragment,{children:[_.map(A=>{const w=new Set(A.paths);if(A.kind==="read"){const U=A.paths[0];return f.jsx(AT,{path:U,doc:m.get(U),comments:N(G=>G===U),renderComment:r,readOnly:l,chatBusy:u,onAddLineComment:d,onAskAboutLine:g,onShowDiff:()=>b(G=>({...G,[U]:!1}))},`read:${U}`)}return f.jsx(xT,{patch:A.patch,comments:N(U=>w.has(U)),renderComment:r,readOnly:l,chatBusy:u,onAddLineComment:d,onAskAboutLine:g,onRead:U=>b(G=>({...G,[U]:!0}))},`diff:${A.paths[0]}`)}),N(A=>!x.has(A)).map(r)]})}function Qb({comment:s,onUpdate:i,onDelete:r,onDiscuss:l,readOnly:u,flash:d}){const[g,y]=ie.useState(!1),[m,p]=ie.useState(s.body),b=s.status==="approved",v=s.status==="dropped",_=s.origin==="user"?"yours":v?"dropped":b?"kept":"drafted";return f.jsxs("div",{id:`c-${s.id}`,className:`comment comment-${s.status}${d?" comment-flash":""}`,children:[f.jsxs("div",{className:"comment-head",children:[f.jsxs("span",{className:"comment-loc",children:[s.path,s.line!=null?`:${s.line}`:""]}),f.jsx("span",{className:`tag tag-${s.origin==="user"?"yours":_}`,children:_}),s.drifted?f.jsx("span",{className:"tag tag-drift",title:"The code this comment pointed at is gone from the diff, so it can't post inline.",children:"drifted — posts in the body"}):s.line==null&&f.jsx("span",{className:"tag tag-drift",title:"A comment with no line — either about the file as a whole, or on a line the PR removed, which GitHub can't take inline.",children:"on the file — posts in the body"}),!s.drifted&&s.originalLine!=null&&s.originalLine!==s.line&&f.jsxs("span",{className:"faint",title:"This comment followed its code to a new line.",children:["was :",s.originalLine]}),f.jsx("span",{className:"grow"}),!u&&(g?f.jsxs(f.Fragment,{children:[f.jsx("button",{className:"btn btn-sm",onClick:()=>{i({body:m}),y(!1)},children:"save"}),f.jsx("button",{className:"btn btn-sm",onClick:()=>{p(s.body),y(!1)},children:"cancel"})]}):f.jsxs(f.Fragment,{children:[f.jsxs("button",{className:`btn btn-sm${b?" btn-keep-on":""}`,title:"Keep this comment — it posts with the review",onClick:()=>i({status:b?"draft":"approved"}),children:[f.jsx(Ze,{name:"check"}),"keep"]}),f.jsxs("button",{className:"btn btn-sm",title:"Edit the wording",onClick:()=>y(!0),children:[f.jsx(Ze,{name:"edit"}),"edit"]}),f.jsxs("button",{className:`btn btn-sm${v?" btn-drop-on":""}`,title:"Drop it — stays local, never posts",onClick:()=>i({status:v?"draft":"dropped"}),children:[f.jsx(Ze,{name:"drop"}),v?"dropped":"drop"]}),l&&f.jsxs("button",{className:"btn btn-sm btn-accent",title:"Point the chat at this comment",onClick:l,children:[f.jsx(Ze,{name:"comment"}),"discuss"]}),s.origin==="user"&&f.jsx("button",{className:"btn btn-sm",title:"Delete the comment you wrote",onClick:r,children:"✕"})]}))]}),g?f.jsxs(f.Fragment,{children:[f.jsx("textarea",{className:"comment-edit",value:m,onChange:x=>p(x.target.value),rows:Math.max(3,m.split(`
241
- `).length)}),f.jsx(Gs,{className:"comment-body",text:Pm(m,s.severity??null)})]}):f.jsx(Wi,{className:"comment-body prose",text:Pm(s.body,s.severity??null)})]})}function OT({files:s,chapterId:i,onAdd:r}){const[l,u]=ie.useState(!1),[d,g]=ie.useState(s[0]??""),[y,m]=ie.useState(""),[p,b]=ie.useState("");return l?f.jsxs("div",{className:"add-comment",children:[f.jsxs("div",{className:"add-comment-row",children:[f.jsx("select",{value:d,onChange:v=>g(v.target.value),children:s.map(v=>f.jsx("option",{value:v,children:v},v))}),f.jsx("input",{placeholder:"line (optional)",value:y,onChange:v=>m(v.target.value.replace(/\D/g,"")),size:12})]}),f.jsx("textarea",{placeholder:"Your comment…",value:p,onChange:v=>b(v.target.value),rows:3}),f.jsx(Gs,{className:"comment-body",text:p}),f.jsxs("div",{className:"add-comment-row",children:[f.jsx("button",{className:"btn",disabled:!p.trim()||!d,onClick:()=>{r({path:d,line:y?Number(y):null,body:p.trim(),chapterId:i}),b(""),m(""),u(!1)},children:"add"}),f.jsx("button",{className:"btn",onClick:()=>u(!1),children:"cancel"})]})]}):f.jsxs("button",{className:"btn btn-dashed",onClick:()=>u(!0),children:[f.jsx(Ze,{name:"plus"}),"add a comment of your own"]})}const kT=2e3;function MT({chapter:s,n:i,diff:r,comments:l,open:u,heavy:d,onToggle:g,onUpdateComment:y,onDeleteComment:m,onAddComment:p,onDiscuss:b,onAskAboutLine:v,chatBusy:_,readOnly:x,anchorRef:N,flash:A}){const w=ie.useMemo(()=>QE(r,s.files),[r,s.files]),U=ie.useMemo(()=>Kb(w,l),[w,l]),G=ie.useMemo(()=>new Set(Ja(w).map(P=>P.path)),[w]),L=l.filter(P=>!U.includes(P)&&!G.has(P.path)),re=l.filter(P=>U.includes(P)||G.has(P.path)),H=P=>f.jsx(Qb,{comment:P,onUpdate:le=>y(P.id,le),onDelete:()=>m(P.id),onDiscuss:b&&(()=>b({target:"comment",id:P.id})),readOnly:x,flash:A===P.id},P.id);return f.jsxs("section",{className:"chapter",ref:N,children:[f.jsxs("header",{className:"chapter-head",onClick:g,children:[f.jsx("span",{className:"faint",children:u?"▾":"▸"}),f.jsxs("h3",{children:[i," · ",s.title]}),f.jsxs("span",{className:"faint chapter-meta",children:[s.files.length," file",s.files.length===1?"":"s",l.length>0?` · ${l.length} comment${l.length===1?"":"s"}`:" · no comments",d!=null&&!u&&f.jsx("span",{title:"Drawing this many lines at once would leave the page too slow to scroll. Open it if you want it — nothing else on the page is affected.",children:` · ${d.toLocaleString()} lines to draw, folded to keep the page quick`})]}),f.jsx("span",{className:"grow"}),b&&f.jsxs("button",{className:"btn btn-sm",onClick:P=>{P.stopPropagation(),b({target:"chapter",id:s.id})},children:[f.jsx(Ze,{name:"comment"}),"discuss"]})]}),u&&f.jsxs("div",{className:"chapter-body",children:[f.jsx(Wi,{className:"prose chapter-explanation",text:s.explanation}),L.map(H),f.jsx(RT,{patch:w,comments:re,renderComment:H,readOnly:x,chatBusy:_,onAddLineComment:(P,le)=>p({path:P.path,line:P.side==="new"?P.line:null,body:le,chapterId:s.id}),onAskAboutLine:v}),!x&&s.files.length>0&&f.jsx(OT,{files:s.files,chapterId:s.id,onAdd:p})]})]})}function CT(s,i){switch(s.kind){case"summary":return"rewrote the summary";case"verdict":return`changed the verdict to ${s.verdict.recommendation.replace("_"," ")}`;case"chapter":{const r=i.chapters.find(l=>l.id===s.chapterId);return`rewrote the "${(r==null?void 0:r.title)??s.chapterId}" chapter`}case"comment-edit":{const r=i.comments.find(l=>l.id===s.commentId);return r?`rewrote the comment on ${r.path}:${r.line??"file"}`:"rewrote a comment"}case"comment-drop":{const r=i.comments.find(l=>l.id===s.commentId);return r?`dropped the comment on ${r.path}:${r.line??"file"}`:"dropped a comment"}case"comment-add":return`added a comment on ${s.path}:${s.line??"file"}`}}function Of(s,i){var l;if(s.target==="summary")return"the summary";if(s.target==="verdict")return"the verdict";if(s.target==="chapter")return((l=i.chapters.find(u=>u.id===s.id))==null?void 0:l.title)??"a chapter";if(s.target==="line")return s.path==null||s.line==null?"a line of the diff":`${s.path}:${s.line}${s.side==="old"?" (removed)":""}`;const r=i.comments.find(u=>u.id===s.id);return r?`${r.path}:${r.line??"file"}`:"a comment"}function DT({turn:s,artifact:i}){return f.jsxs("div",{className:`chat-turn chat-turn-${s.role}`,children:[f.jsx("div",{className:"chat-turn-who",children:s.role==="user"?"You":"Reviewer"}),s.refs.length>0&&f.jsx("div",{className:"chat-refs",children:s.refs.map((r,l)=>f.jsx("span",{className:"chat-ref",children:Of(r,i)},l))}),f.jsx(Wi,{className:"chat-turn-body prose",text:s.body}),s.revisions.map((r,l)=>f.jsxs("div",{className:"chat-revision",children:["↳ ",CT(r,i)]},l)),s.refused.map((r,l)=>f.jsxs("div",{className:"chat-refusal",title:r.reason,children:["⃠ left alone — ",r.reason]},l))]})}function LT({artifact:s,reviewKey:i,refs:r,onClearRefs:l,onDropRef:u,onArtifact:d,readOnly:g,inputRef:y,anchorRef:m}){const[p,b]=ie.useState(""),[v,_]=ie.useState(!1),[x,N]=ie.useState(null),A=s.chat??[],w=s.pendingChat??null,U=w!=null&&w.error==null,G=v||U,L=(H,P,le)=>{!H||G||(_(!0),N(null),Sf(i,{message:H,refs:P}).then(k=>{d(k),le==null||le()}).catch(k=>N(String(k.message??k))).finally(()=>_(!1)))},re=()=>L(p.trim(),r,()=>{b(""),l()});return f.jsxs("section",{className:"card chat-panel",ref:m,children:[f.jsxs("header",{className:"card-head",children:[f.jsx("h2",{children:"talk to the reviewer"}),f.jsx("span",{className:"faint",children:"it revises the draft as you go · nothing here reaches GitHub — only send does"})]}),f.jsxs("div",{className:"card-body",children:[(A.length>0||w)&&f.jsxs("div",{className:"chat-log",children:[A.map(H=>f.jsx(DT,{turn:H,artifact:s},H.id)),w&&f.jsxs("div",{className:"chat-turn chat-turn-user chat-turn-pending",children:[f.jsx("div",{className:"chat-turn-who",children:"You"}),w.refs.length>0&&f.jsx("div",{className:"chat-refs",children:w.refs.map((H,P)=>f.jsx("span",{className:"chat-ref",children:Of(H,s)},P))}),f.jsx(Wi,{className:"chat-turn-body prose",text:w.message}),w.error?f.jsxs("div",{className:"chat-failed",children:["✕ this turn didn't finish — ",w.error,!g&&f.jsxs(f.Fragment,{children:[" ",f.jsx("button",{className:"btn btn-sm",onClick:()=>L(w.message,w.refs),children:"ask again"})," ",f.jsx("button",{className:"btn btn-sm",onClick:()=>k_(i).then(d).catch(H=>N(String(H.message??H))),children:"dismiss"})]})]}):f.jsx("div",{className:"chat-waiting",children:w.progress.length>0?f.jsx("ol",{className:"chat-progress",children:w.progress.map((H,P)=>f.jsx("li",{className:P===w.progress.length-1?"now":void 0,children:H},P))}):"Thinking… it re-reads the code before answering."})]})]}),g?f.jsx("p",{className:"faint",children:"This review was sent — the conversation is kept as a record, but it can't continue."}):f.jsxs(f.Fragment,{children:[r.length>0&&f.jsxs("div",{className:"chat-refs chat-refs-pending",children:[f.jsx("span",{className:"faint",children:"about:"}),r.map((H,P)=>f.jsxs("button",{className:"chat-ref",onClick:()=>u(P),title:"Remove",children:[Of(H,s)," ✕"]},P))]}),f.jsx("textarea",{ref:y,className:"chat-input",rows:2,value:p,disabled:v,placeholder:A.length===0?"the summary restates the author's claims — say what you actually verified":"say more…",onChange:H=>b(H.target.value),onKeyDown:H=>{H.key==="Enter"&&(H.metaKey||H.ctrlKey)&&re()}}),f.jsx(Gs,{className:"chat-turn-body",text:p}),f.jsxs("div",{className:"chat-actions",children:[f.jsxs("button",{className:"btn btn-dark",onClick:re,disabled:G||p.trim()==="",children:[f.jsx(Ze,{name:"send"}),U?"waiting for the answer…":v?"sending…":"send message",f.jsx(Va,{children:"⌘↵"})]}),f.jsx("span",{className:"grow"}),s.preChat&&f.jsx("button",{className:"btn btn-sm",disabled:G,title:"Put the review back the way the AI first wrote it. The conversation stays.",onClick:()=>{_(!0),M_(i).then(d).catch(H=>N(String(H.message??H))).finally(()=>_(!1))},children:"reset the review"})]})]}),x&&f.jsx("div",{className:"error",children:x})]})]})}function zT(s){const i=ie.useRef(null),r=ie.useCallback(()=>{const l=i.current;l&&(l.style.height="auto",l.style.height=`${Math.min(l.scrollHeight+2,window.innerHeight*.7)}px`)},[]);return ie.useEffect(r,[s,r]),ie.useEffect(()=>(window.addEventListener("resize",r),()=>window.removeEventListener("resize",r)),[r]),i}function UT({initial:s,onSave:i,onCancel:r}){const[l,u]=ie.useState(s),[d,g]=ie.useState(!1),[y,m]=ie.useState(null),p=zT(l),b=()=>{d||(g(!0),m(null),i(l).catch(v=>{m(String((v==null?void 0:v.message)??v)),g(!1)}))};return f.jsxs("div",{className:"body-edit-wrap",children:[f.jsx("textarea",{ref:p,className:"body-edit",autoFocus:!0,value:l,onChange:v=>u(v.target.value),rows:10,onKeyDown:v=>{v.key==="Enter"&&(v.metaKey||v.ctrlKey)&&(v.preventDefault(),b()),v.key==="Escape"&&!d&&r()}}),f.jsx(Gs,{className:"body-md",text:l}),y&&f.jsx("p",{className:"error",children:y}),f.jsxs("div",{className:"card-actions",children:[f.jsxs("button",{className:"btn btn-sm",onClick:b,disabled:d,children:[d?"saving…":"save this body",f.jsx(Va,{children:"⌘↵"})]}),f.jsx("button",{className:"btn btn-sm",onClick:r,disabled:d,children:"cancel"}),f.jsx("span",{className:"faint",children:"this is exactly what posts — the review above stays as it reads"})]})]})}function BT({artifact:s,reviewKey:i,event:r,sending:l,onSend:u,error:d,footer:g,next:y,onAdvance:m,onBody:p,anchorRef:b}){const[v,_]=ie.useState(null),[x,N]=ie.useState(null),[A,w]=ie.useState(s.bodyOverride!=null),[U,G]=ie.useState(!1),L=s.bodyOverride!=null;ie.useEffect(()=>{L&&w(!0)},[L]);const re=A&&!U?JSON.stringify([s.bodyOverride,s.summary,s.chapters.map(H=>[H.title,H.explanation]),s.comments.map(H=>[H.path,H.line,H.body,H.severity,H.status,H.drifted])]):"";return ie.useEffect(()=>{if(!A||U)return;let H=!1;return _(null),N(null),C_(i,r).then(P=>{H||(_(P),N(null))}).catch(P=>{H||N(String(P))}),()=>{H=!0}},[i,r,re,A,U]),s.sent?f.jsxs(f.Fragment,{children:[f.jsxs("div",{className:"sent-strip",ref:b,children:[f.jsxs("strong",{children:["✔ sent as ",lb[s.sent.event]]}),f.jsxs("span",{className:"faint",children:[new Date(s.sent.at).toLocaleString()," · ",rb(s),s.sent.auto?" · auto-sent by the daemon":""]}),f.jsx("span",{className:"grow"}),s.sent.url&&f.jsxs("a",{href:s.sent.url,target:"_blank",rel:"noreferrer",children:["view on GitHub ",f.jsx(Ze,{name:"external"})]}),f.jsxs("button",{className:"btn",onClick:m,children:[y?`next review: ${y.pr.repo}#${y.pr.number}`:"back to the queue",f.jsx(Ze,{name:"arrowRight"})]})]}),f.jsx("div",{className:"post-actions",children:g})]}):f.jsxs("div",{className:"send-panel",ref:b,children:[f.jsxs("div",{className:"send-top",children:[f.jsx("span",{className:"lab",children:"send to github"}),f.jsx("span",{className:"grow"}),f.jsx("span",{className:"faint",children:rb(s)}),f.jsxs("button",{className:"link",onClick:()=>w(!A),disabled:U,title:U?"save or cancel the body you are writing first":void 0,children:[f.jsx(Ze,{name:"eye"}),A?"hide what gets posted":"see what gets posted"]})]}),f.jsx("div",{className:"send-action",children:f.jsxs("button",{className:`send-btn tone-bg-${yT[r]}`,disabled:l,onClick:u,children:[f.jsx(Ze,{name:r==="APPROVE"?"approve":r==="COMMENT"?"comment":"changes",size:15}),l?"sending…":`${lb[r]} on ${s.id}`," ",f.jsx(Va,{children:"s"})]})}),d&&f.jsx("p",{className:"error",children:d}),A&&f.jsxs(f.Fragment,{children:[x&&f.jsx("p",{className:"error",children:x}),v?U?f.jsx(UT,{initial:v.body,onSave:H=>p(H).then(()=>G(!1)),onCancel:()=>G(!1)}):f.jsxs(f.Fragment,{children:[f.jsx("pre",{className:"body-preview",children:v.body}),f.jsxs("div",{className:"body-source",children:[L?f.jsxs(f.Fragment,{children:[f.jsx("span",{className:"faint",children:"you wrote this body — it no longer follows the summary or the comments"}),f.jsx("button",{className:"link",onClick:()=>p(null).catch(H=>N(String((H==null?void 0:H.message)??H))),children:"build it from the review again"})]}):f.jsx("span",{className:"faint",children:"built from the summary, the walkthrough and the folded comments"}),f.jsxs("button",{className:"link",onClick:()=>G(!0),children:[f.jsx(Ze,{name:"edit"}),L?"keep editing it":"write it yourself"]})]})]}):!x&&f.jsx("p",{className:"faint",children:"building the body…"})]}),f.jsx("div",{className:"send-footer",children:g})]})}function jT({filed:s}){var l,u,d;const i=((l=s.review)==null?void 0:l.url)??((u=s.reply)==null?void 0:u.url)??null,r=g=>new Date(g).toLocaleDateString();return s.reason==="own-reply"&&s.reply?f.jsxs("div",{children:[f.jsxs("strong",{children:["You answered on this PR on ",r(s.reply.at),", and nobody has replied since."]})," ","So cerber filed this draft under settled: GitHub does not count a"," ",i?f.jsx("a",{href:i,target:"_blank",rel:"noreferrer",children:"comment"}):"comment"," ","as a review, but the PR is with its author either way. Nothing here was changed or sent — the draft is still yours to send."]}):s.reason==="request-withdrawn"?f.jsxs("div",{children:[f.jsx("strong",{children:"Nobody is asking for this review any more."})," The request that put this PR in the inbox was taken back on or before ",r(s.at),", and you had said nothing on the PR, so cerber filed the draft under settled rather than leaving it out for you. Nothing here was changed or sent — the draft is still yours to send."]}):f.jsxs("div",{children:[f.jsxs("strong",{children:["You reviewed this PR on GitHub on ",r(((d=s.review)==null?void 0:d.at)??s.at),"."]})," ","So cerber filed this draft under settled, GitHub having stopped asking you for a review and shown one of your own"," ",i?f.jsx("a",{href:i,target:"_blank",rel:"noreferrer",children:"already on the PR"}):"already on the PR",". Nothing here was changed or sent — the draft is still yours to send."]})}function IT({entries:s,open:i,onToggle:r,anchorRef:l}){const u=d=>{const g=new Date(d);return Number.isNaN(g.getTime())?d:`${g.toLocaleDateString(void 0,{month:"short",day:"2-digit"})} ${g.toLocaleTimeString(void 0,{hour:"2-digit",minute:"2-digit"})}`};return f.jsxs("section",{className:"card",ref:l,children:[f.jsxs("header",{className:"card-head",children:[f.jsx("h2",{children:"history"}),f.jsx("span",{className:"faint",children:s.length>0?`${s.length} entr${s.length===1?"y":"ies"} — what cerber did to this row, and when`:"nothing recorded"}),f.jsx("span",{className:"grow"}),f.jsxs("button",{className:"btn btn-sm","aria-expanded":i,onClick:r,children:[f.jsx(Ze,{name:i?"up":"down",size:12}),i?"hide":"show"]})]}),i&&f.jsx("div",{className:"card-body",children:s.length===0?f.jsx("div",{className:"faint",children:"This review predates cerber keeping a history — it starts at the next thing that happens to it."}):f.jsx("ol",{className:"history",children:s.map((d,g)=>({e:d,i:g})).reverse().map(({e:d,i:g})=>f.jsxs("li",{className:"history-row",title:d.at,children:[f.jsx("span",{className:"history-at",children:u(d.at)}),f.jsx("span",{className:"history-by",children:d.by}),f.jsx("span",{className:"history-what",children:d.what}),d.cause&&f.jsx("span",{className:"history-cause",children:d.cause})]},`${g}-${d.at}`))})})]})}function HT({artifact:s,freshness:i,rerunning:r,onRerun:l}){const u=s.status==="running",d=(i==null?void 0:i.prState)??s.pr.state,g=d==="CLOSED"||d==="MERGED",y=s.refresh,m=i!=null&&i.changed?y:null;if(u)return f.jsxs("div",{className:"freshness freshness-running",children:[f.jsx("strong",{children:"Re-reviewing…"})," Claude is reading the current head. This page updates itself when the run lands — it takes a few minutes."]});const p=s.filed;return!g&&!m&&!p?null:f.jsxs("div",{className:"freshness",children:[p&&f.jsx(jT,{filed:p}),g&&f.jsxs("div",{children:[f.jsxs("strong",{children:["This PR is ",d==="MERGED"?"merged":"closed","."]})," A review can still be sent, but nobody is waiting for it."]}),m&&f.jsxs("div",{children:[f.jsx("strong",{children:"The PR moved on since this review."})," Now at"," ",f.jsx("code",{children:m.toSha.slice(0,7)}),", reviewed at"," ",f.jsx("code",{children:m.fromSha.slice(0,7)}),"."," ",m.moved>0&&f.jsxs(f.Fragment,{children:[m.moved," comment(s) followed the code. "]}),m.drifted>0&&f.jsxs(f.Fragment,{children:[m.drifted," could not — the code they point at is gone, so they will post in the review body instead of inline."," "]}),"The summary and verdict still describe the commit that was reviewed.",!s.sent&&f.jsx("button",{className:"btn btn-sm",disabled:r,onClick:()=>l(),children:r?"starting…":"re-review at the new head"})]})]})}function qT({reviewKey:s}){var Qt,Ct,R,J;const[i,r]=ie.useState(null),[l,u]=ie.useState(null),[d,g]=ie.useState(null),[y,m]=ie.useState(null),[p,b]=ie.useState(!1),[v,_]=ie.useState([]),[x,N]=ie.useState(new Set),A=C=>N(ee=>new Set(ee).add(C)),[w,U]=ie.useState([]),G=ie.useRef(null),[L,re]=ie.useState(new Set),[H,P]=ie.useState(0),le=ie.useRef(new Map),k=ie.useRef(null),z=ie.useRef(null),Q=ie.useRef(null),K=ie.useRef(null),fe=ie.useRef(null),we=ie.useRef(null),[Ae,he]=ie.useState(!1),I=ie.useRef(null),[ne,oe]=ie.useState(!1),[ve,Se]=ie.useState(null),[S,D]=ie.useState(null);ie.useEffect(()=>{if(!S)return;const C=setTimeout(()=>D(null),1800);return()=>clearTimeout(C)},[S]);const q=C=>{var ee,de;U(Le=>Le.some(ye=>ye.target===C.target&&ye.id===C.id)?Le:[...Le,C]),(ee=G.current)==null||ee.scrollIntoView({behavior:"smooth",block:"center"}),(de=G.current)==null||de.focus()};ie.useEffect(()=>{let C=!1;return window.scrollTo({top:0}),r(null),u(null),g(null),m(null),Se(null),Qm(s).then(ee=>{if(!C)return r(ee),O_(s).then(de=>{C||(g(de),de.changed&&r(de.artifact))}).catch(de=>!C&&m(String(de.message??de)))}).catch(ee=>!C&&u(String(ee))),()=>{C=!0}},[s]),ie.useEffect(()=>{If().then(_).catch(()=>{})},[]),ie.useEffect(()=>{const C=I.current;if(!C)return;const ee=()=>document.documentElement.style.setProperty("--top-h",`${C.offsetHeight}px`);ee();const de=new ResizeObserver(ee);return de.observe(C),()=>{de.disconnect(),document.documentElement.style.removeProperty("--top-h")}},[i==null?void 0:i.id]);const ue=(i==null?void 0:i.pendingChat)!=null&&i.pendingChat.error==null;ie.useEffect(()=>{if((i==null?void 0:i.status)!=="running"&&!ue)return;const C=setInterval(()=>{Qm(s).then(ee=>{r(ee),ee.status!=="running"&&b(!1)}).catch(()=>{})},3e3);return()=>clearInterval(C)},[i==null?void 0:i.status,ue,s]);const pe=(i==null?void 0:i.sent)!=null,xe=ie.useMemo(()=>a_(v,s,x),[v,s,x]),me=xe.findIndex(C=>C.key===s),Oe=(me>0?xe[me-1]:null)??null,Ee=(me>=0&&me<xe.length-1?xe[me+1]:null)??null,He=C=>{window.location.hash=`#/r/${encodeURIComponent(C)}`},We=()=>{Ee?He(Ee.key):window.location.hash="#/"},ke=ie.useMemo(()=>{if(!i)return[];const C=e_(i.diff,i.chapters);return C.length>0?[...i.chapters,{id:"__other",title:"other changes",explanation:"Files not assigned to any chapter by the AI.",files:C}]:i.chapters},[i]),At=ie.useMemo(()=>{const C=t_((i==null?void 0:i.diff)??""),ee=new Set(Ja((i==null?void 0:i.diff)??"").filter(Le=>Xf(Le.path)&&Qf(Le.patch).isNew).map(Le=>Le.path)),de=Le=>{const ye=C.get(Le)??0;return ee.has(Le)?Math.round(ye/4):ye};return new Map(ke.map(Le=>[Le.id,Le.files.reduce((ye,be)=>ye+de(be),0)]))},[i==null?void 0:i.diff,ke]),Ge=C=>{const ee=At.get(C)??0;return ee>kT?ee:null},Nn=C=>L.has(C)?Ge(C)!=null:Ge(C)==null,nt=C=>re(ee=>{const de=new Set(ee);return de.has(C)?de.delete(C):de.add(C),de});ie.useEffect(()=>re(new Set),[s]);const xn=C=>{const ee=ke[C];return ee?(P(C),Nn(ee.id)||nt(ee.id),ee):null},rn=(...C)=>{for(const ee of C)if(ee.current)return ee.current.scrollIntoView({behavior:"smooth",block:"start"})},zt=C=>{var de;const ee=xn(C);ee&&((de=le.current.get(ee.id))==null||de.scrollIntoView({behavior:"smooth",block:"start"}))},qn=(C,ee)=>{xn(C),D(ee),Zb(ee)},Jt=vT(i==null?void 0:i.verdict);ie.useEffect(()=>Se(null),[Jt]);const ca=()=>{!i||i.sent||ne||(oe(!0),Se(null),D_(s,Jt).then(C=>{r(C),A(s)}).catch(C=>Se(String(C.message??C))).finally(()=>oe(!1)))};if(ie.useEffect(()=>{const C=ee=>{NT(ee.target)||ee.metaKey||ee.ctrlKey||ee.altKey||(ee.key==="["&&Oe?He(Oe.key):ee.key==="]"&&Ee?He(Ee.key):ee.key==="n"?zt(Math.min(ke.length-1,H+1)):ee.key==="N"?zt(Math.max(0,H-1)):ee.key==="s"&&!pe&&ca())};return window.addEventListener("keydown",C),()=>window.removeEventListener("keydown",C)},[Oe==null?void 0:Oe.key,Ee==null?void 0:Ee.key,ke,H,pe,Jt,i==null?void 0:i.sent,ne]),l&&!i)return f.jsx("p",{className:"error pad",children:l});if(!i)return f.jsx("p",{className:"muted pad",children:"Loading…"});const Vn=C=>{b(!0),u(null),kb(s,C).then(r).catch(ee=>{u(String(ee)),b(!1)})},Ke=C=>C.then(r).catch(ee=>u(String(ee))),Ut=C=>no(s,{status:C}).then(()=>{A(s),We()}).catch(ee=>u(String(ee))),mt=(C,ee)=>Ke(x_(s,C,ee)),bn=C=>Ke(A_(s,C)),yn=C=>Ke(w_(s,C)),kn=i.comments.filter(C=>!C.chapterId||!ke.some(ee=>ee.id===C.chapterId)),{inline:Mn,folded:Cn,dropped:Dn}=Pb(i),on=i.comments.filter(C=>C.status!=="dropped"&&C.drifted).length,St=i.verdict?ob[i.verdict.recommendation]:"none",Wt=_T(i),Nt=ET(i),Gn=i.pendingChat!=null&&i.pendingChat.error==null,Ln=()=>Ke(Sf(s,{message:"Some findings were dropped, edited or re-graded after this review was drafted. Update the summary and verdict to match what still stands — do not re-review the whole PR.",refs:[{target:"verdict",id:null}]})),wn=(C,ee)=>{Ke(Sf(s,{message:ee,refs:[{target:"line",id:null,path:C.path,line:C.line,side:C.side}]}).then(de=>{var Le;return(Le=K.current)==null||Le.scrollIntoView({behavior:"smooth",block:"start"}),de}))},je=(Qt=i.run)!=null&&Qt.trusted?"ran the code":(Ct=i.run)!=null&&Ct.withSource?"read the full source":i.run?"read the diff only":"no run yet",cn=C=>{var ee;return f.jsxs("button",{className:`verdict-pick${((ee=i.verdict)==null?void 0:ee.recommendation)===C?` verdict-pick-on tone-${ob[C]}`:""}`,title:C==="approve"?"Approve":C==="comment"?"Comment without blocking":"Request changes",onClick:()=>Ke(no(s,{verdictRecommendation:C})),children:[f.jsx(Ze,{name:ST[C]}),C.replace("_"," ")]},C)};return f.jsxs("div",{className:"review",children:[f.jsxs("div",{className:"review-top",ref:I,children:[f.jsx("div",{className:"crumb",children:f.jsxs("div",{className:"bar-inner",children:[f.jsx("a",{href:"#/",children:"← queue"}),f.jsx("span",{className:"crumb-sep",children:"/"}),f.jsx("span",{className:"crumb-slug",children:i.id}),f.jsxs("span",{className:"walk",children:[f.jsx("button",{className:"walk-btn",disabled:!Oe,title:Oe?`[ — ${Oe.id}: ${Oe.pr.title}`:"no more reviews this way",onClick:()=>Oe&&He(Oe.key),children:f.jsx(Ze,{name:"chevronLeft"})}),f.jsx("button",{className:"walk-btn",disabled:!Ee,title:Ee?`] — ${Ee.id}: ${Ee.pr.title}`:"no more reviews this way",onClick:()=>Ee&&He(Ee.key),children:f.jsx(Ze,{name:"chevronRight"})})]}),me>=0&&f.jsxs("span",{className:"faint",children:[me+1," of ",xe.length," awaiting"]}),f.jsx("span",{className:"faint crumb-next",children:Ee?`next: ${Ee.pr.title}`:me>=0?"last in the queue":""}),f.jsx("span",{className:"grow"}),f.jsx("a",{href:"#/settings",className:"topbar-link",title:"settings","aria-label":"settings",children:f.jsx(Ze,{name:"settings",size:15})})]})}),f.jsx("div",{className:"review-head",children:f.jsxs("div",{className:"wrap",children:[f.jsxs("div",{className:"review-head-top",children:[f.jsx("a",{className:"review-title",href:i.pr.url,target:"_blank",rel:"noreferrer",children:i.pr.title}),f.jsx("span",{className:"grow"}),i.verdict&&(pe?f.jsxs("span",{className:`chip tone-${St}`,children:[i.verdict.recommendation.replace("_"," "),Nt&&` · ${Nt}`," · ",i.verdict.confidence,"%"]}):f.jsxs("button",{className:`chip chip-btn tone-${St}`,title:"change it, or send — both are at the foot of the page",onClick:()=>rn(k,fe),children:[i.verdict.recommendation.replace("_"," "),Nt&&` · ${Nt}`," · ",i.verdict.confidence,"%",f.jsx(Ze,{name:"down",size:12})]})),!pe&&i.status!=="running"&&f.jsxs("button",{className:"btn btn-sm",disabled:p,title:i.run?"Review the current head again — this draft is replaced, your own comments are kept":"Draft a review of this PR",onClick:()=>Vn(!0),children:[f.jsx(Ze,{name:"rerun"}),p?"starting…":i.run?"re-review at head":"review now"]})]}),f.jsxs("div",{className:"review-head-meta",children:[f.jsx("span",{children:i.pr.author}),f.jsx("span",{className:"crumb-sep",children:"·"}),f.jsxs("span",{children:[i.pr.headRefName," → ",i.pr.baseRefName,i.pr.headSha?` · ${i.pr.headSha.slice(0,7)}`:""]}),f.jsx("span",{className:"crumb-sep",children:"·"}),f.jsxs("span",{children:[i.pr.changedFiles,"f ",f.jsxs("span",{className:"add",children:["+",i.pr.additions]})," ",f.jsxs("span",{className:"del",children:["−",i.pr.deletions]})]}),f.jsx("span",{className:"crumb-sep",children:"·"}),f.jsx("span",{title:"Whether the run read a local checkout of the PR head, or the diff alone.",children:je}),((R=i.run)==null?void 0:R.costUsd)!=null&&f.jsxs(f.Fragment,{children:[f.jsx("span",{className:"crumb-sep",children:"·"}),f.jsxs("span",{title:"What this review would cost at API token rates. Riding a Claude subscription, it draws on your usage limits instead.",children:["≈$",i.run.costUsd.toFixed(2)," at API rates"]})]}),f.jsx("span",{className:"crumb-sep",children:"·"}),f.jsx("span",{title:"Where this review has got to: drafted, settled locally, or sent.",children:i.status})]})]})})]}),f.jsxs("div",{className:"wrap review-body",children:[f.jsxs("aside",{className:"rail",children:[f.jsxs("div",{className:"rail-jumps",children:[f.jsx("button",{className:"lab rail-lab-btn",onClick:()=>rn(z),children:"summary"}),i.verdict&&f.jsx("button",{className:"lab rail-lab-btn",onClick:()=>rn(Q),children:"why"}),f.jsx("button",{className:"lab rail-lab-btn",onClick:()=>{var C;rn(K),(C=G.current)==null||C.focus()},children:"chat"}),f.jsx("button",{className:"lab rail-lab-btn",onClick:()=>{he(!0),rn(we)},children:"history"})]}),ke.length>0&&f.jsxs(f.Fragment,{children:[f.jsx("button",{className:"lab rail-lab rail-lab-btn",onClick:()=>zt(0),children:"changes"}),ke.map((C,ee)=>f.jsxs("div",{className:"rail-group",children:[f.jsxs("button",{className:`rail-item${Nn(C.id)?" rail-item-on":""}`,onClick:()=>zt(ee),children:[f.jsxs("span",{className:"grow",children:[ee+1," · ",C.title]}),f.jsxs("span",{className:"rail-count",children:[C.files.length,"f"]})]}),i.comments.filter(de=>de.chapterId===C.id).map(de=>f.jsxs("button",{className:`rail-comment${de.status==="dropped"?" rail-comment-dropped":""}`,title:`${de.path}${de.line!=null?`:${de.line}`:""} — ${de.body}`,onClick:()=>qn(ee,de.id),children:[f.jsx("span",{className:`rail-dot tone-${TT(de,St)}`,children:"●"}),f.jsxs("span",{className:"rail-comment-loc",children:[de.path.split("/").pop(),de.line!=null?`:${de.drifted?"~":""}${de.line}`:""]})]},de.id))]},C.id))]}),f.jsx("button",{className:`lab rail-lab-btn${ke.length>0?" rail-lab":""}`,onClick:()=>rn(k,fe),children:"verdict"}),f.jsxs("div",{className:"rail-facts",children:[f.jsxs("div",{children:[i.comments.length-Dn.length," keeping · ",Dn.length," dropped"]}),f.jsxs("div",{children:[Mn.length," inline · ",Cn.length," folded into body"]}),on>0&&f.jsxs("div",{className:"warn",title:"The code these comments pointed at is gone from the diff.",children:[on," drifted"]})]})]}),f.jsxs("div",{className:"main",children:[f.jsx(HT,{artifact:i,freshness:d,rerunning:p,onRerun:Vn}),y&&f.jsxs("div",{className:"freshness",title:y,children:[f.jsx("strong",{children:"Couldn't check GitHub for newer commits."})," This is the review as it was drafted — if the PR has moved on since, nothing here knows it yet."]}),l&&f.jsx("div",{className:"error",children:l}),((J=i.run)==null?void 0:J.error)&&f.jsxs("div",{className:"error",children:["Run failed: ",i.run.error]}),f.jsx("div",{className:"card card-summary",ref:z,children:f.jsxs("div",{className:"card-body",children:[f.jsx("div",{className:"lab",children:"summary"}),f.jsx(Wi,{className:"prose lead",text:i.summary||"(no summary)"}),!pe&&f.jsxs("button",{className:"btn btn-sm",onClick:()=>q({target:"summary",id:null}),children:[f.jsx(Ze,{name:"comment"}),"discuss the summary"]})]})}),i.verdict&&f.jsx("div",{className:`card card-why tone-border-${St}`,ref:Q,children:f.jsxs("div",{className:"card-body",children:[f.jsx("div",{className:"lab",children:"why"}),f.jsxs("div",{className:"sev-counts",children:[Ef(i)&&f.jsx("span",{title:"Live findings by grade. Only blockers block.",children:Ef(i)}),Ef(i)&&f.jsx("span",{className:"crumb-sep",children:"·"}),f.jsxs("span",{title:"How sure the review is that its own findings and grades are right — not how good an idea merging is. That is the verdict, and it follows from the blockers.",children:[i.verdict.confidence,"% sure of the findings"]})]}),f.jsx(Wi,{className:"prose lead",text:i.verdict.reasoning}),!pe&&f.jsxs("button",{className:"btn btn-sm",onClick:()=>q({target:"verdict",id:null}),children:[f.jsx(Ze,{name:"comment"}),"discuss the verdict"]})]})}),ke.map((C,ee)=>f.jsx(MT,{chapter:C,n:ee+1,diff:i.diff,comments:i.comments.filter(de=>de.chapterId===C.id),open:Nn(C.id),heavy:Ge(C.id),onToggle:()=>nt(C.id),onUpdateComment:mt,onDeleteComment:bn,onAddComment:yn,onDiscuss:pe?void 0:q,onAskAboutLine:wn,chatBusy:Gn,readOnly:pe,flash:S,anchorRef:de=>{de?le.current.set(C.id,de):le.current.delete(C.id)}},C.id)),kn.length>0&&f.jsxs("section",{className:"card",children:[f.jsxs("header",{className:"card-head",children:[f.jsx("h2",{children:"unattached comments"}),f.jsx("span",{className:"faint",children:"not part of any chapter — they post in the body"})]}),f.jsx("div",{className:"card-body",children:kn.map(C=>f.jsx(Qb,{comment:C,onUpdate:ee=>mt(C.id,ee),onDelete:()=>bn(C.id),onDiscuss:pe?void 0:()=>q({target:"comment",id:C.id}),readOnly:pe},C.id))})]}),f.jsx(IT,{entries:i.history??[],open:Ae,onToggle:()=>he(C=>!C),anchorRef:we})]}),f.jsxs("aside",{className:"cockpit",children:[f.jsx(LT,{artifact:i,reviewKey:s,refs:w,onClearRefs:()=>U([]),onDropRef:C=>U(ee=>ee.filter((de,Le)=>Le!==C)),onArtifact:r,readOnly:pe,inputRef:G,anchorRef:K}),f.jsxs("div",{className:"cockpit-decide",children:[!pe&&Wt&&f.jsxs("div",{className:"verdict-hint",children:[f.jsxs("span",{children:[f.jsx("strong",{children:Wt})," — the verdict was written before those edits."]}),f.jsxs("button",{className:"btn btn-sm",onClick:Ln,disabled:Gn,children:[f.jsx(Ze,{name:"comment"}),Gn?"the reviewer is busy…":"ask the reviewer to re-true it"]})]}),i.verdict&&!pe&&f.jsxs("div",{className:"verdict-bar",ref:k,children:[f.jsx("span",{className:"lab",children:"verdict"}),["approve","comment","request_changes"].map(cn)]}),f.jsx(BT,{artifact:i,reviewKey:s,event:Jt,sending:ne,onSend:ca,error:ve,next:Ee,onAdvance:We,onBody:C=>no(s,{bodyOverride:C}).then(r),anchorRef:fe,footer:f.jsxs(f.Fragment,{children:[f.jsx("span",{className:"faint",children:"not sending?"}),!pe&&f.jsxs(f.Fragment,{children:[f.jsxs("button",{className:"btn btn-sm",title:Ee?`Settle it locally and go to ${Ee.id}`:"Settle it locally and go back to the queue",onClick:()=>Ut("reviewed"),children:[f.jsx(Ze,{name:"check"}),"mark reviewed"]}),f.jsxs("button",{className:"btn btn-sm",title:Ee?`Leave it and go to ${Ee.id}`:"Leave it and go back to the queue",onClick:()=>Ut("skipped"),children:[f.jsx(Ze,{name:"skip"}),"skip"]})]}),f.jsxs("a",{className:"btn btn-sm",href:L_(s),children:[f.jsx(Ze,{name:"download"}),"export .md"]})]})})]})]})]})]})}const ub="cerber.notify.seen",kf="cerber.notify",GT=1e4,Vb=s=>{try{return localStorage.getItem(s)}catch{return null}},Mf=(s,i)=>{try{localStorage.setItem(s,i)}catch{}};function $T(s,i){const r=new Set(i);return fo(s).filter(l=>!r.has(l.key))}function YT(s){return s.map(i=>i.key)}const fb=s=>`${s.pr.repo}#${s.pr.number}`;function FT(s){if(s.length===0)return null;const i=`cerber:${s.map(u=>u.key).sort().join("|")}`;if(s.length===1){const u=s[0];return{title:`${fb(u)} awaits your review`,body:`${u.pr.title} — ${u.pr.author}`,tag:i,key:u.key}}const r=s.slice(0,3).map(fb),l=s.length-r.length;return{title:`${s.length} PRs await your review`,body:l>0?`${r.join(", ")} and ${l} more`:r.join(", "),tag:i,key:null}}const KT=new Set(["127.0.0.1","localhost","::1","[::1]"]);function PT(s,i=window.location.hostname){return!(s!=null&&s.enabled)||!s.notify?!1:KT.has(i)}function Hl(){return typeof Notification>"u"?"unsupported":Notification.permission==="denied"?"blocked":Vb(kf)==="off"?"off":Notification.permission==="granted"?"on":"ask"}async function ZT(){const s=Hl();if(s==="unsupported"||s==="blocked")return s;if(s==="on")return Mf(kf,"off"),Hl();if(Mf(kf,"on"),Notification.permission==="default")try{await Notification.requestPermission()}catch{}return Hl()}function XT(s){const i=new Notification(s.title,{body:s.body,tag:s.tag,icon:"/favicon.svg"});i.onclick=()=>{window.focus(),window.location.hash=s.key?`#/r/${encodeURIComponent(s.key)}`:"#/",i.close()}}function QT(){const s=ie.useRef(null),[i,r]=ie.useState(!1);return ie.useEffect(()=>{let l=!0;const u=Vb(ub);if(u)try{const y=JSON.parse(u);Array.isArray(y)&&(s.current=y.filter(m=>typeof m=="string"))}catch{}const d=()=>Promise.all([If(),Ob().catch(()=>null)]).then(([y,m])=>{if(!l)return;const p=PT(m);r(p);const b=s.current;if(s.current=YT(y),Mf(ub,JSON.stringify(s.current)),!b||Hl()!=="on"||p||document.visibilityState==="visible"&&document.hasFocus())return;const v=FT($T(y,b));v&&XT(v)}).catch(()=>{});d();const g=setInterval(d,GT);return()=>{l=!1,clearInterval(g)}},[]),i}function Jb(){const[s,i]=ie.useState(Hl);return ie.useEffect(()=>{const r=()=>i(Hl());return window.addEventListener("focus",r),()=>window.removeEventListener("focus",r)},[]),[s,()=>void ZT().then(i)]}const ao=s=>{window.location.hash=`#/r/${encodeURIComponent(s)}`};function VT(s){const i=s,r=i==null?void 0:i.tagName;return r==="INPUT"||r==="TEXTAREA"||r==="SELECT"||(i==null?void 0:i.isContentEditable)===!0}function JT({daemon:s}){return s!=null&&s.enabled?f.jsx("div",{className:"daemon-bar",children:f.jsxs("div",{className:"bar-inner",children:[s.pollEnabled?f.jsxs("span",{className:"daemon-live",children:[f.jsx("span",{className:"dot"}),s.repos.length>0?s.repos.join(", "):"PRs awaiting you"," · every"," ",Math.round(s.intervalMs/6e4),"m"]}):f.jsxs("span",{className:"daemon-live",children:[f.jsx("span",{className:"dot dot-off"}),"polling off (settings) — local reviews only"]}),f.jsxs("span",{children:["auto-review ",f.jsx("b",{children:s.autoReview?"on":"off"})]}),f.jsxs("span",{children:["auto-send ",f.jsx("b",{children:s.autoSend==="on"?"on":"shadow"})," ≥",s.autoSendThreshold,"%",s.autoSend==="on"?` · ${s.autoSent} sent`:` · ${s.autoSendCandidates} candidate${s.autoSendCandidates===1?"":"s"}`]}),s.trustedRuns&&f.jsxs("span",{className:"warn",title:"Trust rules are set and auto-review is on: reviews of matching PRs run their code unattended. Settings → Trusted PRs, or serve --no-trust.",children:[f.jsx(Ze,{name:"zap",size:12})," trusted PRs run with Bash"]}),f.jsx("span",{className:"grow"}),s.lastPollError?f.jsx("span",{className:"danger",title:s.lastPollError,children:"discovery offline — showing local reviews only"}):f.jsx("span",{className:"faint",children:s.polling?"polling now…":[s.lastSummary?`last: ${s.lastSummary}`:null,s.nextPollAt?`next ${new Date(s.nextPollAt).toLocaleTimeString()}`:null].filter(Boolean).join(" · ")})]})}):null}function WT({r:s,selected:i,onSelect:r,onOpen:l,awaiting:u}){const d=b_(s),g=o_(s),y=u?p_(s,u):null;return f.jsxs("div",{className:`row${i?" row-on":""}`,onClick:l,onMouseEnter:r,role:"button",tabIndex:-1,children:[f.jsx("span",{className:"col-caret",children:"›"}),f.jsxs("span",{className:"col-slug",title:`${s.pr.owner}/${s.pr.repo}#${s.pr.number}`,children:[s.pr.repo,"#",s.pr.number]}),f.jsx("span",{className:"col-author",title:s.pr.author,children:s.pr.author}),f.jsx("span",{className:"col-title",title:s.pr.title,children:s.pr.title}),s.pr.isDraft&&f.jsx("span",{className:"tag tag-draft",title:"Still a draft — its author asked for your review anyway",children:"draft"}),y&&f.jsx("span",{className:"tag tag-awaiting",title:y.title,children:y.label}),g&&f.jsx("span",{className:"tag tag-done",children:g}),f.jsx("span",{className:"col-comments",children:s.commentCount||""}),f.jsx("span",{className:"col-size",children:s.pr.changedFiles>0?f.jsxs(f.Fragment,{children:[s.pr.changedFiles,"f ",f.jsxs("span",{className:"add",children:["+",s.pr.additions]})," ",f.jsxs("span",{className:"del",children:["−",s.pr.deletions]})]}):"—"}),f.jsx("span",{className:`col-verdict tone-${d.tone}`,children:d.label}),f.jsx("span",{className:"col-age",children:Rb(s.updatedAt)})]})}function db({autoFocus:s}){const[i,r]=ie.useState(""),[l,u]=ie.useState(!1),[d,g]=ie.useState(null),y=m=>{m.preventDefault();const p=i.trim();!p||l||(u(!0),g(null),R_(p).then(b=>ao(b.key)).catch(b=>{g(String(b instanceof Error?b.message:b)),u(!1)}))};return f.jsxs("form",{className:"pull",onSubmit:y,children:[f.jsx(Ze,{name:"plus"}),f.jsx("input",{className:"pull-input",value:i,autoFocus:s,disabled:l,onChange:m=>{r(m.target.value),d&&g(null)},placeholder:"paste a PR URL to review it","aria-label":"Review any PR by URL"}),i.trim()&&f.jsx("button",{className:"btn btn-small",type:"submit",disabled:l,children:l?"starting…":"review"}),d&&f.jsx("span",{className:"pull-error",children:d})]})}function eN({text:s}){const i=ie.useRef(null),[r,l]=ie.useState(!1);return ie.useLayoutEffect(()=>{const u=i.current;if(!u)return;const d=()=>l(u.scrollHeight-u.clientHeight>4);d();const g=new ResizeObserver(d);return g.observe(u),()=>g.disconnect()},[s]),f.jsx("div",{ref:i,className:`strip-prose${r?" strip-prose-clipped":""}`,children:f.jsx(Wi,{className:"prose",text:s})})}function tN(){var Q;const[s,i]=ie.useState(null),[r,l]=ie.useState(null),[u,d]=ie.useState(null),[g,y]=ie.useState(new Set),[m,p]=ie.useState("inbox"),[b,v]=ie.useState(null),_=ie.useRef(!0);ie.useEffect(()=>(_.current=!0,()=>{_.current=!1}),[]);const x=()=>{If().then(K=>_.current&&i(K)).catch(K=>_.current&&d(String(K))),Ob().then(K=>_.current&&l(K)).catch(()=>{})};ie.useEffect(()=>{x();const K=setInterval(x,1e4);return()=>clearInterval(K)},[]);const N=s??[],A=ie.useMemo(()=>s_(N,r),[N,r]),w=A.requests,U=r_(m,A),G=A[U];ie.useEffect(()=>{U!==m&&p(U)},[U,m]);const L=G.find(K=>K.key===b)??G[0]??null,re=K=>{y(fe=>new Set(fe).add(K)),kb(K).then(()=>x()).catch(fe=>_.current&&d(String(fe))).finally(()=>{_.current&&y(fe=>{const we=new Set(fe);return we.delete(K),we})})};if(ie.useEffect(()=>{const K=fe=>{if(VT(fe.target)||fe.metaKey||fe.ctrlKey||fe.altKey)return;const we=G.findIndex(he=>he.key===(L==null?void 0:L.key)),Ae=he=>{const I=G[Math.min(G.length-1,Math.max(0,we+he))];I&&v(I.key)};fe.key==="j"||fe.key==="ArrowDown"?(fe.preventDefault(),Ae(1)):fe.key==="k"||fe.key==="ArrowUp"?(fe.preventDefault(),Ae(-1)):fe.key==="Enter"&&L?ao(L.key):fe.key==="r"&&L&&L.status!=="sent"&&L.status!=="running"&&re(L.key)};return window.addEventListener("keydown",K),()=>window.removeEventListener("keydown",K)},[G,L==null?void 0:L.key]),u&&!s)return f.jsx("p",{className:"error",children:u});if(!s)return f.jsx("p",{className:"muted pad",children:"Loading…"});const H=G.reduce((K,fe)=>K+(fe.costUsd??0),0),P=L?v_(L,r):null,le=L?g.has(L.key):!1,k=_f.filter(K=>A[K].length>0),z=K=>f.jsxs("button",{className:`tab${U===K?" tab-on":""}${_f.includes(K)?" tab-filed":""}${K==="requests"?" tab-requests":""}`,title:xu[K].title,onClick:()=>p(K),children:[xu[K].label," ",f.jsx("span",{className:"tab-count",children:A[K].length})]},K);return f.jsxs(f.Fragment,{children:[f.jsx(JT,{daemon:r}),u&&f.jsx("div",{className:"wrap",children:f.jsx("p",{className:"error",children:u})}),N.length===0?f.jsx("div",{className:"wrap",children:f.jsxs("div",{className:"empty",children:[r!=null&&r.enabled?f.jsxs("p",{children:["Inbox empty — nothing awaits your review.",r.lastPollError?" (Discovery is offline; check gh auth.)":""]}):f.jsx("p",{children:"No reviews yet."}),f.jsx(db,{autoFocus:!0})]})}):f.jsxs("div",{className:"wrap",children:[f.jsxs("div",{className:"tabs",children:[n_.map(z),k.length>0&&f.jsx("span",{className:"tab-split","aria-hidden":"true"}),k.map(z),f.jsx("span",{className:"grow"}),f.jsx(db,{}),H>0&&f.jsxs("span",{className:"faint",title:"What these reviews would cost at API token rates. Riding a Claude subscription, they draw on your usage limits instead.",children:["≈$",H.toFixed(2)," at API rates"]})]}),f.jsxs("div",{className:"table-head",children:[f.jsx("span",{className:"col-caret"}),f.jsx("span",{className:"col-slug",children:"repo#pr"}),f.jsx("span",{className:"col-author",children:"author"}),f.jsx("span",{className:"col-title",children:"title"}),f.jsx("span",{className:"col-comments",children:"c"}),f.jsx("span",{className:"col-size",children:"size"}),f.jsx("span",{className:"col-verdict",children:"verdict"}),f.jsx("span",{className:"col-age",children:"upd"})]}),G.length===0?f.jsx("div",{className:"empty-tab",children:U==="inbox"&&w.length>0?f.jsxs(f.Fragment,{children:[f.jsx("p",{children:m_(w)}),f.jsxs("button",{className:"btn",onClick:()=>p("requests"),children:[f.jsx(Ze,{name:"arrowRight"}),"show ",w.length===1?"it":`all ${w.length}`]})]}):f.jsx("p",{className:"muted",children:U==="inbox"?`Inbox empty — nothing awaits your review.${r!=null&&r.enabled&&r.lastPollError?" (Discovery is offline; check gh auth.)":""}`:`Nothing ${xu[U].label} right now.`})}):G.map(K=>f.jsx(WT,{r:K,selected:K.key===(L==null?void 0:L.key),onSelect:()=>v(K.key),onOpen:()=>ao(K.key),awaiting:l_(K,w)?c_(K,r):null},K.key)),L&&P&&f.jsxs("div",{className:"cursor-strip",children:[f.jsxs("div",{className:"cursor-strip-body",children:[f.jsxs("div",{className:"lab",children:[L.pr.owner,"/",L.pr.repo,"#",L.pr.number,L.pr.headRefName&&L.pr.baseRefName?` · ${L.pr.headRefName} → ${L.pr.baseRefName}`:""]}),f.jsx(eN,{text:P.reasoning}),f.jsxs("div",{className:"cursor-strip-meta",children:[P.meta.join(" · "),((Q=L.sent)==null?void 0:Q.url)&&f.jsxs(f.Fragment,{children:[" · ",f.jsx("a",{href:L.sent.url,target:"_blank",rel:"noreferrer",children:"view on GitHub"})]})]})]}),f.jsxs("div",{className:"cursor-strip-actions",children:[f.jsxs("button",{className:"btn-primary",onClick:()=>ao(L.key),children:[f.jsx(Ze,{name:"arrowRight"}),"open review ",f.jsx(Va,{children:"↵"})]}),f.jsxs("div",{className:"cursor-strip-row",children:[f.jsxs("button",{className:"btn",disabled:L.status==="sent"||L.status==="skipped",title:"Set the local queue status to skipped. Nothing is sent.",onClick:()=>no(L.key,{status:"skipped"}).then(x).catch(K=>d(String(K))),children:[f.jsx(Ze,{name:"skip"}),"skip"]}),f.jsxs("button",{className:"btn",disabled:le||L.status==="running"||L.status==="sent",title:L.status==="sent"?"This review was sent — it is a record now.":"Draft a review of the PR's current head.",onClick:()=>re(L.key),children:[f.jsx(Ze,{name:"rerun"}),le?"starting…":L.status==="awaiting"||L.status==="failed"?"review now":"re-review"]})]})]})]})]})]})}const nN=[{rule:"@acme/*",meaning:"anyone in the acme org"},{rule:"@acme/devs",meaning:"anyone on that GitHub team"},{rule:"@teammate",meaning:"one person, by login"},{rule:"!@acme/contractors",meaning:"deny — beats every rule above"}],hb=s=>s instanceof Error?s.message:String(s),aN={unsupported:"this browser has no notifications to give",blocked:"this browser has blocked notifications for cerber",off:"tell me when a PR lands in the queue",ask:"tell me when a PR lands in the queue (the browser will ask first)",on:"tell me when a PR lands in the queue"};function iN({daemonAnnounces:s}){const[i,r]=Jb(),[l,u]=ie.useState(null),[d,g]=ie.useState(""),[y,m]=ie.useState(null),[p,b]=ie.useState(!1);ie.useEffect(()=>{z_().then(u).catch(_=>m(hb(_)))},[]);const v=_=>{b(!0),m(null),_.then(u).catch(x=>m(hb(x))).finally(()=>b(!1))};return y&&!l?f.jsx("p",{className:"error",children:y}):l?f.jsxs("div",{className:"settings",children:[f.jsx("a",{href:"#/",className:"back",children:"← queue"}),f.jsx("h1",{children:"Inbox"}),f.jsx("p",{children:"By default cerber keeps the queue itself: it polls GitHub for PRs awaiting your review and drafts a review for each, so they're ready when you open the cockpit. Nothing is ever sent without you. Changes apply on the next poll — no restart."}),f.jsxs("label",{className:"inbox-toggle",children:[f.jsx("input",{type:"checkbox",checked:l.daemon.poll,disabled:p,onChange:_=>v(Ru({poll:_.target.checked}))})," ","poll GitHub for PRs awaiting my review (every ",l.daemon.intervalMinutes,"m)"]}),f.jsxs("label",{className:"inbox-toggle",children:[f.jsx("input",{type:"checkbox",checked:l.daemon.autoReview,disabled:p||!l.daemon.poll,onChange:_=>v(Ru({autoReview:_.target.checked}))})," ","draft a review automatically (off: awaiting PRs wait for a click)"]}),f.jsxs("p",{className:"muted",children:["Interval, concurrency and a repo filter live in ",f.jsx("code",{children:l.path})," under"," ",f.jsx("code",{children:"daemon"})," — hand-editable, like everything here."]}),f.jsx("h1",{children:"Notifications"}),f.jsx("p",{children:"When a PR lands in the queue, cerber says so: one desktop notification per poll. A single arrival names the PR; several landing at once fold into one notification. A PR that has already been announced never arrives twice."}),f.jsxs("label",{className:"inbox-toggle",children:[f.jsx("input",{type:"checkbox",checked:l.daemon.notify,disabled:p||!l.daemon.poll,onChange:_=>v(Ru({notify:_.target.checked}))})," ","tell this machine when a PR lands (no cockpit tab required)"]}),f.jsxs("p",{className:"muted",children:["This is the one that works while you're somewhere else: it rides the poll that finds the PR, so it needs nothing open but ",f.jsx("code",{children:"serve"})," itself. It hands the notification to whatever this machine already has — Notification Centre on macOS,"," ",f.jsx("code",{children:"notify-send"})," on Linux — and stays quiet where there is neither. It cannot open a review on click; the browser's can."]}),f.jsxs("label",{className:"inbox-toggle",children:[f.jsx("input",{type:"checkbox",checked:i==="on",disabled:i==="unsupported"||i==="blocked"||s,onChange:r})," ",s?"let this browser announce them instead":aN[i]]}),f.jsx("p",{className:"muted",children:s?f.jsxs(f.Fragment,{children:["Standing down while the machine's own notification is on — two popups for one PR is one too many. Untick the machine one above and this browser takes over: it names the PR and ",f.jsx("strong",{children:"opens that review when you click it"}),", but only while a cockpit tab is open and this browser has granted the permission."]}):f.jsxs(f.Fragment,{children:["Per-browser, not per-machine: the permission belongs to this browser, so the switch lives here rather than in ",f.jsx("code",{children:l.path}),". Granting it is the browser's prompt, not cerber's — a browser that has blocked cerber has to be un-blocked from the padlock next to the address bar. Nothing is announced while you're looking straight at the cockpit."]})}),f.jsx("h1",{children:"Trusted PRs"}),f.jsxs("p",{children:["Every review reads the PR's code. A ",f.jsx("strong",{children:"trusted"})," review may also run it — the test that covers the change, a typecheck, ",f.jsx("code",{children:"git log"})," — so it can report what happened instead of guessing. That means executing code from the PR on this machine, the same as checking the branch out and running the tests yourself."]}),f.jsxs("p",{className:"muted",children:["Trust the people, not the code: nothing about a PR can earn it. Rules live in"," ",f.jsx("code",{children:l.path}),"."]}),f.jsx("p",{className:"muted",children:'Only people can be trusted — there is no way to trust a repository. Anyone can open a PR against a repo you own, so trusting the repo would trust them too. Membership is checked against GitHub when the review runs, and a check that fails counts as "not a member".'}),f.jsxs("div",{className:"trust-add",children:[f.jsx("input",{value:d,"aria-label":"Trust rule",placeholder:"@org/*, @org/team, @login, !deny",disabled:p,onChange:_=>g(_.target.value),onKeyDown:_=>{_.key==="Enter"&&!p&&d.trim()&&(v(Au(d.trim())),g(""))}}),f.jsx("button",{className:"btn",disabled:p||!d.trim(),onClick:()=>{v(Au(d.trim())),g("")},children:"trust"})]}),y&&f.jsx("p",{className:"error",children:y}),l.trust.length===0?f.jsx("p",{className:"muted",children:"Nobody is trusted yet — every review reads the checkout and runs nothing."}):f.jsx("table",{className:"trust-table",children:f.jsx("tbody",{children:l.trust.map(_=>f.jsxs("tr",{className:_.denies?"trust-deny":void 0,children:[f.jsx("td",{children:f.jsx("code",{children:_.rule})}),f.jsx("td",{className:"muted",children:_.explanation}),f.jsx("td",{className:"trust-actions",children:f.jsx("button",{className:"btn",disabled:p,onClick:()=>v(Au(_.rule,!0)),children:"remove"})})]},_.rule))})}),f.jsx("h2",{children:"Rule syntax"}),f.jsx("table",{className:"trust-table",children:f.jsx("tbody",{children:nN.map(_=>f.jsxs("tr",{children:[f.jsx("td",{children:f.jsx("code",{children:_.rule})}),f.jsx("td",{className:"muted",children:_.meaning})]},_.rule))})}),f.jsxs("p",{className:"muted",children:["A trusted review is handed no GitHub credentials — no token, no ",f.jsx("code",{children:"gh"})," login, no git or ssh identity — so a push fails to authenticate rather than being merely discouraged. It is not a sandbox though: it has ",f.jsx("code",{children:"Bash"})," and this machine, so trust people you would let run their branch here anyway. The daemon reviews unattended, so with rules set it runs matching PRs' code with nobody watching — start it with ",f.jsx("code",{children:"--no-trust"})," to prevent that."]})]}):f.jsx("p",{className:"muted",children:"Loading…"})}function pb(){const s=window.location.hash,i=s.match(/^#\/r\/(.+)$/);return i?{view:"detail",key:decodeURIComponent(i[1])}:s==="#/settings"?{view:"settings"}:{view:"queue"}}const mb={unsupported:"",blocked:"This browser blocked notifications for cerber. Turn them back on in its site settings — the padlock next to the address bar.",off:"Notifications off. Click to be told when a PR lands in the queue.",ask:"Click to be told when a PR lands in the queue — the browser will ask you first.",on:"Telling you when a PR lands in the queue. Click to stop."},gb="cerber taps this machine itself when a PR lands — no tab required. The browser's own bell would be a second popup about one PR, so it stands down. Turn the machine one off in Settings to have this browser announce arrivals instead.";function lN({daemonAnnounces:s}){const[i,r]=Jb();return s?f.jsx("span",{className:"topbar-link bell bell-daemon",role:"img",title:gb,"aria-label":gb,children:f.jsx(Ze,{name:"bell",size:15})}):i==="unsupported"?null:f.jsx("button",{className:`topbar-link bell bell-${i}`,title:mb[i],"aria-label":mb[i],"aria-pressed":i==="on",onClick:r,children:f.jsx(Ze,{name:i==="on"?"bell":"bellOff",size:15})})}function sN(){const[s,i]=ie.useState(pb()),r=QT();return ie.useEffect(()=>{const l=()=>i(pb());return window.addEventListener("hashchange",l),()=>window.removeEventListener("hashchange",l)},[]),s.view==="detail"?f.jsx("div",{className:"app",children:f.jsx(qT,{reviewKey:s.key})}):f.jsxs("div",{className:"app",children:[f.jsx("header",{className:"topbar",children:f.jsxs("div",{className:"bar-inner",children:[f.jsxs("a",{href:"#/",className:"brand",children:[f.jsx(z0,{}),"cerber"]}),f.jsx("span",{className:"tagline",children:"nothing reaches GitHub until you say so"}),f.jsx("span",{className:"grow"}),s.view==="queue"&&f.jsxs(f.Fragment,{children:[f.jsxs("span",{className:"hint",children:[f.jsx(Va,{children:"j"}),f.jsx(Va,{children:"k"}),"move"]}),f.jsxs("span",{className:"hint",children:[f.jsx(Va,{children:"↵"}),"open"]}),f.jsxs("span",{className:"hint",children:[f.jsx(Va,{children:"r"}),"review now"]})]}),f.jsx(lN,{daemonAnnounces:r}),f.jsx("a",{href:"#/settings",className:"topbar-link",title:"settings","aria-label":"settings",children:f.jsx(Ze,{name:"settings",size:15})})]})}),s.view==="queue"?f.jsx(tN,{}):f.jsx(iN,{daemonAnnounces:r})]})}dE.createRoot(document.getElementById("root")).render(f.jsx(sE.StrictMode,{children:f.jsx(sN,{})}));
241
+ `).length)}),f.jsx(Gs,{className:"comment-body",text:Pm(m,s.severity??null)})]}):f.jsx(Wi,{className:"comment-body prose",text:Pm(s.body,s.severity??null)})]})}function OT({files:s,chapterId:i,onAdd:r}){const[l,u]=ie.useState(!1),[d,g]=ie.useState(s[0]??""),[y,m]=ie.useState(""),[p,b]=ie.useState("");return l?f.jsxs("div",{className:"add-comment",children:[f.jsxs("div",{className:"add-comment-row",children:[f.jsx("select",{value:d,onChange:v=>g(v.target.value),children:s.map(v=>f.jsx("option",{value:v,children:v},v))}),f.jsx("input",{placeholder:"line (optional)",value:y,onChange:v=>m(v.target.value.replace(/\D/g,"")),size:12})]}),f.jsx("textarea",{placeholder:"Your comment…",value:p,onChange:v=>b(v.target.value),rows:3}),f.jsx(Gs,{className:"comment-body",text:p}),f.jsxs("div",{className:"add-comment-row",children:[f.jsx("button",{className:"btn",disabled:!p.trim()||!d,onClick:()=>{r({path:d,line:y?Number(y):null,body:p.trim(),chapterId:i}),b(""),m(""),u(!1)},children:"add"}),f.jsx("button",{className:"btn",onClick:()=>u(!1),children:"cancel"})]})]}):f.jsxs("button",{className:"btn btn-dashed",onClick:()=>u(!0),children:[f.jsx(Ze,{name:"plus"}),"add a comment of your own"]})}const kT=2e3;function MT({chapter:s,n:i,diff:r,comments:l,open:u,heavy:d,onToggle:g,onUpdateComment:y,onDeleteComment:m,onAddComment:p,onDiscuss:b,onAskAboutLine:v,chatBusy:_,readOnly:x,anchorRef:N,flash:A}){const w=ie.useMemo(()=>QE(r,s.files),[r,s.files]),U=ie.useMemo(()=>Kb(w,l),[w,l]),G=ie.useMemo(()=>new Set(Ja(w).map(P=>P.path)),[w]),L=l.filter(P=>!U.includes(P)&&!G.has(P.path)),re=l.filter(P=>U.includes(P)||G.has(P.path)),H=P=>f.jsx(Qb,{comment:P,onUpdate:le=>y(P.id,le),onDelete:()=>m(P.id),onDiscuss:b&&(()=>b({target:"comment",id:P.id})),readOnly:x,flash:A===P.id},P.id);return f.jsxs("section",{className:"chapter",ref:N,children:[f.jsxs("header",{className:"chapter-head",onClick:g,children:[f.jsx("span",{className:"faint",children:u?"▾":"▸"}),f.jsxs("h3",{children:[i," · ",s.title]}),f.jsxs("span",{className:"faint chapter-meta",children:[s.files.length," file",s.files.length===1?"":"s",l.length>0?` · ${l.length} comment${l.length===1?"":"s"}`:" · no comments",d!=null&&!u&&f.jsx("span",{title:"Drawing this many lines at once would leave the page too slow to scroll. Open it if you want it — nothing else on the page is affected.",children:` · ${d.toLocaleString()} lines to draw, folded to keep the page quick`})]}),f.jsx("span",{className:"grow"}),b&&f.jsxs("button",{className:"btn btn-sm",onClick:P=>{P.stopPropagation(),b({target:"chapter",id:s.id})},children:[f.jsx(Ze,{name:"comment"}),"discuss"]})]}),u&&f.jsxs("div",{className:"chapter-body",children:[f.jsx(Wi,{className:"prose chapter-explanation",text:s.explanation}),L.map(H),f.jsx(RT,{patch:w,comments:re,renderComment:H,readOnly:x,chatBusy:_,onAddLineComment:(P,le)=>p({path:P.path,line:P.side==="new"?P.line:null,body:le,chapterId:s.id}),onAskAboutLine:v}),!x&&s.files.length>0&&f.jsx(OT,{files:s.files,chapterId:s.id,onAdd:p})]})]})}function CT(s,i){switch(s.kind){case"summary":return"rewrote the summary";case"verdict":return`changed the verdict to ${s.verdict.recommendation.replace("_"," ")}`;case"chapter":{const r=i.chapters.find(l=>l.id===s.chapterId);return`rewrote the "${(r==null?void 0:r.title)??s.chapterId}" chapter`}case"comment-edit":{const r=i.comments.find(l=>l.id===s.commentId);return r?`rewrote the comment on ${r.path}:${r.line??"file"}`:"rewrote a comment"}case"comment-drop":{const r=i.comments.find(l=>l.id===s.commentId);return r?`dropped the comment on ${r.path}:${r.line??"file"}`:"dropped a comment"}case"comment-add":return`added a comment on ${s.path}:${s.line??"file"}`}}function Of(s,i){var l;if(s.target==="summary")return"the summary";if(s.target==="verdict")return"the verdict";if(s.target==="chapter")return((l=i.chapters.find(u=>u.id===s.id))==null?void 0:l.title)??"a chapter";if(s.target==="line")return s.path==null||s.line==null?"a line of the diff":`${s.path}:${s.line}${s.side==="old"?" (removed)":""}`;const r=i.comments.find(u=>u.id===s.id);return r?`${r.path}:${r.line??"file"}`:"a comment"}function DT({turn:s,artifact:i}){return f.jsxs("div",{className:`chat-turn chat-turn-${s.role}`,children:[f.jsx("div",{className:"chat-turn-who",children:s.role==="user"?"You":"Reviewer"}),s.refs.length>0&&f.jsx("div",{className:"chat-refs",children:s.refs.map((r,l)=>f.jsx("span",{className:"chat-ref",children:Of(r,i)},l))}),f.jsx(Wi,{className:"chat-turn-body prose",text:s.body}),s.revisions.map((r,l)=>f.jsxs("div",{className:"chat-revision",children:["↳ ",CT(r,i)]},l)),s.refused.map((r,l)=>f.jsxs("div",{className:"chat-refusal",title:r.reason,children:["⃠ left alone — ",r.reason]},l))]})}function LT({artifact:s,reviewKey:i,refs:r,onClearRefs:l,onDropRef:u,onArtifact:d,readOnly:g,inputRef:y,anchorRef:m}){const[p,b]=ie.useState(""),[v,_]=ie.useState(!1),[x,N]=ie.useState(null),A=s.chat??[],w=s.pendingChat??null,U=w!=null&&w.error==null,G=v||U,L=(H,P,le)=>{!H||G||(_(!0),N(null),Sf(i,{message:H,refs:P}).then(k=>{d(k),le==null||le()}).catch(k=>N(String(k.message??k))).finally(()=>_(!1)))},re=()=>L(p.trim(),r,()=>{b(""),l()});return f.jsxs("section",{className:"card chat-panel",ref:m,children:[f.jsxs("header",{className:"card-head",children:[f.jsx("h2",{children:"talk to the reviewer"}),f.jsx("span",{className:"faint",children:"it revises the draft as you go · nothing here reaches GitHub — only send does"})]}),f.jsxs("div",{className:"card-body",children:[(A.length>0||w)&&f.jsxs("div",{className:"chat-log",children:[A.map(H=>f.jsx(DT,{turn:H,artifact:s},H.id)),w&&f.jsxs("div",{className:"chat-turn chat-turn-user chat-turn-pending",children:[f.jsx("div",{className:"chat-turn-who",children:"You"}),w.refs.length>0&&f.jsx("div",{className:"chat-refs",children:w.refs.map((H,P)=>f.jsx("span",{className:"chat-ref",children:Of(H,s)},P))}),f.jsx(Wi,{className:"chat-turn-body prose",text:w.message}),w.error?f.jsxs("div",{className:"chat-failed",children:["✕ this turn didn't finish — ",w.error,!g&&f.jsxs(f.Fragment,{children:[" ",f.jsx("button",{className:"btn btn-sm",onClick:()=>L(w.message,w.refs),children:"ask again"})," ",f.jsx("button",{className:"btn btn-sm",onClick:()=>k_(i).then(d).catch(H=>N(String(H.message??H))),children:"dismiss"})]})]}):f.jsx("div",{className:"chat-waiting",children:w.progress.length>0?f.jsx("ol",{className:"chat-progress",children:w.progress.map((H,P)=>f.jsx("li",{className:P===w.progress.length-1?"now":void 0,children:H},P))}):"Thinking… it re-reads the code before answering."})]})]}),g?f.jsx("p",{className:"faint",children:"This review was sent — the conversation is kept as a record, but it can't continue."}):f.jsxs(f.Fragment,{children:[r.length>0&&f.jsxs("div",{className:"chat-refs chat-refs-pending",children:[f.jsx("span",{className:"faint",children:"about:"}),r.map((H,P)=>f.jsxs("button",{className:"chat-ref",onClick:()=>u(P),title:"Remove",children:[Of(H,s)," ✕"]},P))]}),f.jsx("textarea",{ref:y,className:"chat-input",rows:2,value:p,disabled:v,placeholder:A.length===0?"the summary restates the author's claims — say what you actually verified":"say more…",onChange:H=>b(H.target.value),onKeyDown:H=>{H.key==="Enter"&&(H.metaKey||H.ctrlKey)&&re()}}),f.jsx(Gs,{className:"chat-turn-body",text:p}),f.jsxs("div",{className:"chat-actions",children:[f.jsxs("button",{className:"btn btn-dark",onClick:re,disabled:G||p.trim()==="",children:[f.jsx(Ze,{name:"send"}),U?"waiting for the answer…":v?"sending…":"send message",f.jsx(Va,{children:"⌘↵"})]}),f.jsx("span",{className:"grow"}),s.preChat&&f.jsx("button",{className:"btn btn-sm",disabled:G,title:"Put the review back the way the AI first wrote it. The conversation stays.",onClick:()=>{_(!0),M_(i).then(d).catch(H=>N(String(H.message??H))).finally(()=>_(!1))},children:"reset the review"})]})]}),x&&f.jsx("div",{className:"error",children:x})]})]})}function zT(s){const i=ie.useRef(null),r=ie.useCallback(()=>{const l=i.current;l&&(l.style.height="auto",l.style.height=`${Math.min(l.scrollHeight+2,window.innerHeight*.7)}px`)},[]);return ie.useEffect(r,[s,r]),ie.useEffect(()=>(window.addEventListener("resize",r),()=>window.removeEventListener("resize",r)),[r]),i}function UT({initial:s,onSave:i,onCancel:r}){const[l,u]=ie.useState(s),[d,g]=ie.useState(!1),[y,m]=ie.useState(null),p=zT(l),b=()=>{d||(g(!0),m(null),i(l).catch(v=>{m(String((v==null?void 0:v.message)??v)),g(!1)}))};return f.jsxs("div",{className:"body-edit-wrap",children:[f.jsx("textarea",{ref:p,className:"body-edit",autoFocus:!0,value:l,onChange:v=>u(v.target.value),rows:10,onKeyDown:v=>{v.key==="Enter"&&(v.metaKey||v.ctrlKey)&&(v.preventDefault(),b()),v.key==="Escape"&&!d&&r()}}),f.jsx(Gs,{className:"body-md",text:l}),y&&f.jsx("p",{className:"error",children:y}),f.jsxs("div",{className:"card-actions",children:[f.jsxs("button",{className:"btn btn-sm",onClick:b,disabled:d,children:[d?"saving…":"save this body",f.jsx(Va,{children:"⌘↵"})]}),f.jsx("button",{className:"btn btn-sm",onClick:r,disabled:d,children:"cancel"}),f.jsx("span",{className:"faint",children:"this is exactly what posts — the review above stays as it reads"})]})]})}function BT({artifact:s,reviewKey:i,event:r,sending:l,onSend:u,error:d,footer:g,next:y,onAdvance:m,onBody:p,anchorRef:b}){const[v,_]=ie.useState(null),[x,N]=ie.useState(null),[A,w]=ie.useState(s.bodyOverride!=null),[U,G]=ie.useState(!1),L=s.bodyOverride!=null;ie.useEffect(()=>{L&&w(!0)},[L]);const re=A&&!U?JSON.stringify([s.bodyOverride,s.summary,s.chapters.map(H=>[H.title,H.explanation]),s.comments.map(H=>[H.path,H.line,H.body,H.severity,H.status,H.drifted])]):"";return ie.useEffect(()=>{if(!A||U)return;let H=!1;return _(null),N(null),C_(i,r).then(P=>{H||(_(P),N(null))}).catch(P=>{H||N(String(P))}),()=>{H=!0}},[i,r,re,A,U]),s.sent?f.jsxs(f.Fragment,{children:[f.jsxs("div",{className:"sent-strip",ref:b,children:[f.jsxs("strong",{children:["✔ sent as ",lb[s.sent.event]]}),f.jsxs("span",{className:"faint",children:[new Date(s.sent.at).toLocaleString()," · ",rb(s),s.sent.auto?" · auto-sent by the daemon":""]}),f.jsx("span",{className:"grow"}),s.sent.url&&f.jsxs("a",{href:s.sent.url,target:"_blank",rel:"noreferrer",children:["view on GitHub ",f.jsx(Ze,{name:"external"})]}),f.jsxs("button",{className:"btn",onClick:m,children:[y?`next review: ${y.pr.repo}#${y.pr.number}`:"back to the queue",f.jsx(Ze,{name:"arrowRight"})]})]}),f.jsx("div",{className:"post-actions",children:g})]}):f.jsxs("div",{className:"send-panel",ref:b,children:[f.jsxs("div",{className:"send-top",children:[f.jsx("span",{className:"lab",children:"send to github"}),f.jsx("span",{className:"grow"}),f.jsx("span",{className:"faint",children:rb(s)}),f.jsxs("button",{className:"link",onClick:()=>w(!A),disabled:U,title:U?"save or cancel the body you are writing first":void 0,children:[f.jsx(Ze,{name:"eye"}),A?"hide what gets posted":"see what gets posted"]})]}),f.jsx("div",{className:"send-action",children:f.jsxs("button",{className:`send-btn tone-bg-${yT[r]}`,disabled:l,onClick:u,children:[f.jsx(Ze,{name:r==="APPROVE"?"approve":r==="COMMENT"?"comment":"changes",size:15}),l?"sending…":`${lb[r]} on ${s.id}`," ",f.jsx(Va,{children:"s"})]})}),d&&f.jsx("p",{className:"error",children:d}),A&&f.jsxs(f.Fragment,{children:[x&&f.jsx("p",{className:"error",children:x}),v?U?f.jsx(UT,{initial:v.body,onSave:H=>p(H).then(()=>G(!1)),onCancel:()=>G(!1)}):f.jsxs(f.Fragment,{children:[f.jsx("pre",{className:"body-preview",children:v.body}),f.jsxs("div",{className:"body-source",children:[L?f.jsxs(f.Fragment,{children:[f.jsx("span",{className:"faint",children:"you wrote this body — it no longer follows the summary or the comments"}),f.jsx("button",{className:"link",onClick:()=>p(null).catch(H=>N(String((H==null?void 0:H.message)??H))),children:"build it from the review again"})]}):f.jsx("span",{className:"faint",children:"built from the summary, the walkthrough and the folded comments"}),f.jsxs("button",{className:"link",onClick:()=>G(!0),children:[f.jsx(Ze,{name:"edit"}),L?"keep editing it":"write it yourself"]})]})]}):!x&&f.jsx("p",{className:"faint",children:"building the body…"})]}),f.jsx("div",{className:"send-footer",children:g})]})}function jT({filed:s}){var l,u,d;const i=((l=s.review)==null?void 0:l.url)??((u=s.reply)==null?void 0:u.url)??null,r=g=>new Date(g).toLocaleDateString();return s.reason==="own-reply"&&s.reply?f.jsxs("div",{children:[f.jsxs("strong",{children:["You answered on this PR on ",r(s.reply.at),", and nobody has replied since."]})," ","So cerber filed this draft under settled: GitHub does not count a"," ",i?f.jsx("a",{href:i,target:"_blank",rel:"noreferrer",children:"comment"}):"comment"," ","as a review, but the PR is with its author either way. Nothing here was changed or sent — the draft is still yours to send."]}):s.reason==="request-withdrawn"?f.jsxs("div",{children:[f.jsx("strong",{children:"Nobody is asking for this review any more."})," The request that put this PR in the inbox was taken back on or before ",r(s.at),", and you had said nothing on the PR, so cerber filed the draft under settled rather than leaving it out for you. Nothing here was changed or sent — the draft is still yours to send."]}):f.jsxs("div",{children:[f.jsxs("strong",{children:["You reviewed this PR on GitHub on ",r(((d=s.review)==null?void 0:d.at)??s.at),"."]})," ","So cerber filed this draft under settled, GitHub having stopped asking you for a review and shown one of your own"," ",i?f.jsx("a",{href:i,target:"_blank",rel:"noreferrer",children:"already on the PR"}):"already on the PR",". Nothing here was changed or sent — the draft is still yours to send."]})}function IT({entries:s,open:i,onToggle:r,anchorRef:l}){const u=d=>{const g=new Date(d);return Number.isNaN(g.getTime())?d:`${g.toLocaleDateString(void 0,{month:"short",day:"2-digit"})} ${g.toLocaleTimeString(void 0,{hour:"2-digit",minute:"2-digit"})}`};return f.jsxs("section",{className:"card",ref:l,children:[f.jsxs("header",{className:"card-head",children:[f.jsx("h2",{children:"history"}),f.jsx("span",{className:"faint",children:s.length>0?`${s.length} entr${s.length===1?"y":"ies"} — what cerber did to this row, and when`:"nothing recorded"}),f.jsx("span",{className:"grow"}),f.jsxs("button",{className:"btn btn-sm","aria-expanded":i,onClick:r,children:[f.jsx(Ze,{name:i?"up":"down",size:12}),i?"hide":"show"]})]}),i&&f.jsx("div",{className:"card-body",children:s.length===0?f.jsx("div",{className:"faint",children:"This review predates cerber keeping a history — it starts at the next thing that happens to it."}):f.jsx("ol",{className:"history",children:s.map((d,g)=>({e:d,i:g})).reverse().map(({e:d,i:g})=>f.jsxs("li",{className:"history-row",title:d.at,children:[f.jsx("span",{className:"history-at",children:u(d.at)}),f.jsx("span",{className:"history-by",children:d.by}),f.jsx("span",{className:"history-what",children:d.what}),d.cause&&f.jsx("span",{className:"history-cause",children:d.cause})]},`${g}-${d.at}`))})})]})}function HT({artifact:s,freshness:i,rerunning:r,onRerun:l}){const u=s.status==="running",d=(i==null?void 0:i.prState)??s.pr.state,g=d==="CLOSED"||d==="MERGED",y=s.refresh,m=i!=null&&i.changed?y:null;if(u)return f.jsxs("div",{className:"freshness freshness-running",children:[f.jsx("strong",{children:"Re-reviewing…"})," Claude is reading the current head. This page updates itself when the run lands — it takes a few minutes."]});const p=s.filed;return!g&&!m&&!p?null:f.jsxs("div",{className:"freshness",children:[p&&f.jsx(jT,{filed:p}),g&&f.jsxs("div",{children:[f.jsxs("strong",{children:["This PR is ",d==="MERGED"?"merged":"closed","."]})," A review can still be sent, but nobody is waiting for it."]}),m&&f.jsxs("div",{children:[f.jsx("strong",{children:"The PR moved on since this review."})," Now at"," ",f.jsx("code",{children:m.toSha.slice(0,7)}),", reviewed at"," ",f.jsx("code",{children:m.fromSha.slice(0,7)}),"."," ",m.moved>0&&f.jsxs(f.Fragment,{children:[m.moved," comment(s) followed the code. "]}),m.drifted>0&&f.jsxs(f.Fragment,{children:[m.drifted," could not — the code they point at is gone, so they will post in the review body instead of inline."," "]}),"The summary and verdict still describe the commit that was reviewed.",!s.sent&&f.jsx("button",{className:"btn btn-sm",disabled:r,onClick:()=>l(),children:r?"starting…":"re-review at the new head"})]})]})}function qT({reviewKey:s}){var Qt,Ct,R,J;const[i,r]=ie.useState(null),[l,u]=ie.useState(null),[d,g]=ie.useState(null),[y,m]=ie.useState(null),[p,b]=ie.useState(!1),[v,_]=ie.useState([]),[x,N]=ie.useState(new Set),A=C=>N(ee=>new Set(ee).add(C)),[w,U]=ie.useState([]),G=ie.useRef(null),[L,re]=ie.useState(new Set),[H,P]=ie.useState(0),le=ie.useRef(new Map),k=ie.useRef(null),z=ie.useRef(null),Q=ie.useRef(null),K=ie.useRef(null),fe=ie.useRef(null),we=ie.useRef(null),[Ae,he]=ie.useState(!1),I=ie.useRef(null),[ne,oe]=ie.useState(!1),[ve,Se]=ie.useState(null),[S,D]=ie.useState(null);ie.useEffect(()=>{if(!S)return;const C=setTimeout(()=>D(null),1800);return()=>clearTimeout(C)},[S]);const q=C=>{var ee,de;U(Le=>Le.some(ye=>ye.target===C.target&&ye.id===C.id)?Le:[...Le,C]),(ee=G.current)==null||ee.scrollIntoView({behavior:"smooth",block:"center"}),(de=G.current)==null||de.focus()};ie.useEffect(()=>{let C=!1;return window.scrollTo({top:0}),r(null),u(null),g(null),m(null),Se(null),Qm(s).then(ee=>{if(!C)return r(ee),O_(s).then(de=>{C||(g(de),de.changed&&r(de.artifact))}).catch(de=>!C&&m(String(de.message??de)))}).catch(ee=>!C&&u(String(ee))),()=>{C=!0}},[s]),ie.useEffect(()=>{If().then(_).catch(()=>{})},[]),ie.useEffect(()=>{const C=I.current;if(!C)return;const ee=()=>document.documentElement.style.setProperty("--top-h",`${C.offsetHeight}px`);ee();const de=new ResizeObserver(ee);return de.observe(C),()=>{de.disconnect(),document.documentElement.style.removeProperty("--top-h")}},[i==null?void 0:i.id]);const ue=(i==null?void 0:i.pendingChat)!=null&&i.pendingChat.error==null;ie.useEffect(()=>{if((i==null?void 0:i.status)!=="running"&&!ue)return;const C=setInterval(()=>{Qm(s).then(ee=>{r(ee),ee.status!=="running"&&b(!1)}).catch(()=>{})},3e3);return()=>clearInterval(C)},[i==null?void 0:i.status,ue,s]);const pe=(i==null?void 0:i.sent)!=null,xe=ie.useMemo(()=>a_(v,s,x),[v,s,x]),me=xe.findIndex(C=>C.key===s),Oe=(me>0?xe[me-1]:null)??null,Ee=(me>=0&&me<xe.length-1?xe[me+1]:null)??null,He=C=>{window.location.hash=`#/r/${encodeURIComponent(C)}`},We=()=>{Ee?He(Ee.key):window.location.hash="#/"},ke=ie.useMemo(()=>{if(!i)return[];const C=e_(i.diff,i.chapters);return C.length>0?[...i.chapters,{id:"__other",title:"other changes",explanation:"Files not assigned to any chapter by the AI.",files:C}]:i.chapters},[i]),At=ie.useMemo(()=>{const C=t_((i==null?void 0:i.diff)??""),ee=new Set(Ja((i==null?void 0:i.diff)??"").filter(Le=>Xf(Le.path)&&Qf(Le.patch).isNew).map(Le=>Le.path)),de=Le=>{const ye=C.get(Le)??0;return ee.has(Le)?Math.round(ye/4):ye};return new Map(ke.map(Le=>[Le.id,Le.files.reduce((ye,be)=>ye+de(be),0)]))},[i==null?void 0:i.diff,ke]),Ge=C=>{const ee=At.get(C)??0;return ee>kT?ee:null},Nn=C=>L.has(C)?Ge(C)!=null:Ge(C)==null,nt=C=>re(ee=>{const de=new Set(ee);return de.has(C)?de.delete(C):de.add(C),de});ie.useEffect(()=>re(new Set),[s]);const xn=C=>{const ee=ke[C];return ee?(P(C),Nn(ee.id)||nt(ee.id),ee):null},rn=(...C)=>{for(const ee of C)if(ee.current)return ee.current.scrollIntoView({behavior:"smooth",block:"start"})},zt=C=>{var de;const ee=xn(C);ee&&((de=le.current.get(ee.id))==null||de.scrollIntoView({behavior:"smooth",block:"start"}))},qn=(C,ee)=>{xn(C),D(ee),Zb(ee)},Jt=vT(i==null?void 0:i.verdict);ie.useEffect(()=>Se(null),[Jt]);const ca=()=>{!i||i.sent||ne||(oe(!0),Se(null),D_(s,Jt).then(C=>{r(C),A(s)}).catch(C=>Se(String(C.message??C))).finally(()=>oe(!1)))};if(ie.useEffect(()=>{const C=ee=>{NT(ee.target)||ee.metaKey||ee.ctrlKey||ee.altKey||(ee.key==="["&&Oe?He(Oe.key):ee.key==="]"&&Ee?He(Ee.key):ee.key==="n"?zt(Math.min(ke.length-1,H+1)):ee.key==="N"?zt(Math.max(0,H-1)):ee.key==="s"&&!pe&&ca())};return window.addEventListener("keydown",C),()=>window.removeEventListener("keydown",C)},[Oe==null?void 0:Oe.key,Ee==null?void 0:Ee.key,ke,H,pe,Jt,i==null?void 0:i.sent,ne]),l&&!i)return f.jsx("p",{className:"error pad",children:l});if(!i)return f.jsx("p",{className:"muted pad",children:"Loading…"});const Vn=C=>{b(!0),u(null),kb(s,C).then(r).catch(ee=>{u(String(ee)),b(!1)})},Ke=C=>C.then(r).catch(ee=>u(String(ee))),Ut=C=>no(s,{status:C}).then(()=>{A(s),We()}).catch(ee=>u(String(ee))),mt=(C,ee)=>Ke(x_(s,C,ee)),bn=C=>Ke(A_(s,C)),yn=C=>Ke(w_(s,C)),kn=i.comments.filter(C=>!C.chapterId||!ke.some(ee=>ee.id===C.chapterId)),{inline:Mn,folded:Cn,dropped:Dn}=Pb(i),on=i.comments.filter(C=>C.status!=="dropped"&&C.drifted).length,St=i.verdict?ob[i.verdict.recommendation]:"none",Wt=_T(i),Nt=ET(i),Gn=i.pendingChat!=null&&i.pendingChat.error==null,Ln=()=>Ke(Sf(s,{message:"Some findings were dropped, edited or re-graded after this review was drafted. Update the summary and verdict to match what still stands — do not re-review the whole PR.",refs:[{target:"verdict",id:null}]})),wn=(C,ee)=>{Ke(Sf(s,{message:ee,refs:[{target:"line",id:null,path:C.path,line:C.line,side:C.side}]}).then(de=>{var Le;return(Le=K.current)==null||Le.scrollIntoView({behavior:"smooth",block:"start"}),de}))},je=(Qt=i.run)!=null&&Qt.trusted?"ran the code":(Ct=i.run)!=null&&Ct.withSource?"read the full source":i.run?"read the diff only":"no run yet",cn=C=>{var ee;return f.jsxs("button",{className:`verdict-pick${((ee=i.verdict)==null?void 0:ee.recommendation)===C?` verdict-pick-on tone-${ob[C]}`:""}`,title:C==="approve"?"Approve":C==="comment"?"Comment without blocking":"Request changes",onClick:()=>Ke(no(s,{verdictRecommendation:C})),children:[f.jsx(Ze,{name:ST[C]}),C.replace("_"," ")]},C)};return f.jsxs("div",{className:"review",children:[f.jsxs("div",{className:"review-top",ref:I,children:[f.jsx("div",{className:"crumb",children:f.jsxs("div",{className:"bar-inner",children:[f.jsx("a",{href:"#/",children:"← queue"}),f.jsx("span",{className:"crumb-sep",children:"/"}),f.jsx("span",{className:"crumb-slug",children:i.id}),f.jsxs("span",{className:"walk",children:[f.jsx("button",{className:"walk-btn",disabled:!Oe,title:Oe?`[ — ${Oe.id}: ${Oe.pr.title}`:"no more reviews this way",onClick:()=>Oe&&He(Oe.key),children:f.jsx(Ze,{name:"chevronLeft"})}),f.jsx("button",{className:"walk-btn",disabled:!Ee,title:Ee?`] — ${Ee.id}: ${Ee.pr.title}`:"no more reviews this way",onClick:()=>Ee&&He(Ee.key),children:f.jsx(Ze,{name:"chevronRight"})})]}),me>=0&&f.jsxs("span",{className:"faint",children:[me+1," of ",xe.length," awaiting"]}),f.jsx("span",{className:"faint crumb-next",children:Ee?`next: ${Ee.pr.title}`:me>=0?"last in the queue":""}),f.jsx("span",{className:"grow"}),f.jsx("a",{href:"#/settings",className:"topbar-link",title:"settings","aria-label":"settings",children:f.jsx(Ze,{name:"settings",size:15})})]})}),f.jsx("div",{className:"review-head",children:f.jsxs("div",{className:"wrap",children:[f.jsxs("div",{className:"review-head-top",children:[f.jsx("a",{className:"review-title",href:i.pr.url,target:"_blank",rel:"noreferrer",children:i.pr.title}),f.jsx("span",{className:"grow"}),i.verdict&&(pe?f.jsxs("span",{className:`chip tone-${St}`,children:[i.verdict.recommendation.replace("_"," "),Nt&&` · ${Nt}`," · ",i.verdict.confidence,"%"]}):f.jsxs("button",{className:`chip chip-btn tone-${St}`,title:"change it, or send — both are at the foot of the page",onClick:()=>rn(k,fe),children:[i.verdict.recommendation.replace("_"," "),Nt&&` · ${Nt}`," · ",i.verdict.confidence,"%",f.jsx(Ze,{name:"down",size:12})]})),!pe&&i.status!=="running"&&f.jsxs("button",{className:"btn btn-sm",disabled:p,title:i.run?"Review the current head again — this draft is replaced, your own comments are kept":"Draft a review of this PR",onClick:()=>Vn(!0),children:[f.jsx(Ze,{name:"rerun"}),p?"starting…":i.run?"re-review at head":"review now"]})]}),f.jsxs("div",{className:"review-head-meta",children:[f.jsx("span",{children:i.pr.author}),f.jsx("span",{className:"crumb-sep",children:"·"}),f.jsxs("span",{children:[i.pr.headRefName," → ",i.pr.baseRefName,i.pr.headSha?` · ${i.pr.headSha.slice(0,7)}`:""]}),f.jsx("span",{className:"crumb-sep",children:"·"}),f.jsxs("span",{children:[i.pr.changedFiles,"f ",f.jsxs("span",{className:"add",children:["+",i.pr.additions]})," ",f.jsxs("span",{className:"del",children:["−",i.pr.deletions]})]}),f.jsx("span",{className:"crumb-sep",children:"·"}),f.jsx("span",{title:"Whether the run read a local checkout of the PR head, or the diff alone.",children:je}),((R=i.run)==null?void 0:R.costUsd)!=null&&f.jsxs(f.Fragment,{children:[f.jsx("span",{className:"crumb-sep",children:"·"}),f.jsxs("span",{title:"What this review would cost at API token rates. Riding a Claude subscription, it draws on your usage limits instead.",children:["≈$",i.run.costUsd.toFixed(2)," at API rates"]})]}),f.jsx("span",{className:"crumb-sep",children:"·"}),f.jsx("span",{title:"Where this review has got to: drafted, settled locally, or sent.",children:i.status})]})]})})]}),f.jsxs("div",{className:"wrap review-body",children:[f.jsxs("aside",{className:"rail",children:[f.jsxs("div",{className:"rail-jumps",children:[f.jsx("button",{className:"lab rail-lab-btn",onClick:()=>rn(z),children:"summary"}),i.verdict&&f.jsx("button",{className:"lab rail-lab-btn",onClick:()=>rn(Q),children:"why"}),f.jsx("button",{className:"lab rail-lab-btn",onClick:()=>{var C;rn(K),(C=G.current)==null||C.focus()},children:"chat"}),f.jsx("button",{className:"lab rail-lab-btn",onClick:()=>{he(!0),rn(we)},children:"history"})]}),ke.length>0&&f.jsxs(f.Fragment,{children:[f.jsx("button",{className:"lab rail-lab rail-lab-btn",onClick:()=>zt(0),children:"changes"}),ke.map((C,ee)=>f.jsxs("div",{className:"rail-group",children:[f.jsxs("button",{className:`rail-item${Nn(C.id)?" rail-item-on":""}`,onClick:()=>zt(ee),children:[f.jsxs("span",{className:"grow",children:[ee+1," · ",C.title]}),f.jsxs("span",{className:"rail-count",children:[C.files.length,"f"]})]}),i.comments.filter(de=>de.chapterId===C.id).map(de=>f.jsxs("button",{className:`rail-comment${de.status==="dropped"?" rail-comment-dropped":""}`,title:`${de.path}${de.line!=null?`:${de.line}`:""} — ${de.body}`,onClick:()=>qn(ee,de.id),children:[f.jsx("span",{className:`rail-dot tone-${TT(de,St)}`,children:"●"}),f.jsxs("span",{className:"rail-comment-loc",children:[de.path.split("/").pop(),de.line!=null?`:${de.drifted?"~":""}${de.line}`:""]})]},de.id))]},C.id))]}),f.jsx("button",{className:`lab rail-lab-btn${ke.length>0?" rail-lab":""}`,onClick:()=>rn(k,fe),children:"verdict"}),f.jsxs("div",{className:"rail-facts",children:[f.jsxs("div",{children:[i.comments.length-Dn.length," keeping · ",Dn.length," dropped"]}),f.jsxs("div",{children:[Mn.length," inline · ",Cn.length," folded into body"]}),on>0&&f.jsxs("div",{className:"warn",title:"The code these comments pointed at is gone from the diff.",children:[on," drifted"]})]})]}),f.jsxs("div",{className:"main",children:[f.jsx(HT,{artifact:i,freshness:d,rerunning:p,onRerun:Vn}),y&&f.jsxs("div",{className:"freshness",title:y,children:[f.jsx("strong",{children:"Couldn't check GitHub for newer commits."})," This is the review as it was drafted — if the PR has moved on since, nothing here knows it yet."]}),l&&f.jsx("div",{className:"error",children:l}),((J=i.run)==null?void 0:J.error)&&f.jsxs("div",{className:"error",children:["Run failed: ",i.run.error]}),f.jsx("div",{className:"card card-summary",ref:z,children:f.jsxs("div",{className:"card-body",children:[f.jsx("div",{className:"lab",children:"summary"}),f.jsx(Wi,{className:"prose lead",text:i.summary||"(no summary)"}),!pe&&f.jsxs("button",{className:"btn btn-sm",onClick:()=>q({target:"summary",id:null}),children:[f.jsx(Ze,{name:"comment"}),"discuss the summary"]})]})}),i.verdict&&f.jsx("div",{className:`card card-why tone-border-${St}`,ref:Q,children:f.jsxs("div",{className:"card-body",children:[f.jsx("div",{className:"lab",children:"why"}),f.jsxs("div",{className:"sev-counts",children:[Ef(i)&&f.jsx("span",{title:"Live findings by grade. Only blockers block.",children:Ef(i)}),Ef(i)&&f.jsx("span",{className:"crumb-sep",children:"·"}),f.jsxs("span",{title:"How sure the review is that its own findings and grades are right — not how good an idea merging is. That is the verdict, and it follows from the blockers.",children:[i.verdict.confidence,"% sure of the findings"]})]}),f.jsx(Wi,{className:"prose lead",text:i.verdict.reasoning}),!pe&&f.jsxs("button",{className:"btn btn-sm",onClick:()=>q({target:"verdict",id:null}),children:[f.jsx(Ze,{name:"comment"}),"discuss the verdict"]})]})}),ke.map((C,ee)=>f.jsx(MT,{chapter:C,n:ee+1,diff:i.diff,comments:i.comments.filter(de=>de.chapterId===C.id),open:Nn(C.id),heavy:Ge(C.id),onToggle:()=>nt(C.id),onUpdateComment:mt,onDeleteComment:bn,onAddComment:yn,onDiscuss:pe?void 0:q,onAskAboutLine:wn,chatBusy:Gn,readOnly:pe,flash:S,anchorRef:de=>{de?le.current.set(C.id,de):le.current.delete(C.id)}},C.id)),kn.length>0&&f.jsxs("section",{className:"card",children:[f.jsxs("header",{className:"card-head",children:[f.jsx("h2",{children:"unattached comments"}),f.jsx("span",{className:"faint",children:"not part of any chapter — they post in the body"})]}),f.jsx("div",{className:"card-body",children:kn.map(C=>f.jsx(Qb,{comment:C,onUpdate:ee=>mt(C.id,ee),onDelete:()=>bn(C.id),onDiscuss:pe?void 0:()=>q({target:"comment",id:C.id}),readOnly:pe},C.id))})]}),f.jsx(IT,{entries:i.history??[],open:Ae,onToggle:()=>he(C=>!C),anchorRef:we})]}),f.jsxs("aside",{className:"cockpit",children:[f.jsx(LT,{artifact:i,reviewKey:s,refs:w,onClearRefs:()=>U([]),onDropRef:C=>U(ee=>ee.filter((de,Le)=>Le!==C)),onArtifact:r,readOnly:pe,inputRef:G,anchorRef:K}),f.jsxs("div",{className:"cockpit-decide",children:[!pe&&Wt&&f.jsxs("div",{className:"verdict-hint",children:[f.jsxs("span",{children:[f.jsx("strong",{children:Wt})," — the verdict was written before those edits."]}),f.jsxs("button",{className:"btn btn-sm",onClick:Ln,disabled:Gn,children:[f.jsx(Ze,{name:"comment"}),Gn?"the reviewer is busy…":"ask the reviewer to re-true it"]})]}),i.verdict&&!pe&&f.jsxs("div",{className:"verdict-bar",ref:k,children:[f.jsx("span",{className:"lab",children:"verdict"}),["approve","comment","request_changes"].map(cn)]}),f.jsx(BT,{artifact:i,reviewKey:s,event:Jt,sending:ne,onSend:ca,error:ve,next:Ee,onAdvance:We,onBody:C=>no(s,{bodyOverride:C}).then(r),anchorRef:fe,footer:f.jsxs(f.Fragment,{children:[f.jsx("span",{className:"faint",children:"not sending?"}),!pe&&f.jsxs(f.Fragment,{children:[f.jsxs("button",{className:"btn btn-sm",title:Ee?`Settle it locally and go to ${Ee.id}`:"Settle it locally and go back to the queue",onClick:()=>Ut("reviewed"),children:[f.jsx(Ze,{name:"check"}),"mark reviewed"]}),f.jsxs("button",{className:"btn btn-sm",title:Ee?`Leave it and go to ${Ee.id}`:"Leave it and go back to the queue",onClick:()=>Ut("skipped"),children:[f.jsx(Ze,{name:"skip"}),"skip"]})]}),f.jsxs("a",{className:"btn btn-sm",href:L_(s),children:[f.jsx(Ze,{name:"download"}),"export .md"]})]})})]})]})]})]})}const ub="cerber.notify.seen",kf="cerber.notify",GT=1e4,Vb=s=>{try{return localStorage.getItem(s)}catch{return null}},Mf=(s,i)=>{try{localStorage.setItem(s,i)}catch{}};function $T(s,i){const r=new Set(i);return fo(s).filter(l=>!r.has(l.key))}function YT(s){return s.map(i=>i.key)}const fb=s=>`${s.pr.repo}#${s.pr.number}`;function FT(s){if(s.length===0)return null;const i=`cerber:${s.map(u=>u.key).sort().join("|")}`;if(s.length===1){const u=s[0];return{title:`${fb(u)} awaits your review`,body:`${u.pr.title} — ${u.pr.author}`,tag:i,key:u.key}}const r=s.slice(0,3).map(fb),l=s.length-r.length;return{title:`${s.length} PRs await your review`,body:l>0?`${r.join(", ")} and ${l} more`:r.join(", "),tag:i,key:null}}const KT=new Set(["127.0.0.1","localhost","::1","[::1]"]);function PT(s,i=window.location.hostname){return!(s!=null&&s.enabled)||!s.notify?!1:KT.has(i)}function Hl(){return typeof Notification>"u"?"unsupported":Notification.permission==="denied"?"blocked":Vb(kf)==="off"?"off":Notification.permission==="granted"?"on":"ask"}async function ZT(){const s=Hl();if(s==="unsupported"||s==="blocked")return s;if(s==="on")return Mf(kf,"off"),Hl();if(Mf(kf,"on"),Notification.permission==="default")try{await Notification.requestPermission()}catch{}return Hl()}function XT(s){const i=new Notification(s.title,{body:s.body,tag:s.tag,icon:"/favicon.svg"});i.onclick=()=>{window.focus(),window.location.hash=s.key?`#/r/${encodeURIComponent(s.key)}`:"#/",i.close()}}function QT(){const s=ie.useRef(null),[i,r]=ie.useState(!1);return ie.useEffect(()=>{let l=!0;const u=Vb(ub);if(u)try{const y=JSON.parse(u);Array.isArray(y)&&(s.current=y.filter(m=>typeof m=="string"))}catch{}const d=()=>Promise.all([If(),Ob().catch(()=>null)]).then(([y,m])=>{if(!l)return;const p=PT(m);r(p);const b=s.current;if(s.current=YT(y),Mf(ub,JSON.stringify(s.current)),!b||Hl()!=="on"||p||document.visibilityState==="visible"&&document.hasFocus())return;const v=FT($T(y,b));v&&XT(v)}).catch(()=>{});d();const g=setInterval(d,GT);return()=>{l=!1,clearInterval(g)}},[]),i}function Jb(){const[s,i]=ie.useState(Hl);return ie.useEffect(()=>{const r=()=>i(Hl());return window.addEventListener("focus",r),()=>window.removeEventListener("focus",r)},[]),[s,()=>void ZT().then(i)]}const ao=s=>{window.location.hash=`#/r/${encodeURIComponent(s)}`};function VT(s){const i=s,r=i==null?void 0:i.tagName;return r==="INPUT"||r==="TEXTAREA"||r==="SELECT"||(i==null?void 0:i.isContentEditable)===!0}function JT({daemon:s}){return s!=null&&s.enabled?f.jsx("div",{className:"daemon-bar",children:f.jsxs("div",{className:"bar-inner",children:[s.pollEnabled?f.jsxs("span",{className:"daemon-live",children:[f.jsx("span",{className:"dot"}),s.repos.length>0?s.repos.join(", "):"PRs awaiting you"," · every"," ",Math.round(s.intervalMs/6e4),"m"]}):f.jsxs("span",{className:"daemon-live",children:[f.jsx("span",{className:"dot dot-off"}),"polling off (settings) — local reviews only"]}),f.jsxs("span",{children:["auto-review ",f.jsx("b",{children:s.autoReview?"on":"off"})]}),f.jsxs("span",{children:["auto-send ",f.jsx("b",{children:s.autoSend==="on"?"on":"shadow"})," ≥",s.autoSendThreshold,"%",s.autoSend==="on"?` · ${s.autoSent} sent`:` · ${s.autoSendCandidates} candidate${s.autoSendCandidates===1?"":"s"}`]}),s.trustedRuns&&f.jsxs("span",{className:"warn",title:"Trust rules are set and auto-review is on: reviews of matching PRs run their code unattended. Settings → Trusted PRs, or serve --no-trust.",children:[f.jsx(Ze,{name:"zap",size:12})," trusted PRs run with Bash"]}),f.jsx("span",{className:"grow"}),s.lastPollError?f.jsx("span",{className:"danger",title:s.lastPollError,children:"discovery offline — showing local reviews only"}):f.jsx("span",{className:"faint",children:s.polling?"polling now…":[s.lastSummary?`last: ${s.lastSummary}`:null,s.nextPollAt?`next ${new Date(s.nextPollAt).toLocaleTimeString()}`:null].filter(Boolean).join(" · ")})]})}):null}function WT({r:s,selected:i,onSelect:r,onOpen:l,awaiting:u}){const d=b_(s),g=o_(s),y=u?p_(s,u):null;return f.jsxs("div",{className:`row${i?" row-on":""}`,onClick:l,onMouseEnter:r,role:"button",tabIndex:-1,children:[f.jsx("span",{className:"col-caret",children:"›"}),f.jsxs("span",{className:"col-slug",title:`${s.pr.owner}/${s.pr.repo}#${s.pr.number}`,children:[s.pr.repo,"#",s.pr.number]}),f.jsx("span",{className:"col-author",title:s.pr.author,children:s.pr.author}),f.jsx("span",{className:"col-title",title:s.pr.title,children:s.pr.title}),s.pr.isDraft&&f.jsx("span",{className:"tag tag-draft",title:"Still a draft — its author asked for your review anyway",children:"draft"}),y&&f.jsx("span",{className:"tag tag-awaiting",title:y.title,children:y.label}),g&&f.jsx("span",{className:"tag tag-done",children:g}),f.jsx("span",{className:"col-comments",children:s.commentCount||""}),f.jsx("span",{className:"col-size",children:s.pr.changedFiles>0?f.jsxs(f.Fragment,{children:[s.pr.changedFiles,"f ",f.jsxs("span",{className:"add",children:["+",s.pr.additions]})," ",f.jsxs("span",{className:"del",children:["−",s.pr.deletions]})]}):"—"}),f.jsx("span",{className:`col-verdict tone-${d.tone}`,children:d.label}),f.jsx("span",{className:"col-age",children:Rb(s.updatedAt)})]})}function db({autoFocus:s}){const[i,r]=ie.useState(""),[l,u]=ie.useState(!1),[d,g]=ie.useState(null),y=m=>{m.preventDefault();const p=i.trim();!p||l||(u(!0),g(null),R_(p).then(b=>ao(b.key)).catch(b=>{g(String(b instanceof Error?b.message:b)),u(!1)}))};return f.jsxs("form",{className:"pull",onSubmit:y,children:[f.jsx(Ze,{name:"plus"}),f.jsx("input",{className:"pull-input",value:i,autoFocus:s,disabled:l,onChange:m=>{r(m.target.value),d&&g(null)},placeholder:"paste a PR URL to review it","aria-label":"Review any PR by URL"}),i.trim()&&f.jsx("button",{className:"btn btn-small",type:"submit",disabled:l,children:l?"starting…":"review"}),d&&f.jsx("span",{className:"pull-error",children:d})]})}function eN({text:s}){const i=ie.useRef(null),[r,l]=ie.useState(!1);return ie.useLayoutEffect(()=>{const u=i.current;if(!u)return;const d=()=>l(u.scrollHeight-u.clientHeight>4);d();const g=new ResizeObserver(d);return g.observe(u),()=>g.disconnect()},[s]),f.jsx("div",{ref:i,className:`strip-prose${r?" strip-prose-clipped":""}`,children:f.jsx(Wi,{className:"prose",text:s})})}function tN(){var Q;const[s,i]=ie.useState(null),[r,l]=ie.useState(null),[u,d]=ie.useState(null),[g,y]=ie.useState(new Set),[m,p]=ie.useState("inbox"),[b,v]=ie.useState(null),_=ie.useRef(!0);ie.useEffect(()=>(_.current=!0,()=>{_.current=!1}),[]);const x=()=>{If().then(K=>_.current&&i(K)).catch(K=>_.current&&d(String(K))),Ob().then(K=>_.current&&l(K)).catch(()=>{})};ie.useEffect(()=>{x();const K=setInterval(x,1e4);return()=>clearInterval(K)},[]);const N=s??[],A=ie.useMemo(()=>s_(N,r),[N,r]),w=A.requests,U=r_(m,A),G=A[U];ie.useEffect(()=>{U!==m&&p(U)},[U,m]);const L=G.find(K=>K.key===b)??G[0]??null,re=K=>{y(fe=>new Set(fe).add(K)),kb(K).then(()=>x()).catch(fe=>_.current&&d(String(fe))).finally(()=>{_.current&&y(fe=>{const we=new Set(fe);return we.delete(K),we})})};if(ie.useEffect(()=>{const K=fe=>{if(VT(fe.target)||fe.metaKey||fe.ctrlKey||fe.altKey)return;const we=G.findIndex(he=>he.key===(L==null?void 0:L.key)),Ae=he=>{const I=G[Math.min(G.length-1,Math.max(0,we+he))];I&&v(I.key)};fe.key==="j"||fe.key==="ArrowDown"?(fe.preventDefault(),Ae(1)):fe.key==="k"||fe.key==="ArrowUp"?(fe.preventDefault(),Ae(-1)):fe.key==="Enter"&&L?ao(L.key):fe.key==="r"&&L&&L.status!=="sent"&&L.status!=="running"&&re(L.key)};return window.addEventListener("keydown",K),()=>window.removeEventListener("keydown",K)},[G,L==null?void 0:L.key]),u&&!s)return f.jsx("p",{className:"error",children:u});if(!s)return f.jsx("p",{className:"muted pad",children:"Loading…"});const H=G.reduce((K,fe)=>K+(fe.costUsd??0),0),P=L?v_(L,r):null,le=L?g.has(L.key):!1,k=_f.filter(K=>A[K].length>0),z=K=>f.jsxs("button",{className:`tab${U===K?" tab-on":""}${_f.includes(K)?" tab-filed":""}${K==="requests"?" tab-requests":""}`,title:xu[K].title,onClick:()=>p(K),children:[xu[K].label," ",f.jsx("span",{className:"tab-count",children:A[K].length})]},K);return f.jsxs(f.Fragment,{children:[f.jsx(JT,{daemon:r}),u&&f.jsx("div",{className:"wrap",children:f.jsx("p",{className:"error",children:u})}),N.length===0?f.jsx("div",{className:"wrap",children:f.jsxs("div",{className:"empty",children:[r!=null&&r.enabled?f.jsxs("p",{children:["Inbox empty — nothing awaits your review.",r.lastPollError?" (Discovery is offline; check gh auth.)":""]}):f.jsx("p",{children:"No reviews yet."}),f.jsx(db,{autoFocus:!0})]})}):f.jsxs("div",{className:"wrap",children:[f.jsxs("div",{className:"tabs",children:[n_.map(z),k.length>0&&f.jsx("span",{className:"tab-split","aria-hidden":"true"}),k.map(z),f.jsx("span",{className:"grow"}),f.jsx(db,{}),H>0&&f.jsxs("span",{className:"faint",title:"What these reviews would cost at API token rates. Riding a Claude subscription, they draw on your usage limits instead.",children:["≈$",H.toFixed(2)," at API rates"]})]}),f.jsxs("div",{className:"table-head",children:[f.jsx("span",{className:"col-caret"}),f.jsx("span",{className:"col-slug",children:"repo#pr"}),f.jsx("span",{className:"col-author",children:"author"}),f.jsx("span",{className:"col-title",children:"title"}),f.jsx("span",{className:"col-comments",children:"c"}),f.jsx("span",{className:"col-size",children:"size"}),f.jsx("span",{className:"col-verdict",children:"verdict"}),f.jsx("span",{className:"col-age",children:"upd"})]}),G.length===0?f.jsx("div",{className:"empty-tab",children:U==="inbox"&&w.length>0?f.jsxs(f.Fragment,{children:[f.jsx("p",{children:m_(w)}),f.jsxs("button",{className:"btn",onClick:()=>p("requests"),children:[f.jsx(Ze,{name:"arrowRight"}),"show ",w.length===1?"it":`all ${w.length}`]})]}):f.jsx("p",{className:"muted",children:U==="inbox"?`Inbox empty — nothing awaits your review.${r!=null&&r.enabled&&r.lastPollError?" (Discovery is offline; check gh auth.)":""}`:`Nothing ${xu[U].label} right now.`})}):G.map(K=>f.jsx(WT,{r:K,selected:K.key===(L==null?void 0:L.key),onSelect:()=>v(K.key),onOpen:()=>ao(K.key),awaiting:l_(K,w)?c_(K,r):null},K.key)),L&&P&&f.jsxs("div",{className:"cursor-strip",children:[f.jsxs("div",{className:"cursor-strip-body",children:[f.jsxs("div",{className:"lab",children:[L.pr.owner,"/",L.pr.repo,"#",L.pr.number,L.pr.headRefName&&L.pr.baseRefName?` · ${L.pr.headRefName} → ${L.pr.baseRefName}`:""]}),f.jsx(eN,{text:P.reasoning}),f.jsxs("div",{className:"cursor-strip-meta",children:[P.meta.join(" · "),((Q=L.sent)==null?void 0:Q.url)&&f.jsxs(f.Fragment,{children:[" · ",f.jsx("a",{href:L.sent.url,target:"_blank",rel:"noreferrer",children:"view on GitHub"})]})]})]}),f.jsxs("div",{className:"cursor-strip-actions",children:[f.jsxs("button",{className:"btn-primary",onClick:()=>ao(L.key),children:[f.jsx(Ze,{name:"arrowRight"}),"open review ",f.jsx(Va,{children:"↵"})]}),f.jsxs("div",{className:"cursor-strip-row",children:[f.jsxs("button",{className:"btn",disabled:L.status==="sent"||L.status==="skipped",title:"Set the local queue status to skipped. Nothing is sent.",onClick:()=>no(L.key,{status:"skipped"}).then(x).catch(K=>d(String(K))),children:[f.jsx(Ze,{name:"skip"}),"skip"]}),f.jsxs("button",{className:"btn",disabled:le||L.status==="running"||L.status==="sent",title:L.status==="sent"?"This review was sent — it is a record now.":"Draft a review of the PR's current head.",onClick:()=>re(L.key),children:[f.jsx(Ze,{name:"rerun"}),le?"starting…":L.status==="awaiting"||L.status==="failed"?"review now":"re-review"]})]})]})]})]})]})}const nN=[{rule:"@acme/*",meaning:"anyone in the acme org"},{rule:"@acme/devs",meaning:"anyone on that GitHub team"},{rule:"@teammate",meaning:"one person, by login"},{rule:"!@acme/contractors",meaning:"deny — beats every rule above"}],hb=s=>s instanceof Error?s.message:String(s),aN={unsupported:"this browser has no notifications to give",blocked:"this browser has blocked notifications for cerber",off:"tell me when a PR lands in the queue",ask:"tell me when a PR lands in the queue (the browser will ask first)",on:"tell me when a PR lands in the queue"};function iN({daemonAnnounces:s}){const[i,r]=Jb(),[l,u]=ie.useState(null),[d,g]=ie.useState(""),[y,m]=ie.useState(null),[p,b]=ie.useState(!1);ie.useEffect(()=>{z_().then(u).catch(_=>m(hb(_)))},[]);const v=_=>{b(!0),m(null),_.then(u).catch(x=>m(hb(x))).finally(()=>b(!1))};return y&&!l?f.jsx("p",{className:"error",children:y}):l?f.jsxs("div",{className:"settings",children:[f.jsx("a",{href:"#/",className:"back",children:"← queue"}),f.jsx("h1",{children:"Inbox"}),f.jsx("p",{children:"By default cerber keeps the queue itself: it polls GitHub for PRs awaiting your review and drafts a review for each, so they're ready when you open the cockpit. Nothing is ever sent without you. Changes apply on the next poll — no restart."}),f.jsxs("label",{className:"inbox-toggle",children:[f.jsx("input",{type:"checkbox",checked:l.daemon.poll,disabled:p,onChange:_=>v(Ru({poll:_.target.checked}))})," ","poll GitHub for PRs awaiting my review (every ",l.daemon.intervalMinutes,"m)"]}),f.jsxs("label",{className:"inbox-toggle",children:[f.jsx("input",{type:"checkbox",checked:l.daemon.autoReview,disabled:p||!l.daemon.poll,onChange:_=>v(Ru({autoReview:_.target.checked}))})," ","draft a review automatically (off: awaiting PRs wait for a click)"]}),f.jsxs("p",{className:"muted",children:["Interval, concurrency and a repo filter live in ",f.jsx("code",{children:l.path})," under"," ",f.jsx("code",{children:"daemon"})," — hand-editable, like everything here."]}),f.jsx("h1",{children:"Notifications"}),f.jsx("p",{children:"When a PR lands in the queue, cerber says so: one desktop notification per poll. A single arrival names the PR; several landing at once fold into one notification. A PR that has already been announced never arrives twice."}),f.jsxs("label",{className:"inbox-toggle",children:[f.jsx("input",{type:"checkbox",checked:l.daemon.notify,disabled:p||!l.daemon.poll,onChange:_=>v(Ru({notify:_.target.checked}))})," ","tell this machine when a PR lands (no cockpit tab required)"]}),f.jsxs("p",{className:"muted",children:["This is the one that works while you're somewhere else: it rides the poll that finds the PR, so it needs nothing open but ",f.jsx("code",{children:"serve"})," itself. It hands the notification to whatever this machine already has — Notification Centre on macOS,"," ",f.jsx("code",{children:"notify-send"})," on Linux — and stays quiet where there is neither. On macOS it"," ",f.jsx("strong",{children:"opens that review when you click it"}),', through a small app cerber builds for the job (the first one asks you to allow notifications from "Cerber"); a'," ",f.jsx("code",{children:"notify-send"})," tap announces the PR but has no click to give."]}),f.jsxs("label",{className:"inbox-toggle",children:[f.jsx("input",{type:"checkbox",checked:i==="on",disabled:i==="unsupported"||i==="blocked"||s,onChange:r})," ",s?"let this browser announce them instead":aN[i]]}),f.jsx("p",{className:"muted",children:s?f.jsx(f.Fragment,{children:"Standing down while the machine's own notification is on — two popups for one PR is one too many. Untick the machine one above and this browser takes over: it names the PR and opens that review on click just the same, but only while a cockpit tab is open and this browser has granted the permission."}):f.jsxs(f.Fragment,{children:["Per-browser, not per-machine: the permission belongs to this browser, so the switch lives here rather than in ",f.jsx("code",{children:l.path}),". Granting it is the browser's prompt, not cerber's — a browser that has blocked cerber has to be un-blocked from the padlock next to the address bar. Nothing is announced while you're looking straight at the cockpit."]})}),f.jsx("h1",{children:"Trusted PRs"}),f.jsxs("p",{children:["Every review reads the PR's code. A ",f.jsx("strong",{children:"trusted"})," review may also run it — the test that covers the change, a typecheck, ",f.jsx("code",{children:"git log"})," — so it can report what happened instead of guessing. That means executing code from the PR on this machine, the same as checking the branch out and running the tests yourself."]}),f.jsxs("p",{className:"muted",children:["Trust the people, not the code: nothing about a PR can earn it. Rules live in"," ",f.jsx("code",{children:l.path}),"."]}),f.jsx("p",{className:"muted",children:'Only people can be trusted — there is no way to trust a repository. Anyone can open a PR against a repo you own, so trusting the repo would trust them too. Membership is checked against GitHub when the review runs, and a check that fails counts as "not a member".'}),f.jsxs("div",{className:"trust-add",children:[f.jsx("input",{value:d,"aria-label":"Trust rule",placeholder:"@org/*, @org/team, @login, !deny",disabled:p,onChange:_=>g(_.target.value),onKeyDown:_=>{_.key==="Enter"&&!p&&d.trim()&&(v(Au(d.trim())),g(""))}}),f.jsx("button",{className:"btn",disabled:p||!d.trim(),onClick:()=>{v(Au(d.trim())),g("")},children:"trust"})]}),y&&f.jsx("p",{className:"error",children:y}),l.trust.length===0?f.jsx("p",{className:"muted",children:"Nobody is trusted yet — every review reads the checkout and runs nothing."}):f.jsx("table",{className:"trust-table",children:f.jsx("tbody",{children:l.trust.map(_=>f.jsxs("tr",{className:_.denies?"trust-deny":void 0,children:[f.jsx("td",{children:f.jsx("code",{children:_.rule})}),f.jsx("td",{className:"muted",children:_.explanation}),f.jsx("td",{className:"trust-actions",children:f.jsx("button",{className:"btn",disabled:p,onClick:()=>v(Au(_.rule,!0)),children:"remove"})})]},_.rule))})}),f.jsx("h2",{children:"Rule syntax"}),f.jsx("table",{className:"trust-table",children:f.jsx("tbody",{children:nN.map(_=>f.jsxs("tr",{children:[f.jsx("td",{children:f.jsx("code",{children:_.rule})}),f.jsx("td",{className:"muted",children:_.meaning})]},_.rule))})}),f.jsxs("p",{className:"muted",children:["A trusted review is handed no GitHub credentials — no token, no ",f.jsx("code",{children:"gh"})," login, no git or ssh identity — so a push fails to authenticate rather than being merely discouraged. It is not a sandbox though: it has ",f.jsx("code",{children:"Bash"})," and this machine, so trust people you would let run their branch here anyway. The daemon reviews unattended, so with rules set it runs matching PRs' code with nobody watching — start it with ",f.jsx("code",{children:"--no-trust"})," to prevent that."]})]}):f.jsx("p",{className:"muted",children:"Loading…"})}function pb(){const s=window.location.hash,i=s.match(/^#\/r\/(.+)$/);return i?{view:"detail",key:decodeURIComponent(i[1])}:s==="#/settings"?{view:"settings"}:{view:"queue"}}const mb={unsupported:"",blocked:"This browser blocked notifications for cerber. Turn them back on in its site settings — the padlock next to the address bar.",off:"Notifications off. Click to be told when a PR lands in the queue.",ask:"Click to be told when a PR lands in the queue — the browser will ask you first.",on:"Telling you when a PR lands in the queue. Click to stop."},gb="cerber taps this machine itself when a PR lands — no tab required. The browser's own bell would be a second popup about one PR, so it stands down. Turn the machine one off in Settings to have this browser announce arrivals instead.";function lN({daemonAnnounces:s}){const[i,r]=Jb();return s?f.jsx("span",{className:"topbar-link bell bell-daemon",role:"img",title:gb,"aria-label":gb,children:f.jsx(Ze,{name:"bell",size:15})}):i==="unsupported"?null:f.jsx("button",{className:`topbar-link bell bell-${i}`,title:mb[i],"aria-label":mb[i],"aria-pressed":i==="on",onClick:r,children:f.jsx(Ze,{name:i==="on"?"bell":"bellOff",size:15})})}function sN(){const[s,i]=ie.useState(pb()),r=QT();return ie.useEffect(()=>{const l=()=>i(pb());return window.addEventListener("hashchange",l),()=>window.removeEventListener("hashchange",l)},[]),s.view==="detail"?f.jsx("div",{className:"app",children:f.jsx(qT,{reviewKey:s.key})}):f.jsxs("div",{className:"app",children:[f.jsx("header",{className:"topbar",children:f.jsxs("div",{className:"bar-inner",children:[f.jsxs("a",{href:"#/",className:"brand",children:[f.jsx(z0,{}),"cerber"]}),f.jsx("span",{className:"tagline",children:"nothing reaches GitHub until you say so"}),f.jsx("span",{className:"grow"}),s.view==="queue"&&f.jsxs(f.Fragment,{children:[f.jsxs("span",{className:"hint",children:[f.jsx(Va,{children:"j"}),f.jsx(Va,{children:"k"}),"move"]}),f.jsxs("span",{className:"hint",children:[f.jsx(Va,{children:"↵"}),"open"]}),f.jsxs("span",{className:"hint",children:[f.jsx(Va,{children:"r"}),"review now"]})]}),f.jsx(lN,{daemonAnnounces:r}),f.jsx("a",{href:"#/settings",className:"topbar-link",title:"settings","aria-label":"settings",children:f.jsx(Ze,{name:"settings",size:15})})]})}),s.view==="queue"?f.jsx(tN,{}):f.jsx(iN,{daemonAnnounces:r})]})}dE.createRoot(document.getElementById("root")).render(f.jsx(sE.StrictMode,{children:f.jsx(sN,{})}));
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>cerber</title>
8
- <script type="module" crossorigin src="/assets/index-DNaKDgSy.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-D0STDydg.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-C9jlO49c.css">
10
10
  </head>
11
11
  <body>