@agentsdance/codejury 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/index.html ADDED
@@ -0,0 +1,1699 @@
1
+ <!doctype html>
2
+ <title>Code Jury — Convergence Console</title>
3
+ <style>
4
+ :root {
5
+ --ground:#F4F6F8; --surface:#FFFFFF; --surface-alt:#EDF0F4;
6
+ --line:#D7DDE4; --line-soft:#E6EAEF; --ink:#14181D; --ink-2:#39434F; --muted:#5A6572;
7
+ --accent:#3A6B8A; --accent-soft:#E2ECF3; --main-hue:#6A4E8C; --main-soft:#EDE6F4;
8
+ --codex:#3A6B8A; --droid:#1F7A6B; --droid-soft:#DDF0EC; --agy:#4E7BD6;
9
+ --ok:#2E7D5B; --ok-soft:#E0F0E8; --warn:#B07A2B; --warn-soft:#F6ECD9;
10
+ --bad:#A8443A; --bad-soft:#F6E3E0;
11
+ --shadow:0 1px 2px rgba(20,24,29,.05), 0 8px 24px -14px rgba(20,24,29,.20);
12
+ --serif: ui-serif,"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;
13
+ --sans: ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
14
+ --mono: ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
15
+ }
16
+ @media (prefers-color-scheme: dark) {
17
+ :root:not([data-theme="light"]) {
18
+ --ground:#0E1216; --surface:#161C23; --surface-alt:#1C232B;
19
+ --line:#2B343E; --line-soft:#222A33; --ink:#E4E9EE; --ink-2:#B4BEC9; --muted:#8593A2;
20
+ --accent:#7FB0D0; --accent-soft:#1A2B38; --main-hue:#B49AD4; --main-soft:#241C31;
21
+ --codex:#7FB0D0; --droid:#63C4B0; --droid-soft:#142C28; --agy:#8FB4F5;
22
+ --ok:#6FC49B; --ok-soft:#15271E; --warn:#D9A857; --warn-soft:#292013;
23
+ --bad:#E08076; --bad-soft:#2B1816;
24
+ --shadow:0 1px 2px rgba(0,0,0,.45), 0 10px 28px -16px rgba(0,0,0,.75);
25
+ }
26
+ }
27
+ :root[data-theme="dark"] {
28
+ --ground:#0E1216; --surface:#161C23; --surface-alt:#1C232B;
29
+ --line:#2B343E; --line-soft:#222A33; --ink:#E4E9EE; --ink-2:#B4BEC9; --muted:#8593A2;
30
+ --accent:#7FB0D0; --accent-soft:#1A2B38; --main-hue:#B49AD4; --main-soft:#241C31;
31
+ --codex:#7FB0D0; --droid:#63C4B0; --droid-soft:#142C28; --agy:#8FB4F5;
32
+ --ok:#6FC49B; --ok-soft:#15271E; --warn:#D9A857; --warn-soft:#292013;
33
+ --bad:#E08076; --bad-soft:#2B1816;
34
+ --shadow:0 1px 2px rgba(0,0,0,.45), 0 10px 28px -16px rgba(0,0,0,.75);
35
+ }
36
+
37
+ * { box-sizing:border-box; }
38
+ body { margin:0; background:var(--ground); color:var(--ink); font-family:var(--sans);
39
+ font-size:15px; line-height:1.55; -webkit-font-smoothing:antialiased; }
40
+ .wrap { max-width:1060px; margin:0 auto; padding:34px 22px 80px; display:flex; flex-direction:column; gap:38px; }
41
+
42
+ /* header */
43
+ .hd { display:flex; flex-direction:column; gap:14px; }
44
+ .kicker { font-family:var(--mono); font-size:10.5px; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); }
45
+ h1 { font-family:var(--serif); font-size:clamp(26px,3.6vw,38px); font-weight:600; margin:0;
46
+ line-height:1.12; letter-spacing:-.015em; text-wrap:balance; }
47
+ .meta { display:flex; flex-wrap:wrap; gap:9px 20px; align-items:center;
48
+ font-family:var(--mono); font-size:12.5px; color:var(--muted); }
49
+ .meta a { color:var(--accent); text-decoration:none; border-bottom:1px solid color-mix(in srgb,var(--accent) 40%,transparent); }
50
+ .meta a:hover { border-bottom-color:var(--accent); }
51
+ .meta a:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
52
+ .badge-ok { display:inline-flex; align-items:center; gap:7px; color:var(--ok); background:var(--ok-soft);
53
+ border:1px solid color-mix(in srgb,var(--ok) 34%,transparent); padding:4px 12px; border-radius:999px;
54
+ font-size:11px; letter-spacing:.07em; text-transform:uppercase; }
55
+ .badge-ok .d { width:7px; height:7px; border-radius:50%; background:currentColor; }
56
+ .badge-warn { display:inline-flex; align-items:center; gap:7px; color:var(--warn); background:var(--warn-soft);
57
+ border:1px solid color-mix(in srgb,var(--warn) 34%,transparent); padding:4px 12px; border-radius:999px;
58
+ font-size:11px; letter-spacing:.07em; text-transform:uppercase; }
59
+ .badge-mute { display:inline-flex; align-items:center; gap:7px; color:var(--muted); background:var(--surface-alt);
60
+ border:1px solid var(--line); padding:4px 12px; border-radius:999px;
61
+ font-size:11px; letter-spacing:.07em; text-transform:uppercase; }
62
+ .badge-warn .d,.badge-mute .d { width:7px; height:7px; border-radius:50%; background:currentColor; }
63
+ /* One control, not a chip per run. A row of chips is fine for five runs and
64
+ a wall for a hundred: it wrapped to two lines at seven, and the page header
65
+ stopped being a header. */
66
+ .switcher { position:relative; display:inline-block; }
67
+ .switcher:empty { display:none; }
68
+ .sw-btn {
69
+ display:inline-flex; align-items:center; gap:8px; max-width:min(560px, 90vw);
70
+ font:500 12px/1 var(--mono); color:var(--ink); background:var(--surface);
71
+ border:1px solid var(--line); border-radius:8px; padding:8px 11px; cursor:pointer;
72
+ }
73
+ .sw-btn:hover { border-color:var(--accent); }
74
+ .sw-btn .nm { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
75
+ .sw-btn .cnt { color:var(--muted); font-size:11px; padding-left:8px;
76
+ margin-left:2px; border-left:1px solid var(--line-soft); }
77
+ .sw-btn .car { color:var(--muted); font-size:9px; margin-left:auto; }
78
+ .sw-pop {
79
+ position:absolute; top:calc(100% + 6px); left:0; z-index:40;
80
+ width:min(460px, 92vw); background:var(--surface); border:1px solid var(--line);
81
+ border-radius:10px; box-shadow:var(--shadow); overflow:hidden;
82
+ }
83
+ .sw-pop[hidden] { display:none; }
84
+ .sw-pop input {
85
+ width:100%; box-sizing:border-box; border:0; border-bottom:1px solid var(--line-soft);
86
+ background:transparent; color:var(--ink); font:400 13px/1 var(--sans); padding:11px 13px;
87
+ }
88
+ .sw-pop input:focus { outline:none; background:var(--surface-alt); }
89
+ .sw-list { max-height:min(52vh, 420px); overflow-y:auto; padding:5px; }
90
+ .sw-h {
91
+ font:600 10px/1 var(--sans); letter-spacing:.08em; text-transform:uppercase;
92
+ color:var(--muted); padding:9px 9px 5px;
93
+ }
94
+ .sw-i {
95
+ display:flex; align-items:center; gap:9px; width:100%; text-align:left;
96
+ background:none; border:0; border-radius:7px; padding:7px 9px; cursor:pointer;
97
+ font:400 12px/1.35 var(--sans); color:var(--ink);
98
+ }
99
+ .sw-i:hover, .sw-i[data-on="1"] { background:var(--surface-alt); }
100
+ .sw-i[aria-pressed="true"] { background:var(--accent-soft); }
101
+ .sw-i .d { width:7px; height:7px; border-radius:50%; flex:none; }
102
+ .sw-i .id { font-family:var(--mono); font-size:11.5px; flex:none; }
103
+ .sw-i .at { font:400 10.5px/1 var(--mono); color:var(--accent); flex:none; }
104
+ .sw-i .ti { color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
105
+ .sw-i .rt { margin-left:auto; flex:none; font:400 10.5px/1 var(--mono); color:var(--muted); }
106
+ .sw-none { padding:18px 13px; text-align:center; color:var(--muted); font:400 12px/1 var(--sans); }
107
+ .pr-chip { font-family:var(--mono); font-size:11.5px; padding:5px 11px; border-radius:3px; cursor:pointer;
108
+ border:1px solid var(--line); background:var(--surface); color:var(--muted);
109
+ display:inline-flex; align-items:center; gap:7px; }
110
+ .pr-chip:hover { border-color:var(--accent); color:var(--accent); }
111
+ .pr-chip:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
112
+ .pr-chip[aria-pressed="true"] { border-color:var(--accent); color:var(--accent); background:var(--accent-soft); }
113
+ .pr-chip .d { width:7px; height:7px; border-radius:50%; }
114
+ /* Repo qualifier, shown only when more than one repo is on screen. Dimmed so
115
+ the PR number stays the thing you scan for. */
116
+ .pr-chip .repo { opacity:.65; margin-right:4px; }
117
+
118
+ h2 { font-family:var(--serif); font-size:22px; font-weight:600; margin:0; letter-spacing:-.01em; }
119
+ section { display:flex; flex-direction:column; gap:14px; }
120
+ .note { margin:0; color:var(--muted); font-size:13.5px; max-width:66ch; }
121
+
122
+ /* timeline */
123
+ .tl { background:var(--surface); border:1px solid var(--line); border-radius:4px; box-shadow:var(--shadow);
124
+ padding:18px 20px 14px; display:flex; flex-direction:column; gap:12px; overflow-x:auto; }
125
+ .tl-inner { min-width:660px; display:flex; flex-direction:column; gap:10px; }
126
+ .tl-rounds { position:relative; height:20px; }
127
+ /* An abandoned span is drawn hollow: it happened, but it produced nothing,
128
+ and filling it the same as a completed round overstates the run. */
129
+ .seg[data-abandoned="1"] { opacity:.45; background:repeating-linear-gradient(
130
+ 45deg, var(--line) 0 4px, transparent 4px 8px); color:var(--muted); }
131
+ .tl-rounds span { position:absolute; top:0; font-family:var(--mono); font-size:10.5px;
132
+ letter-spacing:.1em; text-transform:uppercase; color:var(--muted);
133
+ border-left:1px solid var(--line); padding-left:7px; }
134
+ .lane { display:grid; grid-template-columns:74px 1fr; gap:12px; align-items:center; }
135
+ .lane .who { font-family:var(--mono); font-size:12px; display:flex; align-items:center; gap:7px; }
136
+ .lane .who .sw { width:9px; height:9px; border-radius:2px; }
137
+ .track { position:relative; height:26px; background:var(--surface-alt); border-radius:3px; }
138
+ /* min-width so a span that is real but very short — or has not finished and
139
+ therefore has no width at all — still paints something hoverable. */
140
+ .seg { position:absolute; top:0; height:26px; border-radius:3px; display:flex; align-items:center;
141
+ min-width:4px; padding:0 7px; font-family:var(--mono); font-size:10.5px; color:#fff;
142
+ white-space:nowrap; overflow:hidden; }
143
+ /* Still running: striped rather than solid, and never flush to the right edge. */
144
+ .seg[data-open="1"] { min-width:10px; margin-left:-5px;
145
+ background-image:repeating-linear-gradient(115deg,rgba(255,255,255,.30) 0 4px,transparent 4px 8px); }
146
+ .seg[data-a="codex"] { background:var(--codex); }
147
+ .seg[data-a="droid"] { background:var(--droid); }
148
+ .seg[data-a="main"] { background:var(--main-hue); }
149
+ .tl-foot { font-family:var(--mono); font-size:11.5px; color:var(--muted); }
150
+
151
+ /* rounds */
152
+ .rgrid { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:12px; }
153
+ .rcard { background:var(--surface); border:1px solid var(--line); border-radius:4px; box-shadow:var(--shadow);
154
+ overflow:hidden; display:flex; flex-direction:column; }
155
+ .rcard header { padding:11px 15px; background:var(--surface-alt); border-bottom:1px solid var(--line-soft);
156
+ display:flex; align-items:baseline; gap:10px; }
157
+ .rcard header b { font-family:var(--mono); font-size:13px; }
158
+ .rcard header .sha { font-family:var(--mono); font-size:11.5px; color:var(--muted); margin-left:auto; }
159
+ .verd { display:flex; gap:10px; padding:11px 15px; border-top:1px solid var(--line-soft); align-items:flex-start; }
160
+ .verd:first-of-type { border-top:0; }
161
+ .verd .ag { font-family:var(--mono); font-size:11.5px; display:flex; align-items:center; gap:6px; min-width:76px; }
162
+ .verd .ag .sw { width:8px; height:8px; border-radius:2px; }
163
+ .verd .txt { font-size:13.2px; color:var(--ink-2); }
164
+ .verd .txt b { color:var(--ink); }
165
+ .verd[data-s="clean"] .txt b { color:var(--ok); }
166
+ .verd[data-s="found"] .txt b { color:var(--warn); }
167
+
168
+ /* exchanges */
169
+ .filters { display:flex; gap:7px; flex-wrap:wrap; }
170
+ .chip { font-family:var(--mono); font-size:11px; letter-spacing:.06em; text-transform:uppercase;
171
+ padding:5px 11px; border-radius:999px; border:1px solid var(--line); background:var(--surface);
172
+ color:var(--muted); cursor:pointer; }
173
+ .chip:hover { border-color:var(--accent); color:var(--accent); }
174
+ .chip:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
175
+ .chip[aria-pressed="true"] { background:var(--accent); border-color:var(--accent); color:var(--surface); }
176
+ :root[data-theme="dark"] .chip[aria-pressed="true"] { color:#0E1216; }
177
+ @media (prefers-color-scheme: dark){ :root:not([data-theme="light"]) .chip[aria-pressed="true"]{ color:#0E1216; } }
178
+
179
+ /* exchange — a terminal transcript. Deliberately dark in both themes: this is
180
+ agent output, and it reads as a session rather than a document. */
181
+ .term, .io { --t-bg:#0F1419; --t-fg:#C6CFD8; --t-dim:#6B7885; --t-line:#212A33;
182
+ --t-codex:#7FB0D0; --t-droid:#63C4B0; --t-claude:#B49AD4;
183
+ --t-ok:#6FC49B; --t-warn:#D9A857; --t-bad:#E08076;
184
+ background:var(--t-bg); border:1px solid var(--t-line); border-radius:6px;
185
+ overflow:hidden; box-shadow:var(--shadow); font-family:var(--mono); }
186
+ .term-bar { display:flex; align-items:center; gap:8px; padding:9px 13px;
187
+ background:#0A0E12; border-bottom:1px solid var(--t-line); }
188
+ .term-bar .dot { width:10px; height:10px; border-radius:50%; }
189
+ .term-bar .dot.r { background:#E0655B; } .term-bar .dot.y { background:#D9A857; }
190
+ .term-bar .dot.g { background:#59B87F; }
191
+ .term-bar .t { margin-left:8px; font-size:11.5px; color:var(--t-dim); }
192
+ .term-body { padding:14px 16px 16px; font-size:12.8px; line-height:1.62; color:var(--t-fg);
193
+ overflow-x:auto; }
194
+ .term .cwd { color:var(--t-droid); }
195
+ .term .branch { color:var(--t-claude); }
196
+ .term .cmdline { margin:0 0 12px; white-space:pre-wrap; word-break:break-all; }
197
+ .term .cmdline .p { color:var(--t-ok); margin-right:7px; }
198
+ .term .cmdline .c { color:var(--t-fg); }
199
+
200
+ .xb { border-top:1px solid var(--t-line); padding:13px 0 4px; }
201
+ .xb:first-of-type { border-top:0; padding-top:2px; }
202
+ .xb-head { display:flex; gap:10px; align-items:baseline; flex-wrap:wrap; margin-bottom:9px; }
203
+ .xb-head .claim { color:#E7EDF3; font-weight:600; flex:1 1 300px; }
204
+ .xb-head .meta { color:var(--t-dim); font-size:11.5px; width:100%; }
205
+ .tag { font-size:10.5px; letter-spacing:.09em; text-transform:uppercase; padding:2px 8px;
206
+ border-radius:3px; white-space:nowrap; }
207
+ .tag[data-r="accepted"] { color:var(--t-ok); background:rgba(111,196,155,.13); }
208
+ .tag[data-r="deferred"] { color:var(--t-warn); background:rgba(217,168,87,.13); }
209
+ .tag[data-r="rejected"] { color:var(--t-bad); background:rgba(224,128,118,.13); }
210
+ .tag[data-r="open"] { color:var(--t-codex); background:rgba(127,176,208,.13); }
211
+
212
+ .ln { display:grid; grid-template-columns:82px 78px minmax(0,1fr); gap:10px; padding:5px 0;
213
+ align-items:start; }
214
+ .ln .at { color:var(--t-dim); font-size:11.5px; line-height:1.35; }
215
+ .ln .at b { display:block; font-weight:500; color:#8A99A8; }
216
+ .ln .at i { display:block; font-style:normal; font-size:10.5px; opacity:.72; }
217
+ .ln .nm { display:inline-flex; align-items:center; gap:6px; font-weight:600; }
218
+ .ln .nm svg { width:12px; height:12px; }
219
+ .ln[data-who="codex"] .nm { color:var(--t-codex); }
220
+ .ln[data-who="droid"] .nm { color:var(--t-droid); }
221
+ .ln[data-who="claude"] .nm { color:var(--t-claude); }
222
+ .ln .say { white-space:pre-wrap; }
223
+ .ln .say strong { color:#EDF2F7; font-weight:600; }
224
+ @media (max-width:720px){ .ln{ grid-template-columns:1fr; gap:2px; } }
225
+
226
+ .term .q { margin:7px 0 3px 0; padding:8px 11px; background:#0A0E12; border-left:2px solid var(--t-line);
227
+ color:#9FB0BF; font-size:12px; white-space:pre; overflow-x:auto; }
228
+ .term .ask { margin:7px 0 3px; padding-left:10px; border-left:2px solid var(--t-codex); color:#DCE5EC; }
229
+ .term .out { margin:9px 0 2px; font-size:12.2px; }
230
+ .term .out[data-r="accepted"] { color:var(--t-ok); }
231
+ .term .out[data-r="deferred"] { color:var(--t-warn); }
232
+ .term .out[data-r="rejected"] { color:var(--t-bad); }
233
+ .term .out b { text-transform:uppercase; letter-spacing:.08em; font-size:11px; }
234
+
235
+ /* What went in and what came out, as one session. A clean verdict still has a
236
+ report, and hiding it was why a converged run looked like nothing happened. */
237
+ .io { background:var(--t-bg); border:1px solid var(--t-line); border-radius:6px;
238
+ overflow:hidden; box-shadow:var(--shadow); font-family:var(--mono); }
239
+ .io-body { padding:13px 16px 18px; font-size:12.6px; line-height:1.6; color:var(--t-fg);
240
+ max-height:640px; overflow:auto; }
241
+
242
+ /* One column per reviewer. They are side by side to make the independence
243
+ legible at a glance; below the breakpoint they stack, which is the same
244
+ claim in one dimension. */
245
+ /* Full width, one at a time — a report needs the room to be readable. */
246
+ .convo-grid { display:block; }
247
+ /* The selected tab has to be unmistakable. When it was only tinted, clicking
248
+ the already-selected first tab looked like a dead button rather than a
249
+ no-op — the state was there, it just was not legible enough. */
250
+ .convo-switch { display:flex; gap:6px; flex-wrap:wrap; margin:0 0 10px; }
251
+ .convo-switch .pr-chip { font-weight:600; }
252
+ /* Literal, not var(--t-bg): that variable is scoped to .term/.io, so on a
253
+ chip it resolves to nothing and left white text on a transparent
254
+ background — invisible, and only a screenshot showed it. */
255
+ .convo-switch .pr-chip[aria-pressed="true"] {
256
+ background:#0F1419; color:#fff; border-color:#0F1419;
257
+ box-shadow:inset 0 -2px 0 var(--accent); }
258
+ .convo-switch .pr-chip[aria-pressed="true"]:hover { color:#fff; }
259
+ .convo-switch .pr-chip[aria-pressed="false"] { opacity:.7; }
260
+ .convo-note { margin:0 0 10px; font-size:12.5px; color:var(--muted); }
261
+ .convo .io-body { max-height:none; }
262
+ /* A long unbroken report must scroll inside its own column rather than
263
+ widening the grid and pushing the other conversation off-screen. */
264
+ .convo .stream { white-space:pre-wrap; overflow-wrap:anywhere; }
265
+
266
+ /* Findings carry the same grouping as the transcripts above: one block per
267
+ reviewer, so a claim is always read next to who raised it. */
268
+ .xgroup { margin:0 0 6px; }
269
+ .xgroup + .xgroup { margin-top:18px; padding-top:4px; border-top:1px dashed var(--t-line); }
270
+ .xgroup > .rule { margin-bottom:8px; }
271
+ .io .cmdline { margin:0 0 4px; white-space:pre-wrap; word-break:break-all; }
272
+ .io .cmdline .p { color:var(--t-ok); margin-right:7px; }
273
+
274
+ /* A divider that carries its own metadata, the way a TUI labels a pane. */
275
+ .rule { display:flex; align-items:center; gap:10px; margin:20px 0 9px;
276
+ font-size:10.5px; letter-spacing:.09em; text-transform:uppercase; color:var(--t-dim); }
277
+ .rule:first-child { margin-top:4px; }
278
+ .rule i { flex:1 1 auto; height:1px; background:var(--t-line); }
279
+ .rule .lbl { display:inline-flex; align-items:center; gap:6px; font-weight:600; }
280
+ .rule .lbl svg { width:12px; height:12px; }
281
+ .rule[data-who="codex"] .lbl { color:var(--t-codex); }
282
+ .rule[data-who="droid"] .lbl { color:var(--t-droid); }
283
+ .rule[data-who="stdin"] .lbl { color:var(--t-claude); }
284
+ .rule .rt { color:var(--t-dim); white-space:nowrap; }
285
+ .rule .rt b { font-weight:600; }
286
+ .rule .rt b[data-s="clean"] { color:var(--t-ok); }
287
+ .rule .rt b[data-s="found"] { color:var(--t-warn); }
288
+ .rule .rt b[data-s="error"] { color:var(--t-bad); }
289
+
290
+ .io .stream { margin:0; white-space:pre-wrap; overflow-x:auto; color:var(--t-fg);
291
+ border-left:2px solid var(--t-line); padding:2px 0 2px 13px; }
292
+ .io .stream.raw { white-space:pre; }
293
+ .io .stream.stdin { color:#9FB0BF; }
294
+
295
+ /* Terminal-style toggles: they read as flags, not as buttons. */
296
+ .tabs { display:flex; gap:6px; margin:8px 0 0 13px; }
297
+ .tabs button { font-family:var(--mono); font-size:10.5px; letter-spacing:.05em; cursor:pointer;
298
+ padding:2px 8px; border-radius:2px; border:1px solid var(--t-line);
299
+ background:transparent; color:var(--t-dim); }
300
+ .tabs button:hover { border-color:var(--t-codex); color:var(--t-codex); }
301
+ .tabs button:focus-visible { outline:2px solid var(--t-codex); outline-offset:2px; }
302
+ .tabs button[aria-pressed="true"] { border-color:var(--t-ok); color:var(--t-ok); }
303
+ .io .fold { cursor:pointer; }
304
+ .io .fold:focus-visible { outline:2px solid var(--t-codex); outline-offset:2px; }
305
+
306
+ /* shipped */
307
+ .ship { display:grid; grid-template-columns:repeat(auto-fit,minmax(158px,1fr)); gap:1px;
308
+ background:var(--line-soft); border:1px solid var(--line); }
309
+ .ship div { background:var(--surface); padding:15px 17px; display:flex; flex-direction:column; gap:3px; }
310
+ .ship .n { font-family:var(--mono); font-size:24px; font-variant-numeric:tabular-nums; letter-spacing:-.02em; }
311
+ .ship .k { font-family:var(--mono); font-size:10px; letter-spacing:.11em; text-transform:uppercase; color:var(--muted); }
312
+ .ship .s { font-size:12.4px; color:var(--muted); }
313
+ footer { border-top:1px solid var(--line); padding-top:16px; color:var(--muted); font-size:12.6px; max-width:78ch; }
314
+ @media (prefers-reduced-motion: reduce){ *{ transition:none !important; } }
315
+
316
+ /* ── Conversation ───────────────────────────────────────────────────
317
+ Two speakers, two sides. claude always left, the reviewer always right,
318
+ so which side a bubble sits on tells you who is talking before you read
319
+ a word of it. One column per reviewer: codex and agy are independent
320
+ conversations and must never be shown merged, or their agreement stops
321
+ being evidence of anything. */
322
+ .convo-bar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin:0 0 14px; }
323
+ .convo-tabs { display:flex; gap:6px; flex-wrap:wrap; min-width:0; }
324
+ .convo-bar .spacer { flex:1 1 auto; }
325
+ /* Side by side stopped scaling at three: a fourth reviewer either overflowed
326
+ or squeezed every column below the width a report needs to be readable in.
327
+ One thread at full width is the default, and stacking is a deliberate
328
+ choice for comparing — never something the layout does to you because a
329
+ fifth agent was configured. */
330
+ .convo-grid { display:flex; flex-direction:column; gap:18px; }
331
+ /* A per-reviewer count on the tab, so an unread thread is visible without
332
+ opening it — the reason to run five reviewers is to notice the one that
333
+ found something. */
334
+ .convo-tabs .pr-chip .n {
335
+ font:600 10px/1 var(--mono); color:var(--muted);
336
+ padding:2px 5px; border-radius:20px; background:var(--surface-alt); margin-left:2px;
337
+ }
338
+ .convo-tabs .pr-chip[aria-pressed="true"] .n { background:rgba(255,255,255,.22); color:inherit; }
339
+ .convo-tabs .pr-chip .n[data-live="1"] { color:var(--warn); }
340
+ .stack-btn {
341
+ font:600 11px/1 var(--sans); color:var(--ink-2); background:var(--surface);
342
+ border:1px solid var(--line); border-radius:20px; padding:7px 12px; cursor:pointer;
343
+ }
344
+ .stack-btn[aria-pressed="true"] { background:var(--accent); border-color:var(--accent); color:#fff; }
345
+ /* Stacked: every thread visible, each collapsible, so one long conversation
346
+ does not bury the four below it. */
347
+ .convo > header { cursor:default; }
348
+ .convo-grid[data-stack="1"] .convo > header { cursor:pointer; }
349
+ .convo .tw { margin-right:2px; font-size:10px; color:var(--muted); display:none; }
350
+ .convo-grid[data-stack="1"] .convo .tw { display:inline; }
351
+ .convo[data-open="0"] .msgs { display:none; }
352
+ .convo {
353
+ border:1px solid var(--line); border-radius:12px; background:var(--surface);
354
+ box-shadow:var(--shadow); display:flex; flex-direction:column; min-width:0;
355
+ }
356
+ .convo > header {
357
+ display:flex; align-items:center; gap:8px; padding:10px 14px;
358
+ border-bottom:1px solid var(--line-soft); font:600 13px/1.3 var(--sans);
359
+ }
360
+ .convo > header .who { color:var(--ink); }
361
+ .convo > header .st { margin-left:auto; font:500 11px/1 var(--mono); color:var(--muted); }
362
+ .convo > header .d { width:8px; height:8px; border-radius:50%; background:var(--ok); flex:none; }
363
+ .convo > header .d[data-live="1"] { background:var(--warn); animation:blip 1.4s ease-in-out infinite; }
364
+ @keyframes blip { 0%,100%{opacity:1} 50%{opacity:.25} }
365
+ /* The thread is what this page exists to show, so it gets real height: tall
366
+ enough to read in, with a floor so a short conversation still reads as the
367
+ main surface rather than a strip under the timeline. */
368
+ .msgs { padding:14px 16px; display:flex; flex-direction:column; gap:10px;
369
+ overflow-y:auto; max-height:82vh; min-height:min(34em, 58vh); }
370
+ .convo-grid[data-stack="1"] .msgs { max-height:56vh; min-height:0; }
371
+ /* Wider than before: bubbles now carry the reviewer's report in full, and a
372
+ narrow column turns a report into a ribbon. */
373
+ .msg { max-width:min(88%, 940px); display:flex; flex-direction:column; gap:4px; min-width:0; }
374
+ /* Popped out: the thread and nothing else, filling the window. Same fold and
375
+ same live stream as the embedded view — only the frame around it differs. */
376
+ /* Descendant, not child: the page nests everything inside div.wrap, so a
377
+ `body > header` selector matches nothing at all. */
378
+ body[data-view="conversation"] .wrap > header,
379
+ body[data-view="conversation"] section:not(#convo-sec) { display:none; }
380
+ body[data-view="conversation"] #convo-sec > h2,
381
+ body[data-view="conversation"] #c-note { display:none; }
382
+ body[data-view="conversation"] #convo-pop { display:none; }
383
+ body[data-view="conversation"] { padding:0; }
384
+ body[data-view="conversation"] .wrap { max-width:none; width:100%; margin:0;
385
+ padding:0; height:100vh; display:flex; flex-direction:column; }
386
+ body[data-view="conversation"] #convo-sec { margin:0; padding:12px 16px 16px;
387
+ height:100vh; display:flex; flex-direction:column; gap:10px; }
388
+ body[data-view="conversation"] .convo-grid { flex:1; min-height:0; }
389
+ body[data-view="conversation"] .convo { height:100%; }
390
+ body[data-view="conversation"] .msgs { max-height:none; min-height:0; flex:1; }
391
+ .msg[data-side="l"] { align-self:flex-start; }
392
+ .msg[data-side="r"] { align-self:flex-end; }
393
+ .msg .nm { font:600 11px/1 var(--sans); color:var(--muted); padding:0 4px; }
394
+ .msg .nm time {
395
+ font:400 10px/1 var(--mono); color:var(--muted); opacity:.72; margin-left:6px;
396
+ cursor:help; letter-spacing:.02em;
397
+ }
398
+ .msg .nm time:hover { opacity:1; }
399
+ .msg[data-side="r"] .nm { text-align:right; }
400
+ .bub {
401
+ /* Named so a scrolling .tall inside can fade to its own bubble's colour,
402
+ which differs per side. */
403
+ --bub-bg:var(--surface-alt);
404
+ border:1px solid var(--line); border-radius:12px; padding:9px 12px;
405
+ font:400 13px/1.55 var(--sans); color:var(--ink); white-space:pre-wrap;
406
+ overflow-wrap:anywhere; background:var(--bub-bg);
407
+ }
408
+ .msg[data-side="l"] .bub { --bub-bg:var(--main-soft);
409
+ border-bottom-left-radius:4px; border-color:var(--main-hue); }
410
+ .msg[data-side="r"] .bub { border-bottom-right-radius:4px; }
411
+ .bub .hd { display:block; font:600 12px/1.4 var(--sans); margin-bottom:3px; }
412
+ .bub .loc { font:500 11px/1.4 var(--mono); color:var(--muted); }
413
+ /* Names the finding a verdict answers, now that findings are not
414
+ bubbles of their own. Quiet: the reviewer already said it in full. */
415
+ .bub .re { display:block; font:500 11px/1.45 var(--sans); color:var(--muted);
416
+ border-left:2px solid var(--line); padding-left:7px; margin-bottom:5px; }
417
+ .bub .re:last-child { margin-bottom:0; }
418
+ /* A re-argued claim. Deliberately quiet and textless: the words are in the
419
+ reply bubble above it, and this only marks that the argument continues. */
420
+ .bub[data-k="rebuttal"] { border-left:3px solid var(--warn); }
421
+ .bub[data-k="verdict"] { border-left:3px solid var(--accent); }
422
+ .bub[data-k="verdict"][data-v="accepted"] { border-left-color:var(--ok); background:var(--ok-soft); }
423
+ .bub[data-k="verdict"][data-v="rejected"] { border-left-color:var(--bad); background:var(--bad-soft); }
424
+ .bub[data-k="verdict"][data-v="deferred"] { border-left-color:var(--warn); background:var(--warn-soft); }
425
+ .bub[data-k="commit"] { font:500 12px/1.5 var(--mono); background:transparent; border-style:dashed; }
426
+ .bub[data-k="waiting"] {
427
+ font:500 12px/1.5 var(--sans); color:var(--muted);
428
+ background:transparent; border-style:dashed;
429
+ }
430
+ .bub[data-k="waiting"] .tick { font-family:var(--mono); color:var(--ink-2); }
431
+ .bub[data-k="waiting"] .q { color:var(--warn); }
432
+ .bub[data-k="waiting"]::after {
433
+ content:"●"; margin-left:6px; font-size:9px; color:var(--warn);
434
+ animation:blip 1.4s ease-in-out infinite;
435
+ }
436
+ .bub[data-k="streaming"] { font:400 12px/1.5 var(--mono); color:var(--ink-2); }
437
+ .bub[data-k="streaming"]::after {
438
+ content:"▍"; animation:blip 1s steps(2) infinite; color:var(--accent);
439
+ }
440
+ /* A long message keeps all of its text and gives up height instead. The
441
+ inset shadows are the affordance: they say "there is more above/below"
442
+ without a control to click. */
443
+ .bub .tall { max-height:26em; overflow-y:auto; overscroll-behavior:contain;
444
+ scrollbar-width:thin; border-radius:3px;
445
+ background:
446
+ linear-gradient(var(--bub-bg) 30%, transparent) top / 100% 1.2em no-repeat,
447
+ linear-gradient(transparent, var(--bub-bg) 70%) bottom / 100% 1.2em no-repeat,
448
+ radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.10), transparent) top / 100% .5em no-repeat,
449
+ radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.10), transparent) bottom / 100% .5em no-repeat;
450
+ background-attachment:local, local, scroll, scroll; }
451
+ .bub .tall:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
452
+ .convo[data-a="agy"] > header .who, .msg[data-side="r"] .nm { color:var(--muted); }
453
+ .convo-tabs .pr-chip .ic, .convo > header .ic, .msg .nm .ic {
454
+ display:inline-flex; align-items:center; vertical-align:-2px; margin-right:4px;
455
+ }
456
+ .msg .nm .ic svg { width:11px; height:11px; }
457
+ /* Inline-flex, not flex: a full-width flex row on the right side rendered as
458
+ an empty box beside the name. */
459
+ .msg[data-side="r"] .nm { display:inline-flex; align-items:center; align-self:flex-end; }
460
+ .msg[data-side="l"] .nm { display:inline-flex; align-items:center; align-self:flex-start; }
461
+ .convo > header .who { display:inline-flex; align-items:center; gap:2px; }
462
+ .convo > header .sw { margin:0 4px; color:var(--muted); font-weight:400; }
463
+ .convo .empty { padding:26px 14px; text-align:center; font:400 13px/1.5 var(--sans); color:var(--muted); }
464
+ </style>
465
+
466
+ <div class="wrap">
467
+
468
+ <header class="hd">
469
+ <div class="switcher" id="switcher"></div>
470
+ <div class="kicker" id="t-kicker"></div>
471
+ <h1 id="t-title"></h1>
472
+ <div class="meta" id="t-meta"></div>
473
+ <p class="note" id="t-summary"></p>
474
+ </header>
475
+
476
+ <section>
477
+ <h2>How the run actually went</h2>
478
+ <p class="note">Reviewers run concurrently; the main agent works between rounds. Bar width is real elapsed time.</p>
479
+ <div class="tl"><div class="tl-inner" id="tl"></div></div>
480
+ <p class="tl-foot" id="tlfoot"></p>
481
+ </section>
482
+
483
+ <section>
484
+ <h2>Round by round</h2>
485
+ <div class="rgrid" id="rounds"></div>
486
+ </section>
487
+
488
+ <section id="convo-sec">
489
+ <h2>The conversation</h2>
490
+ <p class="note" id="c-note">Claude on the left, the reviewer on the right. Each reviewer is a
491
+ separate thread — they never see each other's findings, which is the only reason running more
492
+ than one counts as evidence.</p>
493
+ <div class="convo-bar">
494
+ <div class="convo-tabs" id="convo-tabs"></div>
495
+ <span class="spacer"></span>
496
+ <button class="stack-btn" id="convo-stack" aria-pressed="false">stack all</button>
497
+ <a class="stack-btn" id="convo-pop" href="#" target="_blank" rel="noopener"
498
+ title="Open the conversation in its own window">pop out ↗</a>
499
+ </div>
500
+ <div class="convo-grid" id="convo-grid"></div>
501
+ </section>
502
+
503
+ <section>
504
+ <h2>The exchange</h2>
505
+ <p class="note" id="x-note"></p>
506
+ <div class="filters" id="filters"></div>
507
+ <div id="thread" style="display:flex;flex-direction:column;gap:16px"></div>
508
+ </section>
509
+
510
+ <section id="ship-sec">
511
+ <h2>What shipped</h2>
512
+ <div class="ship" id="ship"></div>
513
+ </section>
514
+
515
+ <footer id="foot"></footer>
516
+ </div>
517
+
518
+ <script>
519
+ let RUN = {
520
+ target: {
521
+ repo: "acme/worker-pool", id: "#128",
522
+ url: "https://github.com/acme/worker-pool/pull/128",
523
+ title: "Grow the CreateSandbox retry wait on repeated failures",
524
+ branch: "fix/sandbox-manager-create-backoff", trunk: "master",
525
+ diffstat: "2 files · +418 −13", state: "merged", stateNote: "merged · converged on ae473ab",
526
+ summary: "Two reviewers — codex and droid — read each pushed commit independently. The main agent triages what they find, fixes what survives verification, answers every finding including the ones it rejects, and pushes again. Three rounds until both reported nothing new."
527
+ },
528
+ totalMin: 100,
529
+ lanes: [
530
+ { who:"codex", label:"codex", segs:[ {r:1,s:0,e:18},{r:2,s:40,e:58},{r:3,s:78,e:96} ] },
531
+ { who:"droid", label:"droid", segs:[ {r:1,s:0,e:2},{r:2,s:40,e:42},{r:3,s:78,e:80} ] },
532
+ { who:"main", label:"claude", segs:[ {r:1,s:18,e:40,t:"triage · fix · push"},
533
+ {r:2,s:58,e:78,t:"triage · fix · push"},
534
+ {r:3,s:96,e:100,t:"merge prep"} ] }
535
+ ],
536
+ marks: [ {at:0,l:"round 1"}, {at:40,l:"round 2"}, {at:78,l:"round 3"} ],
537
+ tlfoot:"codex ~18 min per round, droid ~2 min. Every round's wall clock is set by the slower reviewer — running them in series would have roughly doubled the run.",
538
+ xnote:"Every finding gets a reply. The rejected one matters most — it caught a bug in the prompt, not the code.",
539
+ rounds: [
540
+ { n:1, sha:"bf94454", verdicts:[
541
+ { a:"codex", s:"found", t:"<b>1 finding</b> — tests derive their bounds from the constants they check" },
542
+ { a:"droid", s:"clean", t:"<b>NO NEW FINDINGS</b>" } ] },
543
+ { n:2, sha:"6901882", verdicts:[
544
+ { a:"codex", s:"found", t:"<b>2 findings</b> — jitter is deletable; cancellation test is probabilistic" },
545
+ { a:"droid", s:"clean", t:"<b>NO NEW FINDINGS</b>" } ] },
546
+ { n:3, sha:"ae473ab", verdicts:[
547
+ { a:"codex", s:"clean", t:"<b>NO NEW FINDINGS</b>" },
548
+ { a:"droid", s:"clean", t:"<b>NO NEW FINDINGS</b>" } ] }
549
+ ],
550
+ exchanges: [
551
+ { id:"X1", round:2, res:"accepted", loc:"main_test.go:2237 · low · found by codex",
552
+ claim:"Jitter could be deleted and every test would still pass",
553
+ outcome:"Fixed. Test_createBackoff now asserts jitter shortens and varies across 200 draws — verified to fail when jitter is removed.",
554
+ turns:[
555
+ { who:"codex", ts:"round 2 · T+18m", body:"Every assertion accepts the nominal delay itself. Replacing createBackoff's final expression with <strong>return delay</strong> removes jitter entirely yet passes all new backoff and worker tests. The sampling assertion only enforces the ceiling, not that waits are ever shortened or varied." },
556
+ { who:"claude", ts:"round 2 · T+41m", body:"Reproduced — and my first attempt to reproduce it was itself wrong. Deleting the jitter expression orphaned the <strong>math/rand/v2</strong> import, so the FAIL I saw was a compile error, not an assertion. Kept the import used and re-ran: every test passed. You were right.",
557
+ quote:"_ = rand.Float64()\nreturn delay // jitter gone\nall backoff tests: PASS", ask:"Fixed by asserting jitter actually moves. Does that close it?" },
558
+ { who:"codex", ts:"round 3 · T+96m", body:"NO NEW FINDINGS." } ] },
559
+
560
+ { id:"X2", round:2, res:"accepted", loc:"main_test.go:2476 · low · found by codex",
561
+ claim:"The cancellation test could pass by luck",
562
+ outcome:"Fixed. The wait is now stubbed to report an unfinished wait with the context still live, so a looping worker has no race to hide behind. Fails deterministically in 0.00s.",
563
+ turns:[
564
+ { who:"codex", ts:"round 2 · T+18m", body:"If the worker regresses from <strong>return</strong> to <strong>continue</strong>, its next select can still choose ctx.Done() before a buffered job. All 20 runs can therefore report exactly one call and pass incorrectly — roughly a 1-in-1,048,576 false pass." },
565
+ { who:"claude", ts:"round 2 · T+52m", body:"Accepted. Made it deterministic rather than merely improbable. Writing it surfaced a second problem of my own: under regression the test <strong>hung</strong> instead of failing — the worker drained the buffered channel then blocked on an open, empty one. Closing the channel makes it fail fast.",
566
+ quote:"before: 20 runs, P(false pass) ≈ 2⁻²⁰\nafter: deterministic, fails in 0.00s",
567
+ ask:"A test that hangs on regression is worse than one that fails — it burns the CI timeout and reads as infra. Agreed?" },
568
+ { who:"codex", ts:"round 3 · T+96m", body:"NO NEW FINDINGS." } ] },
569
+
570
+ { id:"X3", round:1, res:"accepted", loc:"main_test.go:2223 · low · found by codex",
571
+ claim:"The tests validate their own constants",
572
+ outcome:"Fixed. Test_backoffLimits pins 1s / 60s / 0.2 / 60s independently, so a constant change has to be deliberate.",
573
+ turns:[
574
+ { who:"codex", ts:"round 1 · T+18m", body:"The expected bounds are derived from createBackoffJitter and maxCreateBackoff. Changing jitter to 50% or lowering the cap to 48s leaves all new tests green despite violating the specified behaviour. Pin those contractual values independently." },
575
+ { who:"claude", ts:"round 1 · T+33m", body:"Reproduced exactly as described — with the cap at 48s and jitter at 50%, every existing backoff test stayed green and only the new pinning test failed.",
576
+ quote:"cap 60s→48s, jitter 0.2→0.5\nother backoff tests: PASS\nTest_backoffLimits: FAIL" } ] },
577
+
578
+ { id:"X4", round:1, res:"rejected", loc:"acquire_route_handler.go · info · raised by droid",
579
+ claim:"The diff also deletes IdlePoolMissReason and a response header",
580
+ outcome:"Rejected — an artifact of the diff base, caused by my prompt. The prompt now pins the merge-base, and droid confirmed against it.",
581
+ turns:[
582
+ { who:"droid", ts:"round 1 · T+02m", body:"The diff also removes the IdlePoolMissReason type, the X-Assign-Missed-Reason response header, and ~80 lines of sanitization. This is a feature removal that changes the public API surface — a separate concern from the backoff change." },
583
+ { who:"claude", ts:"round 1 · T+29m", body:"<strong>Rejected, and this one is my fault rather than yours.</strong> My prompt told you to run <strong>git diff origin/master</strong>. The trunk gained that commit after our branch point, so another commit's addition showed up inverted as our deletion.",
584
+ quote:"git diff origin/master → 8 files\ngit diff $(git merge-base …) → 2 files", ask:"Please re-check against the merge-base and confirm." },
585
+ { who:"droid", ts:"round 1 · T+36m", body:"Confirmed. The diff against the merge-base touches exactly two files, and no mention of IdlePoolMissReason appears. The commit that added it landed on the trunk after the branch point. Purely an artifact of the diff base." } ] },
586
+
587
+ { id:"X5", round:1, res:"deferred", loc:"main.go:289 · medium · found by codex",
588
+ claim:"Producer can over-reserve the in-flight count",
589
+ outcome:"Deferred by agreement. Pre-existing and untouched by this change; tracked as its own PR rather than widening this one.",
590
+ turns:[
591
+ { who:"codex", ts:"round 1 · T+18m", body:"The producer reads the in-flight count, unlocks, calculates need, and only later locks again to add it. During manager replacement two producers can observe the same count and both reserve the full deficit. The mutex prevents a data race but not this check-then-act race." },
592
+ { who:"claude", ts:"round 1 · T+33m", body:"<strong>Agree it is real. Not fixing it here</strong> — producer and the in-flight map are untouched by this change, which only edits the worker retry path. Widening the PR to cover it would mix two concerns.",
593
+ ask:"Do you agree it is (a) pre-existing and (b) not made materially more likely by this change? If the new backoff widens that window, say so and I will reconsider." },
594
+ { who:"codex", ts:"round 1 · T+51m", body:"Accepted. The producer reservation code is byte-for-byte outside this commit's diff, so the issue is pre-existing. The new backoff does not materially widen the concurrent-producer window; cancellation-aware worker exit arguably reduces old/new manager overlap. I agree it belongs in a separate PR." } ] }
595
+ ],
596
+ ship: [
597
+ { n:"3", k:"rounds", s:"to convergence" },
598
+ { n:"5", k:"exchanges", s:"3 fixed · 1 deferred · 1 rejected" },
599
+ { n:"11", k:"turns", s:"every finding answered" },
600
+ { n:"0", k:"prod bugs", s:"found in these 3 rounds" },
601
+ { n:"3", k:"test defects", s:"tests that proved nothing" },
602
+ { n:"~1.7h", k:"agent time", s:"~20 min per round" }
603
+ ],
604
+ foot:"Verbatim from the run on #128. The main agent's turns include two of its own mistakes — a compile error misread as a caught regression, and a test that hung rather than failed — because both changed what shipped. Deferred findings became their own pull requests; rejected ones stay on the record with the evidence that settled them."
605
+ };
606
+
607
+ // One mark per agent. Inline SVG on currentColor: no external asset, so the
608
+ // console works served locally and as a self-contained page, and both themes
609
+ // get the right contrast for free. Unknown agents fall back to a plain dot.
610
+ const ICONS = {
611
+ // Official marks (simple-icons, CC0 icon set; the marks themselves remain the
612
+ // trademarks of their owners and are used here only to identify the tools).
613
+ claude: '<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">' +
614
+ '<path fill="currentColor" d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z"/></svg>',
615
+ codex: '<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">' +
616
+ '<path fill="currentColor" d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/></svg>',
617
+ // agy is Google Antigravity. No mark is published in the CC0 icon set, so this
618
+ // is Antigravity's own shape rather than a borrowed one: an upward chevron
619
+ // over a horizon, which is what the product's own iconography reads as.
620
+ agy: '<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">' +
621
+ '<path fill="currentColor" d="M12 2.8 22 19.2h-4.36L12 9.9 6.36 19.2H2z"/>' +
622
+ '<path fill="currentColor" d="M7.6 21.2h8.8v1.9H7.6z" opacity=".55"/></svg>',
623
+ // Grok's own mark, taken from grok.com. Not the xAI slash logo — that is the
624
+ // company, this is the product doing the reviewing.
625
+ grok: '<svg viewBox="0 0 34 33" width="16" height="16" aria-hidden="true">' +
626
+ '<path fill="currentColor" d="M13.2371 21.0407L24.3186 12.8506C24.8619 12.4491 25.6384 12.6057 25.8973 13.2294C27.2597 16.5185 26.651 20.4712 23.9403 23.1851C21.2297 25.8989 17.4581 26.4941 14.0108 25.1386L10.2449 26.8843C15.6463 30.5806 22.2053 29.6665 26.304 25.5601C29.5551 22.3051 30.562 17.8683 29.6205 13.8673L29.629 13.8758C28.2637 7.99809 29.9647 5.64871 33.449 0.844576C33.5314 0.730667 33.6139 0.616757 33.6964 0.5L29.1113 5.09055V5.07631L13.2343 21.0436"/>' +
627
+ '<path fill="currentColor" d="M10.9503 23.0313C7.07343 19.3235 7.74185 13.5853 11.0498 10.2763C13.4959 7.82722 17.5036 6.82767 21.0021 8.2971L24.7595 6.55998C24.0826 6.07017 23.215 5.54334 22.2195 5.17313C17.7198 3.31926 12.3326 4.24192 8.67479 7.90126C5.15635 11.4239 4.0499 16.8403 5.94992 21.4622C7.36924 24.9165 5.04257 27.3598 2.69884 29.826C1.86829 30.7002 1.0349 31.5745 0.36364 32.5L10.9474 23.0341"/></svg>',
628
+ // Factory publishes no mark in the icon set, so droid keeps an honest generic
629
+ // machine glyph rather than an invented "official" logo.
630
+ // Factory's own mark, taken from factory.ai's nav logo.
631
+ droid: '<svg viewBox="0 0 67 65" width="15" height="15" aria-hidden="true">' +
632
+ '<path fill="currentColor" d="M47.75 11.15a.867.867 0 0 1-.671-.806.84.84 0 0 1 .067-.362c1.688-4.007 2.433-7.213 1.23-8.555-3.183-3.56-15.952 3.52-20.024 5.919a.9.9 0 0 1-1.273-.41c-1.711-3.998-3.51-6.78-5.334-6.9-4.833-.323-8.73 13.49-9.87 17.992a.85.85 0 0 1-.459.563.9.9 0 0 1-.737.027c-4.109-1.647-7.398-2.373-8.773-1.2-3.651 3.104 3.609 15.557 6.068 19.528a.85.85 0 0 1-.11 1.031.9.9 0 0 1-.31.21C3.455 39.856.604 41.61.478 43.389c-.329 4.713 13.834 8.513 18.452 9.625q.186.046.337.163a.87.87 0 0 1 .332.642.84.84 0 0 1-.067.362c-1.688 4.007-2.433 7.214-1.23 8.555 3.183 3.561 15.954-3.519 20.025-5.917a.9.9 0 0 1 1.058.107.9.9 0 0 1 .215.302c1.711 3.997 3.509 6.779 5.334 6.9 4.833.322 8.73-13.49 9.868-17.993a.85.85 0 0 1 .168-.33.88.88 0 0 1 .659-.324.9.9 0 0 1 .371.066c4.109 1.647 7.397 2.372 8.773 1.2 3.651-3.105-3.61-15.559-6.07-19.53a.85.85 0 0 1 .111-1.03.9.9 0 0 1 .31-.21c4.1-1.67 6.952-3.424 7.075-5.203.331-4.713-13.833-8.513-18.45-9.623m-5.546-4.518c.93 1.624-3.858 12.446-7.42 20.015a.7.7 0 0 1-.28.303.71.71 0 0 1-.796-.059.7.7 0 0 1-.23-.341c-1.439-4.921-3.082-10.704-4.841-15.612a.84.84 0 0 1 .01-.594.87.87 0 0 1 .401-.446c4.392-2.34 11.908-5.446 13.156-3.266m-21.048 1.34c1.833.507 6.294 11.46 9.264 19.268a.67.67 0 0 1-.2.754.71.71 0 0 1-.794.08c-4.589-2.485-9.94-5.444-14.743-7.702a.87.87 0 0 1-.422-.427.84.84 0 0 1-.04-.591c1.414-4.679 4.471-12.063 6.935-11.383M7.243 23.433c1.664-.906 12.762 3.763 20.522 7.235.13.058.239.154.311.274a.67.67 0 0 1-.06.776.7.7 0 0 1-.35.225c-5.045 1.403-10.976 3.006-16.01 4.721a.9.9 0 0 1-.607-.01.88.88 0 0 1-.456-.391c-2.395-4.284-5.586-11.613-3.35-12.83M8.617 43.96c.519-1.788 11.752-6.14 19.758-9.035a.72.72 0 0 1 .773.195.67.67 0 0 1 .081.774c-2.548 4.475-5.582 9.694-7.898 14.377a.87.87 0 0 1-.437.413.9.9 0 0 1-.607.039c-4.797-1.37-12.37-4.36-11.67-6.763m15.855 13.568c-.93-1.623 3.859-12.446 7.42-20.014a.7.7 0 0 1 .28-.303.715.715 0 0 1 .796.059.7.7 0 0 1 .23.34c1.439 4.92 3.083 10.705 4.841 15.613a.84.84 0 0 1-.01.593.87.87 0 0 1-.402.445c-4.391 2.335-11.908 5.447-13.15 3.267zm21.049-1.34c-1.836-.506-6.297-11.461-9.266-19.269a.67.67 0 0 1 .2-.755.71.71 0 0 1 .795-.078c4.587 2.484 9.94 5.445 14.742 7.703.189.088.339.24.423.426a.84.84 0 0 1 .039.592c-1.413 4.686-4.47 12.063-6.933 11.381m13.912-15.462c-1.665.907-12.762-3.763-20.523-7.236a.7.7 0 0 1-.311-.273.67.67 0 0 1 .06-.777.7.7 0 0 1 .35-.225c5.046-1.402 10.975-3.005 16.009-4.72a.9.9 0 0 1 .609.01.88.88 0 0 1 .457.392c2.393 4.282 5.584 11.613 3.349 12.829M58.06 20.2c-.521 1.79-11.753 6.14-19.759 9.036a.72.72 0 0 1-.774-.195.67.67 0 0 1-.08-.776c2.547-4.474 5.581-9.694 7.897-14.377a.87.87 0 0 1 .437-.412.9.9 0 0 1 .607-.038c4.797 1.377 12.37 4.359 11.672 6.762"/></svg>',
633
+ };
634
+ /**
635
+ * A mark for an agent with no official one.
636
+ *
637
+ * A single dot for every unknown agent made three reviewers look like the same
638
+ * anonymous thing, and unbranded next to codex's real mark. These are
639
+ * deliberately geometric rather than logo-like: inventing an "official" mark
640
+ * for someone else's product would be worse than having none. The shape is
641
+ * picked by hashing the name, so an agent keeps its glyph across reloads and
642
+ * two agents in the same run are distinguishable at a glance.
643
+ */
644
+ const GLYPHS = [
645
+ // hexagon
646
+ '<path fill="currentColor" d="M12 2.6 20.1 7v10L12 21.4 3.9 17V7z" opacity=".9"/>',
647
+ // rounded square, hollow centre
648
+ '<path fill="currentColor" d="M6 3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3Zm0 3v12h12V6Z"/><circle cx="12" cy="12" r="2.6" fill="currentColor"/>',
649
+ // triangle
650
+ '<path fill="currentColor" d="M12 3.2 21.5 20H2.5z"/>',
651
+ // four-point star
652
+ '<path fill="currentColor" d="M12 2c.6 5 2.4 7.4 10 10-7.6 2.6-9.4 5-10 10-.6-5-2.4-7.4-10-10 7.6-2.6 9.4-5 10-10Z"/>',
653
+ // concentric rings
654
+ '<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2.2"/><circle cx="12" cy="12" r="3.4" fill="currentColor"/>',
655
+ // diamond
656
+ '<path fill="currentColor" d="M12 2.2 21.8 12 12 21.8 2.2 12z"/>',
657
+ // three bars
658
+ '<path fill="currentColor" d="M3 5h18v3.4H3zM3 10.3h18v3.4H3zM3 15.6h18V19H3z"/>',
659
+ // pentagon
660
+ '<path fill="currentColor" d="M12 2.4 22 9.7l-3.8 11.7H5.8L2 9.7z"/>',
661
+ ];
662
+
663
+ const icon = (name) => {
664
+ if (ICONS[name]) return ICONS[name];
665
+ const n = String(name ?? "");
666
+ let h = 0;
667
+ for (let i = 0; i < n.length; i++) h = (h * 31 + n.charCodeAt(i)) | 0;
668
+ const g = GLYPHS[Math.abs(h) % GLYPHS.length];
669
+ return `<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">${g}</svg>`;
670
+ };
671
+
672
+ // Times are pinned to Singapore, not the viewer's locale: a review record should
673
+ // read the same wherever it is opened, and 24-hour reads better against log data.
674
+ const TZ = "Asia/Singapore";
675
+ const TZ_LABEL = "SGT";
676
+ const clockFmt = new Intl.DateTimeFormat("en-GB", {
677
+ timeZone: TZ, hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit",
678
+ });
679
+
680
+ // A turn carries an ISO time when it was measured; the round/offset label is
681
+ // secondary context, shown under it rather than wrapped into the same line.
682
+ function when(t) {
683
+ let clock = "";
684
+ if (t?.at) {
685
+ const d = new Date(t.at);
686
+ if (!Number.isNaN(+d)) clock = clockFmt.format(d);
687
+ }
688
+ const ctx = t?.ts ?? "";
689
+ return `${clock ? `<b>${esc(clock)}</b>` : ""}${ctx ? `<i>${esc(ctx)}</i>` : ""}`;
690
+ }
691
+
692
+ const $ = id => document.getElementById(id);
693
+ const esc = s => String(s).replace(/[&<>]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
694
+ const rich = s => esc(s).replace(/&lt;strong&gt;/g,"<strong>").replace(/&lt;\/strong&gt;/g,"</strong>")
695
+ .replace(/&lt;b&gt;/g,"<b>").replace(/&lt;\/b&gt;/g,"</b>");
696
+ const pct = m => (m / RUN.totalMin) * 100;
697
+
698
+ /**
699
+ * Merge timeline marks too close together to render side by side.
700
+ *
701
+ * A label is roughly 8% of the track at the widths this is read at, so marks
702
+ * nearer than that collide. Rather than dropping the later one — which would
703
+ * misreport how many rounds ran — they collapse into a range: "rounds 3-6",
704
+ * with the full list on hover.
705
+ */
706
+ function collapseMarks(marks = []) {
707
+ const sorted = [...marks].sort((a, b) => a.at - b.at);
708
+ const out = [];
709
+ const MIN_GAP = 8; // percent of the track
710
+ for (const m of sorted) {
711
+ const last = out[out.length - 1];
712
+ if (last && pct(m.at) - pct(last.at) < MIN_GAP) {
713
+ last.group.push(m.l);
714
+ const ns = last.group.map((l) => String(l).replace(/\D+/g, "")).filter(Boolean);
715
+ last.l = ns.length > 1 ? `rounds ${ns[0]}-${ns[ns.length - 1]}` : last.group[0];
716
+ last.title = last.group.join(", ");
717
+ continue;
718
+ }
719
+ out.push({ at: m.at, l: m.l, title: m.l, group: [m.l] });
720
+ }
721
+ return out;
722
+ }
723
+
724
+ // exchanges. "open" is a finding the main agent has not answered yet, and it is
725
+ // the state a live run spends most of its time in — leaving it out of the
726
+ // filters hid every finding the CLI recorded.
727
+
728
+ const STATES = ["open","accepted","deferred","rejected"];
729
+ let show = new Set(STATES);
730
+ $("filters").innerHTML = STATES
731
+ .map(v=>`<button class="chip" data-v="${v}" aria-pressed="true">${v}</button>`).join("");
732
+
733
+ function draw(){
734
+ const all = RUN.exchanges ?? [];
735
+ const rows = all.filter(x => show.has(x.res ?? x.resolution));
736
+ const t = RUN.target ?? {};
737
+ const cmd = t.url ? `jury review ${t.url}` : `jury review --dir . --trunk ${t.trunk ?? "master"}`;
738
+
739
+ // This copy has to describe THIS run. A line about the rejected finding was
740
+ // being printed over a run that raised no findings at all.
741
+ const verdicts = (RUN.rounds ?? []).flatMap(r => r.verdicts ?? []);
742
+ const allClean = verdicts.length > 0 && verdicts.every(v => v.s === "clean");
743
+ $("x-note").textContent = RUN.xnote ?? (all.length
744
+ ? "Every finding gets a reply, including the ones the main agent rejects — with the evidence that settled them."
745
+ : allClean
746
+ ? "Nothing to answer: every reviewer read the diff and reported no new problems."
747
+ : "Nothing tracked yet for this run.");
748
+
749
+ const empty = all.length ? "no exchanges match this filter"
750
+ : allClean ? "no findings raised — every reviewer reported clean"
751
+ : "no findings recorded yet";
752
+
753
+ const card = x => `
754
+ <div class="xb">
755
+ <div class="xb-head">
756
+ <span class="claim">${esc(x.claim)}</span>
757
+ <span class="tag" data-r="${x.res ?? x.resolution}">${x.res ?? x.resolution}</span>
758
+ <span class="meta">${esc(x.id ?? "")} · ${esc(x.loc ?? "")}</span>
759
+ </div>
760
+ ${(x.turns ?? []).map(tt => `
761
+ <div class="ln" data-who="${tt.who}">
762
+ <span class="at">${when(tt)}</span>
763
+ <span class="nm">${icon(tt.who)}${esc(tt.who)}</span>
764
+ <span class="say">${rich(tt.body)}
765
+ ${tt.quote ? `<div class="q">${esc(tt.quote)}</div>` : ""}
766
+ ${tt.ask ? `<div class="ask">${esc(tt.ask)}</div>` : ""}
767
+ </span>
768
+ </div>`).join("")}
769
+ ${x.outcome ? `<div class="out" data-r="${x.res ?? x.resolution}"><b>${x.res ?? x.resolution}</b> — ${esc(x.outcome)}</div>` : ""}
770
+ </div>`;
771
+
772
+ // Grouped by reviewer, in the order they first spoke. A single flat list
773
+ // reads as one debate among everyone; these were N separate conversations,
774
+ // and which reviewer raised a claim is most of what makes it worth weighing.
775
+ // An unattributed finding still has to appear somewhere, or grouping would
776
+ // silently drop it — a finding nobody can see is worse than an ugly heading.
777
+ const who = [];
778
+ for (const x of all) {
779
+ const k = x.with || "unattributed";
780
+ if (!who.includes(k)) who.push(k);
781
+ }
782
+
783
+ const body = !rows.length
784
+ ? `<div style="color:var(--t-dim);padding:8px 0">${esc(empty)}</div>`
785
+ // Follows the conversation picked above, so the findings on screen are the
786
+ // ones raised in the thread you are reading. Stacked means "show me all of
787
+ // them", and with only one reviewer there is nothing to follow.
788
+ // Derived, not read off a global: draw() runs before drawConvo() on the
789
+ // first paint, so consulting CONVO_PICK directly showed every reviewer once
790
+ // and then silently disagreed with the tab that ended up pressed.
791
+ : who.filter(a => who.length < 2 || CONVO_STACK || a === (who.includes(CONVO_PICK) ? CONVO_PICK : who[0])).map(agent => {
792
+ const is = x => (x.with || "unattributed") === agent;
793
+ const mine = rows.filter(is);
794
+ if (!mine.length) return "";
795
+ const shown = all.filter(is).length;
796
+ return `
797
+ <div class="xgroup">
798
+ <div class="rule" data-who="${esc(agent)}">
799
+ <span class="lbl">${icon(agent)}claude ⇄ ${esc(agent)}</span><i></i>
800
+ <span class="rt">${mine.length} of ${shown} finding(s)</span>
801
+ </div>
802
+ ${mine.map(card).join("")}
803
+ </div>`;
804
+ }).join("") || `<div style="color:var(--t-dim);padding:8px 0">${esc(empty)}</div>`;
805
+
806
+ $("thread").innerHTML = `
807
+ <div class="term">
808
+ <div class="term-bar">
809
+ <span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
810
+ <span class="t">${esc(t.repo ?? "")} ${esc(t.id ?? "")} — exchange</span>
811
+ </div>
812
+ <div class="term-body">
813
+ <div class="cmdline"><span class="cwd">~/${esc((t.repo ?? "repo").split("/").pop())}</span> <span class="branch">git:(${esc(t.branch ?? "?")})</span></div>
814
+ <div class="cmdline"><span class="p">❯</span><span class="c">${esc(cmd)}</span></div>
815
+ ${body}
816
+ </div>
817
+ </div>`;
818
+ }
819
+
820
+ $("filters").addEventListener("click", e=>{
821
+ const b=e.target.closest(".chip"); if(!b) return;
822
+ const v=b.dataset.v;
823
+ if(show.has(v)){ show.delete(v); b.setAttribute("aria-pressed","false"); }
824
+ else { show.add(v); b.setAttribute("aria-pressed","true"); }
825
+ draw();
826
+ });
827
+
828
+ const BADGE = { merged:"badge-ok", converged:"badge-ok", review:"badge-warn", human:"badge-warn", queued:"badge-mute" };
829
+
830
+ function renderHeader(){
831
+ const t = RUN.target ?? {};
832
+ // Name the repo here, next to the switcher, rather than only in the meta row
833
+ // below the title: the chips read "#46 / #48" and a bare number does not say
834
+ // what project it belongs to.
835
+ const scope = [...new Set(ALL.map(r => r.target?.repo).filter(Boolean))];
836
+ $("t-kicker").textContent = ALL.length > 1
837
+ ? `${scope.length === 1 ? scope[0] : `${scope.length} repos`} · ${ALL.length} pull requests`
838
+ : `${t.repo ? `${t.repo} · ` : ""}single pull request`;
839
+ $("t-title").textContent = t.title ?? "(untitled change)";
840
+ $("t-summary").textContent = t.summary ?? "";
841
+
842
+ const link = t.url
843
+ ? `<a href="${esc(t.url)}" target="_blank" rel="noopener">${esc(t.repo ?? "")} ${esc(t.id ?? "")}</a>`
844
+ : `<span>${esc(t.repo ?? "")} ${esc(t.id ?? "")}</span>`;
845
+ const branch = t.branch ? `<span>${esc(t.branch)} → ${esc(t.trunk ?? "master")}</span>` : "";
846
+ const stat = t.diffstat ? `<span>${esc(t.diffstat)}</span>` : "";
847
+ const cls = BADGE[t.state] ?? "badge-mute";
848
+ const badge = t.state
849
+ ? `<span class="${cls}"><span class="d"></span>${esc(t.stateNote || t.state)}</span>` : "";
850
+ $("t-meta").innerHTML = [link, branch, stat, badge].filter(Boolean).join("");
851
+
852
+ // Only worth showing when there is something to switch between.
853
+ //
854
+ // A bare "#48" does not say which project it belongs to, and PR numbers
855
+ // collide across repos — two chips reading #48 would be indistinguishable.
856
+ // The repo name is carried whenever more than one is on screen; with a single
857
+ // repo it is redundant, and the title is what actually tells them apart.
858
+ const repos = new Set(ALL.map(r => r.target?.repo).filter(Boolean));
859
+ drawSwitcher();
860
+ }
861
+
862
+ /* ── Run switcher ───────────────────────────────────────────────────────
863
+ A chip per run wrapped to two rows at seven runs; at a hundred it is a
864
+ wall. One control names the current run, and opening it gives a filterable
865
+ list with whatever is LIVE first — a running review is the thing you came
866
+ to look at, and it must never be somewhere down a list of two hundred. */
867
+ let SW_OPEN = false;
868
+ let SW_Q = "";
869
+
870
+ // "Live" cannot be the state field alone. A run that was killed keeps whatever
871
+ // state it was last written with, so six abandoned runs all claimed to be
872
+ // under review — and the one actually running was lost among them. A run is
873
+ // live if it says it is reviewing AND something happened in the last few
874
+ // minutes.
875
+ const LIVE_WINDOW_MS = 6 * 60 * 1000;
876
+ function lastActivity(r) {
877
+ // A verdict is the best signal, but a run whose reviewer never reported has
878
+ // none — and those are exactly the runs you just started and want at the top.
879
+ // Fall back to the newest round, so nothing sorts as "no activity ever".
880
+ const verdict = (r.rounds ?? []).flatMap((x) => x.verdicts ?? []).map((v) => v.at).filter(Boolean).pop();
881
+ if (verdict) return Date.parse(verdict);
882
+ const started = (r.rounds ?? []).map((x) => x.startedAt).filter(Boolean).pop();
883
+ return started ? Date.parse(started) : null;
884
+ }
885
+ function isLive(r) {
886
+ if (r.target?.state !== "review") return false;
887
+ // A launched round with no verdict yet is live by definition: the reviewer
888
+ // has not reported, which is exactly the case worth watching.
889
+ if (r === RUN && EVENTS.length) {
890
+ const last = EVENTS[EVENTS.length - 1];
891
+ if (last?.ts && Date.now() - Date.parse(last.ts) < LIVE_WINDOW_MS) return true;
892
+ }
893
+ const at = lastActivity(r);
894
+ return at !== null && Date.now() - at < LIVE_WINDOW_MS;
895
+ }
896
+
897
+ function runLabel(r) {
898
+ const t = r.target ?? {};
899
+ const repo = (t.repo ?? "").split("/").pop() || "run";
900
+ return `${repo} · ${t.id ?? "?"}`;
901
+ }
902
+
903
+ /**
904
+ * Which attempt this is, when the same PR has been reviewed more than once.
905
+ *
906
+ * Every run of PR #2 is labelled "#2", so without this three separate reviews
907
+ * are three identical rows. Shown only when there IS more than one — a lone run
908
+ * does not need to defend its uniqueness.
909
+ */
910
+ function attemptOf(r) {
911
+ const m = /-(\d{8})-(\d{4})$/.exec(r.slug ?? "");
912
+ if (!m) return "";
913
+ const [, d, hm] = m;
914
+ return `${d.slice(4, 6)}/${d.slice(6, 8)} ${hm.slice(0, 2)}:${hm.slice(2)}`;
915
+ }
916
+ function hasSiblings(r, all) {
917
+ const key = `${r.target?.repo}#${r.target?.id}`;
918
+ return all.filter((x) => `${x.target?.repo}#${x.target?.id}` === key).length > 1;
919
+ }
920
+
921
+ // "16h", "3d" — a run's age is glanceable, an ISO timestamp is not.
922
+ function ago(r) {
923
+ const last = lastActivity(r);
924
+ if (!last) return "";
925
+ const s = (Date.now() - last) / 1000;
926
+ if (!Number.isFinite(s) || s < 0) return "";
927
+ if (s < 90) return "just now";
928
+ if (s < 5400) return `${Math.round(s / 60)}m`;
929
+ if (s < 172800) return `${Math.round(s / 3600)}h`;
930
+ return `${Math.round(s / 86400)}d`;
931
+ }
932
+
933
+ function runSummary(r) {
934
+ const t = r.target ?? {};
935
+ if (isLive(r) && t.stateNote) return t.stateNote;
936
+ const n = (r.exchanges ?? []).length;
937
+ if (t.state === "converged") return "converged";
938
+ const found = n ? `${n} finding${n === 1 ? "" : "s"}` : "";
939
+ // A stale "review" is a run that stopped without finishing. Saying "review"
940
+ // would repeat the claim that misled the grouping in the first place.
941
+ if (t.state === "review") return found ? `${found} · stopped` : "stopped";
942
+ return found || t.stateNote || t.state || "";
943
+ }
944
+
945
+ function drawSwitcher() {
946
+ const el = $("switcher");
947
+ if (!el) return;
948
+ if (ALL.length < 2) { el.innerHTML = ""; return; }
949
+
950
+ const q = SW_Q.trim().toLowerCase();
951
+ const match = (r) => {
952
+ if (!q) return true;
953
+ const t = r.target ?? {};
954
+ return [t.repo, t.id, t.title, t.branch, r.slug]
955
+ .filter(Boolean).join(" ").toLowerCase().includes(q);
956
+ };
957
+
958
+ const hits = ALL.filter(match);
959
+ // Newest first, full stop. Grouping by repo read well in the abstract and
960
+ // badly in practice: it floated 9-day-old runs above ones from ten minutes
961
+ // ago, so the list did not answer "what was I just looking at?" — which is
962
+ // the only question this control gets asked.
963
+ hits.sort((a, b) => (lastActivity(b) ?? 0) - (lastActivity(a) ?? 0));
964
+ const live = hits.filter(isLive);
965
+ const rest = hits.filter((r) => !isLive(r));
966
+
967
+ const item = (r) => {
968
+ const t = r.target ?? {};
969
+ const on = r === RUN;
970
+ return `<button class="sw-i" role="option" data-slug="${esc(r.slug ?? "")}" aria-pressed="${on}"
971
+ ><span class="d" style="background:${isLive(r) ? "var(--warn)" : t.state === "converged" ? "var(--ok)" : "var(--muted)"}"></span
972
+ ><span class="id">${esc(runLabel(r))}</span
973
+ >${hasSiblings(r, ALL) && attemptOf(r) ? `<span class="at">${esc(attemptOf(r))}</span>` : ""
974
+ }<span class="ti">${esc(runSummary(r))}</span
975
+ ><span class="rt">${esc(ago(r))}</span></button>`;
976
+ };
977
+
978
+ const group = (label, rows) =>
979
+ rows.length ? `<div class="sw-h">${label}</div>${rows.map(item).join("")}` : "";
980
+
981
+ el.innerHTML = `
982
+ <button class="sw-btn" id="sw-btn" aria-expanded="${SW_OPEN}"
983
+ ><span class="d" style="width:7px;height:7px;border-radius:50%;background:${
984
+ isLive(RUN) ? "var(--warn)" : "var(--ok)"};flex:none"></span
985
+ ><span class="nm">${esc(runLabel(RUN))}${
986
+ hasSiblings(RUN, ALL) && attemptOf(RUN) ? ` · ${attemptOf(RUN)}` : ""}</span
987
+ ><span class="cnt">of ${ALL.length}</span><span class="car">▼</span></button>
988
+ <div class="sw-pop" id="sw-pop" ${SW_OPEN ? "" : "hidden"} role="listbox">
989
+ <input id="sw-q" type="search" placeholder="filter by repo, number, title, branch…"
990
+ value="${esc(SW_Q)}" autocomplete="off">
991
+ <div class="sw-list">${
992
+ hits.length
993
+ ? group(live.length ? `running now · ${live.length}` : "", live)
994
+ + group(`recent · ${rest.length}`, rest)
995
+ : `<div class="sw-none">nothing matches “${esc(SW_Q)}”</div>`
996
+ }</div>
997
+ </div>`;
998
+
999
+ if (SW_OPEN) {
1000
+ const input = $("sw-q");
1001
+ input?.focus();
1002
+ input?.setSelectionRange(SW_Q.length, SW_Q.length);
1003
+ }
1004
+ }
1005
+
1006
+ document.addEventListener("click", (e) => {
1007
+ if (e.target.closest("#sw-btn")) { SW_OPEN = !SW_OPEN; SW_Q = ""; drawSwitcher(); return; }
1008
+ const pick = e.target.closest("#sw-pop .sw-i");
1009
+ if (pick) {
1010
+ const next = ALL.find((r) => r.slug === pick.dataset.slug);
1011
+ if (next) { RUN = next; setRunParam(next.slug); SW_OPEN = false; SW_Q = ""; render(); }
1012
+ return;
1013
+ }
1014
+ // Anywhere else closes it — including the page behind.
1015
+ if (SW_OPEN && !e.target.closest("#sw-pop")) { SW_OPEN = false; drawSwitcher(); }
1016
+ });
1017
+
1018
+ document.addEventListener("input", (e) => {
1019
+ if (e.target.id !== "sw-q") return;
1020
+ SW_Q = e.target.value;
1021
+ drawSwitcher();
1022
+ });
1023
+
1024
+ document.addEventListener("keydown", (e) => {
1025
+ if (!SW_OPEN) return;
1026
+ if (e.key === "Escape") { SW_OPEN = false; drawSwitcher(); return; }
1027
+ if (e.key !== "Enter") return;
1028
+ // Enter takes the first match, so filtering to one run and pressing Enter is
1029
+ // the whole interaction.
1030
+ const first = document.querySelector("#sw-pop .sw-i");
1031
+ if (first) { e.preventDefault(); first.click(); }
1032
+ });
1033
+
1034
+ // Every reviewer's report in full, plus the prompt it answered. A round where
1035
+ // nobody found anything still has to show what was checked — the first line
1036
+ // alone ("NO NEW FINDINGS") hid caveats like a reviewer that could not run the
1037
+ // tests at all.
1038
+ const bytes = n => n < 1024 ? `${n} B` : `${(n/1024).toFixed(1)} kB`;
1039
+
1040
+ // report ⇄ raw stream, now hung off the conversation rather than the removed
1041
+ // round-by-round transcript. Fetched on demand: a verbose agent's stream can
1042
+ // dwarf the rest of the payload, and most of it is transcript rather than
1043
+ // answer.
1044
+ document.addEventListener("click", async (e) => {
1045
+ const b = e.target.closest("#convo-grid button[data-view]");
1046
+ if (!b) return;
1047
+ const tabs = b.closest(".tabs");
1048
+ const pre = tabs.previousElementSibling;
1049
+ const want = b.dataset.view;
1050
+ if (b.getAttribute("aria-pressed") === "true") return;
1051
+
1052
+ if (want === "raw") {
1053
+ if (!pre.dataset.report) pre.dataset.report = pre.textContent;
1054
+ if (!pre.dataset.raw) {
1055
+ b.textContent = "loading…";
1056
+ try {
1057
+ const res = await fetch(`api/raw?run=${encodeURIComponent(b.dataset.slug)}&file=${encodeURIComponent(b.dataset.for)}`);
1058
+ pre.dataset.raw = res.ok ? await res.text() : `(${res.status} — raw stream not on disk for this round)`;
1059
+ } catch { pre.dataset.raw = "(raw stream unavailable — is the console still serving?)"; }
1060
+ b.textContent = "raw stream";
1061
+ }
1062
+ pre.textContent = pre.dataset.raw;
1063
+ pre.classList.add("raw");
1064
+ } else {
1065
+ pre.textContent = pre.dataset.report ?? pre.textContent;
1066
+ pre.classList.remove("raw");
1067
+ }
1068
+ for (const other of tabs.querySelectorAll("button[data-view]")) {
1069
+ other.setAttribute("aria-pressed", String(other === b));
1070
+ }
1071
+ });
1072
+
1073
+ function render(){
1074
+ renderHeader();
1075
+ // timeline
1076
+ $("tl").innerHTML =
1077
+ // Marks that land within a label's width of each other are drawn as one.
1078
+ // Absolute positioning with no collision handling rendered six rounds a
1079
+ // few seconds apart as "ROUNDROUNDROUND23" — unreadable, and it hid how
1080
+ // many rounds there actually were.
1081
+ `<div class="tl-rounds">${collapseMarks(RUN.marks).map(m=>
1082
+ `<span style="left:${pct(m.at)}%" title="${esc(m.title ?? m.l)}">${esc(m.l)}</span>`).join("")}</div>` +
1083
+ RUN.lanes.map(l=>`
1084
+ <div class="lane">
1085
+ <span class="who"><span class="ic" style="color:${l.who==="main"?"var(--main-hue)":hueFor(l.who)}">${icon(l.label)}</span>${esc(l.label)}</span>
1086
+ <div class="track">${l.segs.map(s=>{
1087
+ // Rounded at the point of display: these are differences of floats,
1088
+ // so a span reads "8.300000000000004 min" without it.
1089
+ const w = pct(s.e - s.s), mins = +(s.e - s.s).toFixed(1);
1090
+ return `<div class="seg" data-a="${l.who}"${s.open ? ' data-open="1"' : ""}
1091
+ style="left:${pct(s.s)}%;width:${w}%"${s.abandoned ? ' data-abandoned="1"' : ""}
1092
+ title="${esc(s.t || ("round "+s.r+" · "+mins+" min"))}">${w > 9 ? esc(s.t || mins+" min") : ""}</div>`;
1093
+ }).join("")}</div>
1094
+ </div>`).join("");
1095
+ $("tlfoot").textContent = RUN.tlfoot;
1096
+
1097
+ $("rounds").innerHTML = RUN.rounds.map(r=>`
1098
+ <article class="rcard">
1099
+ <header><b>Round ${r.n}</b><span class="sha">${esc(r.sha)}</span></header>
1100
+ ${r.verdicts.map(v=>`
1101
+ <div class="verd" data-s="${v.s}">
1102
+ <span class="ag"><span class="ic" style="color:${hueFor(v.a)}">${icon(v.a)}</span>${esc(v.a)}</span>
1103
+ <span class="txt">${rich(v.t)}</span>
1104
+ </div>`).join("")}
1105
+ </article>`).join("");
1106
+
1107
+ // A run that has not shipped anything yet has no tally. An empty grid under a
1108
+ // heading reads as a rendering failure, so drop the whole section.
1109
+ const ship = RUN.ship ?? [];
1110
+ $("ship-sec").style.display = ship.length ? "" : "none";
1111
+ $("ship").innerHTML = ship.map(s=>`
1112
+ <div><span class="n">${esc(s.n)}</span><span class="k">${esc(s.k)}</span><span class="s">${esc(s.s)}</span></div>`).join("");
1113
+ // Same reason as the ship grid: an empty footer still draws its rule, which
1114
+ // reads as content that failed to load.
1115
+ $("foot").textContent = RUN.foot ?? "";
1116
+ $("foot").style.display = RUN.foot ? "" : "none";
1117
+ draw();
1118
+ // The stream follows whichever run is on screen; switching PRs switches it.
1119
+ connect(RUN.slug ?? null);
1120
+ drawConvo();
1121
+ }
1122
+
1123
+ /* ── Conversation ─────────────────────────────────────────────────────
1124
+ Rendered from the event stream, not from run.json. The timeline needs a
1125
+ folded run; a conversation needs the events in the order they were spoken,
1126
+ and folding throws that order away.
1127
+
1128
+ Side is fixed by speaker, never by turn order: claude left, reviewer right.
1129
+ A reader should be able to tell who is talking from across the room. */
1130
+ let THREADS = []; // [{agent, turns:[…]}]
1131
+ let CONVO_STACK = false; // one thread full width, or every thread stacked
1132
+ let CONVO_PICK = null; // which thread, when not stacked
1133
+ const CONVO_SHUT = new Set(); // collapsed threads, by agent, while stacked
1134
+
1135
+ const SIDE = (who) => (who === "claude" ? "l" : "r");
1136
+
1137
+ // A stable colour per agent. codex and droid have tokens already; anything else
1138
+ // is hashed to a hue so a five-reviewer run does not render five identical
1139
+ // chips — and so the same agent keeps its colour across reloads.
1140
+ const HUES = {
1141
+ claude: "var(--main-hue)", codex: "var(--codex)", droid: "var(--droid)",
1142
+ grok: "var(--ink-2)", agy: "var(--agy)",
1143
+ };
1144
+ function hueFor(name) {
1145
+ if (HUES[name]) return HUES[name];
1146
+ let h = 0;
1147
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
1148
+ return `hsl(${Math.abs(h) % 360} 42% 45%)`;
1149
+ }
1150
+
1151
+ const VERDICT_TEXT = {
1152
+ accepted: "Accepted — fixed",
1153
+ deferred: "Agreed, but not fixing here",
1154
+ rejected: "Rejected",
1155
+ superseded: "Superseded",
1156
+ };
1157
+
1158
+ // Long reports collapse. A twenty-minute reviewer turn can be thousands of
1159
+ // lines, and an un-collapsed one buries every exchange after it.
1160
+ const CLAMP = 700;
1161
+ /* The collapsing half of a bubble, on its own so that bubbles which lead with a
1162
+ header — a verdict, a named claim — can clamp their body without clamping the
1163
+ header along with it. */
1164
+ /**
1165
+ * Every message in full, always. Nothing is truncated and nothing hides behind
1166
+ * a button: reading a review should not mean clicking through it.
1167
+ *
1168
+ * The reason a clamp existed is still real — evidence arrives verbatim from
1169
+ * `jury finding reproduce --evidence` and is routinely thousands of lines, and
1170
+ * one such turn inlined at full height buries every turn after it. So a long
1171
+ * message scrolls WITHIN ITS OWN BOX instead of being cut: the whole text is in
1172
+ * the DOM, selectable and searchable with the browser's own find, while the
1173
+ * thread below it stays reachable. Bounded height, not bounded content.
1174
+ */
1175
+ function clamped(text) {
1176
+ const t = text ?? "";
1177
+ if (t.length <= CLAMP) return esc(t);
1178
+ return `<div class="tall" tabindex="0" role="region"
1179
+ aria-label="long message, ${bytes(t.length)}, scrollable">${esc(t)}</div>`;
1180
+ }
1181
+ function bubble(text, k, attrs = "") {
1182
+ return `<div class="bub" data-k="${k}" ${attrs}>${clamped(text)}</div>`;
1183
+ }
1184
+ /** m:ss, or h:mm:ss once a reviewer has been going that long. */
1185
+ function hms(sec) {
1186
+ const s = Math.max(0, Math.floor(sec));
1187
+ const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60;
1188
+ return h
1189
+ ? `${h}:${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`
1190
+ : `${m}:${String(r).padStart(2, "0")}`;
1191
+ }
1192
+
1193
+ function hash(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return h; }
1194
+
1195
+ /**
1196
+ * When a turn was spoken.
1197
+ *
1198
+ * Relative inline, absolute on hover. An offset ("+4m") is what you want when
1199
+ * reading the exchange as a conversation; the wall clock is what you want when
1200
+ * correlating against a log, and that is the rarer need. Pinned to SGT like
1201
+ * everything else here: a review record should read the same wherever it is
1202
+ * opened.
1203
+ */
1204
+ let CONVO_T0 = null;
1205
+ let CONVO_SPREAD = 0;
1206
+ let CONVO_PREV = null;
1207
+ function stampFor(t) {
1208
+ if (!t?.ts) return "";
1209
+ const at = Date.parse(t.ts);
1210
+ if (Number.isNaN(at)) return "";
1211
+ const clock = clockFmt.format(new Date(at));
1212
+ const abs = `${clock} ${TZ_LABEL}`;
1213
+ // Offsets only say something when the turn is actually separated from what
1214
+ // came before it. Every finding parsed from one report is appended in the
1215
+ // same millisecond, so a whole batch reads "+0s" — truthful and useless.
1216
+ //
1217
+ // Decided per turn, not per thread. Keying it on the thread's total spread
1218
+ // was all-or-nothing: one slow triage pushed the spread over a minute and
1219
+ // then the same-instant batch of findings ABOVE it rendered "+0s" anyway,
1220
+ // which is the case this rule exists to avoid.
1221
+ if (CONVO_T0 === null || CONVO_SPREAD < 60000) {
1222
+ return `<time title="${esc(abs)}">${esc(clock)}</time>`;
1223
+ }
1224
+ const d = Math.max(0, Math.round((at - CONVO_T0) / 1000));
1225
+ // Within a minute of the turn before it, this one carries no offset worth
1226
+ // reading; the wall clock is the more informative of the two.
1227
+ const gap = CONVO_PREV === null ? d * 1000 : at - CONVO_PREV;
1228
+ CONVO_PREV = at;
1229
+ if (d === 0 || gap < 60000) {
1230
+ return `<time title="${esc(abs)}">${esc(clock)}</time>`;
1231
+ }
1232
+ const rel = d < 60 ? `+${d}s` : d < 3600 ? `+${Math.round(d / 60)}m` : `+${(d / 3600).toFixed(1)}h`;
1233
+ return `<time title="${esc(abs)}">${esc(rel)}</time>`;
1234
+ }
1235
+
1236
+ function turnHTML(t, agent) {
1237
+ const side = SIDE(t.who);
1238
+ const nm = t.who === "claude" ? "claude" : agent;
1239
+ let body = "";
1240
+ switch (t.kind) {
1241
+ case "finding":
1242
+ body = `<div class="bub" data-k="finding"><span class="hd">${esc(t.claim ?? "")}</span
1243
+ >${t.loc ? `<span class="loc">${esc(t.loc)}</span>\n` : ""}${esc((t.body ?? "").slice(0, CLAMP))}</div>`;
1244
+ break;
1245
+ case "reproduced":
1246
+ // Evidence is whatever the reviewer passed to `finding reproduce
1247
+ // --evidence`, which is routinely a wall of captured command output. It
1248
+ // collapses, or one reproduction buries every turn below it.
1249
+ body = `<div class="bub" data-k="reproduced"
1250
+ >${t.claim ? `<span class="re">${esc(t.claim)}</span>\n` : ""}Reproduced. ${clamped(t.text ?? "")}</div>`;
1251
+ break;
1252
+ case "verdict":
1253
+ // The claim is named here because the findings are no longer bubbles of
1254
+ // their own: without it a verdict floats free of what it answers.
1255
+ body = `<div class="bub" data-k="verdict" data-v="${esc(t.verdict ?? "")}"
1256
+ ><span class="hd">${esc(VERDICT_TEXT[t.verdict] ?? t.verdict ?? "")}</span
1257
+ >${t.claim ? `<span class="re">${esc(t.claim)}</span>\n` : ""}${esc(t.text ?? "")}${t.test ? `\n\nRegression test: ${esc(t.test)} — verified failing with the fix reverted.` : ""}</div>`;
1258
+ break;
1259
+ case "rebuttal":
1260
+ // A reviewer's rebuttal carries no text of its own: it was an excerpt of
1261
+ // the reply rendered directly above it. What it marks is that the claim is
1262
+ // being re-argued, so it names the claim and leaves the words to the
1263
+ // report. Claude's turn in the thread does carry text, and shows it.
1264
+ body = `<div class="bub" data-k="rebuttal"
1265
+ ><span class="hd">${t.who === "claude" ? "still open" : "not convinced"}</span
1266
+ >${t.claim ? `<span class="re">${esc(t.claim)}</span>` : ""}${t.text ? `\n${esc(t.text)}` : ""}</div>`;
1267
+ break;
1268
+ case "commit":
1269
+ body = `<div class="bub" data-k="commit">pushed ${esc(t.sha ?? "")}${t.text ? ` · ${esc(t.text)}` : ""}</div>`;
1270
+ break;
1271
+ case "waiting": {
1272
+ // Counted up client-side between heartbeats, so it ticks every second
1273
+ // rather than jumping in five-second steps.
1274
+ // An agent that streamed and then went quiet says so: "working 6:12 ·
1275
+ // quiet 2m" is the difference between thinking and wedged, and the
1276
+ // heartbeat alone cannot express it.
1277
+ const quiet = t.quietSeconds
1278
+ ? ` <span class="q">· quiet ${esc(hms(t.quietSeconds))}</span>`
1279
+ : "";
1280
+ body = `<div class="bub" data-k="waiting" data-since="${Date.now() - (t.seconds ?? 0) * 1000}"
1281
+ >working <b class="tick">${esc(hms(t.seconds ?? 0))}</b>${quiet}</div>`;
1282
+ break;
1283
+ }
1284
+ case "streaming":
1285
+ // The tail only: a live agent's stdout is the transcript so far, and the
1286
+ // interesting part is always the end of it.
1287
+ body = `<div class="bub" data-k="streaming">${clamped(t.text ?? "")}</div>`;
1288
+ break;
1289
+ case "report":
1290
+ body = bubble(t.text ?? "", "report");
1291
+ break;
1292
+ case "reply":
1293
+ body = bubble(t.text ?? "(reply sent)", "reply");
1294
+ break;
1295
+ case "answer":
1296
+ body = bubble(t.text ?? "", "answer");
1297
+ break;
1298
+ default:
1299
+ body = bubble(t.text ?? "", esc(t.kind ?? "turn"));
1300
+ }
1301
+ return `<div class="msg" data-side="${side}"><span class="nm"
1302
+ ><span class="ic" style="color:${nm === "claude" ? "var(--main-hue)" : hueFor(nm)}">${icon(nm)}</span>${esc(nm)}${stampFor(t)}</span>${body}</div>`;
1303
+ }
1304
+
1305
+ /**
1306
+ * `?view=conversation` renders the thread alone, filling the window, so it can
1307
+ * sit in a second window beside the terminal while a round runs. It is the same
1308
+ * fold and the same stream as the embedded view — CSS hides the rest of the
1309
+ * page — so there is no second implementation to keep in step.
1310
+ */
1311
+ const POPPED = new URL(location.href).searchParams.get("view") === "conversation";
1312
+ if (POPPED) document.body.dataset.view = "conversation";
1313
+
1314
+ function drawConvo() {
1315
+ const tabs = $("convo-tabs"), grid = $("convo-grid"), sec = $("convo-sec");
1316
+ if (!tabs || !grid) return;
1317
+ const threads = THREADS.filter((t) => t.agent && t.agent !== "?");
1318
+ // Popped out there is nothing else on the page, so hiding the only section
1319
+ // would leave a blank window rather than an empty-looking one.
1320
+ if (!threads.length) { sec.style.display = POPPED ? "" : "none"; return; }
1321
+ sec.style.display = "";
1322
+
1323
+ // The popout carries the run it was opened from, so the link is shareable and
1324
+ // survives a reload.
1325
+ const pop = $("convo-pop");
1326
+ if (pop) {
1327
+ const u = new URL(location.href);
1328
+ u.searchParams.set("view", "conversation");
1329
+ if (RUN?.slug) u.searchParams.set("run", RUN.slug);
1330
+ pop.href = u.pathname + u.search;
1331
+ }
1332
+ if (POPPED) {
1333
+ const who = threads.map((t) => t.agent).join(", ");
1334
+ document.title = RUN?.target?.id
1335
+ ? `${RUN.target.id} · ${who} — conversation`
1336
+ : "conversation";
1337
+ }
1338
+
1339
+ const names = threads.map((t) => t.agent);
1340
+ if (!names.includes(CONVO_PICK)) CONVO_PICK = names[0];
1341
+ const live = (a) => threads.find((t) => t.agent === a)?.turns.some((x) => x.kind === "streaming");
1342
+
1343
+ // The count sits on the tab because with five reviewers the question is
1344
+ // which one to read, and that is answerable from turn count and liveness
1345
+ // without opening any of them.
1346
+ tabs.innerHTML = names.map((a) => {
1347
+ const t = threads.find((x) => x.agent === a);
1348
+ const on = !CONVO_STACK && CONVO_PICK === a;
1349
+ // The agent's own mark, not a coloured dot: with five reviewers the name is
1350
+ // the slowest thing on the chip to read, and an unknown agent still gets a
1351
+ // legible fallback glyph.
1352
+ return `<button class="pr-chip" data-ctab="${esc(a)}" aria-pressed="${on}"
1353
+ ><span class="ic" style="color:${hueFor(a)}">${icon(a)}</span
1354
+ >${esc(a)}<span class="n" data-live="${live(a) ? 1 : 0}">${t.turns.length}</span></button>`;
1355
+ }).join("");
1356
+
1357
+ const btn = $("convo-stack");
1358
+ if (btn) {
1359
+ btn.setAttribute("aria-pressed", String(CONVO_STACK));
1360
+ btn.textContent = CONVO_STACK ? `one at a time` : `stack all ${names.length}`;
1361
+ }
1362
+
1363
+ const shown = CONVO_STACK ? threads : threads.filter((t) => t.agent === CONVO_PICK);
1364
+ grid.dataset.stack = CONVO_STACK ? "1" : "0";
1365
+ grid.innerHTML = shown.map((t) => {
1366
+ const open = !CONVO_STACK || !CONVO_SHUT.has(t.agent);
1367
+ const l = live(t.agent);
1368
+ // Offsets are measured from this thread's own first turn, not the run's:
1369
+ // each conversation reads from its own beginning.
1370
+ const times = t.turns.map((x) => x.ts).filter(Boolean).map(Date.parse)
1371
+ .filter((n) => !Number.isNaN(n)).sort((a, b) => a - b);
1372
+ CONVO_T0 = times[0] ?? null;
1373
+ CONVO_SPREAD = times.length > 1 ? times[times.length - 1] - times[0] : 0;
1374
+ // Per thread: turns render in order, so each stamp compares against the
1375
+ // previous turn of THIS conversation.
1376
+ CONVO_PREV = null;
1377
+ const msgs = t.turns.length
1378
+ ? t.turns.map((x) => turnHTML(x, t.agent)).join("")
1379
+ : `<p class="empty">waiting for ${esc(t.agent)}…</p>`;
1380
+ return `<div class="convo" data-a="${esc(t.agent)}" data-open="${open ? 1 : 0}">
1381
+ <header data-fold-agent="${esc(t.agent)}"><span class="tw">${open ? "▾" : "▸"}</span
1382
+ ><span class="d" data-live="${l ? 1 : 0}"></span>
1383
+ <span class="who"><span class="ic" style="color:var(--main-hue)">${icon("claude")}</span>claude
1384
+ <span class="sw">⇄</span>
1385
+ <span class="ic" style="color:${hueFor(t.agent)}">${icon(t.agent)}</span>${esc(t.agent)}</span>
1386
+ <span class="st">${t.turns.length} turn${t.turns.length === 1 ? "" : "s"}${l ? " · live" : ""}</span>
1387
+ </header>
1388
+ <div class="msgs" data-agent="${esc(t.agent)}">${msgs}</div>
1389
+ </div>`;
1390
+ }).join("");
1391
+
1392
+ for (const box of grid.querySelectorAll(".msgs")) {
1393
+ if (box.dataset.at !== "0") box.scrollTop = box.scrollHeight;
1394
+ }
1395
+ }
1396
+
1397
+ document.addEventListener("click", (e) => {
1398
+ const tab = e.target.closest("[data-ctab]");
1399
+ if (tab) {
1400
+ // Picking a reviewer while stacked means "show me that one", not "keep
1401
+ // everything stacked and scroll to it".
1402
+ CONVO_STACK = false;
1403
+ CONVO_PICK = tab.dataset.ctab;
1404
+ // The exchange below follows this selection, so it has to move with it.
1405
+ drawConvo(); draw();
1406
+ return;
1407
+ }
1408
+ if (e.target.closest("#convo-stack")) { CONVO_STACK = !CONVO_STACK; drawConvo(); draw(); return; }
1409
+ const fold = e.target.closest("[data-fold-agent]");
1410
+ if (fold && CONVO_STACK) {
1411
+ const a = fold.dataset.foldAgent;
1412
+ CONVO_SHUT.has(a) ? CONVO_SHUT.delete(a) : CONVO_SHUT.add(a);
1413
+ drawConvo();
1414
+ return;
1415
+ }
1416
+ });
1417
+
1418
+ // Remember whether each column was pinned to the bottom before a re-render.
1419
+ document.addEventListener("scroll", (e) => {
1420
+ const box = e.target.closest?.(".msgs");
1421
+ if (!box) return;
1422
+ const atEnd = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
1423
+ box.dataset.at = atEnd ? "1" : "0";
1424
+ }, true);
1425
+
1426
+ /* Live events. The append-only log is the transport: the server replays it on
1427
+ connect and pushes each new line, so a page opened mid-round catches up and
1428
+ then follows in real time. Reconnects on drop — a run outlives any one
1429
+ connection, and a console that silently stops updating is worse than one
1430
+ that never started. */
1431
+ // One timer for every waiting bubble on the page. The heartbeat lands every
1432
+ // five seconds; this makes the display move every second in between, so a
1433
+ // reviewer that is alive looks alive rather than frozen.
1434
+ setInterval(() => {
1435
+ for (const el of document.querySelectorAll('.bub[data-k="waiting"]')) {
1436
+ const since = Number(el.dataset.since);
1437
+ if (!since) continue;
1438
+ const b = el.querySelector(".tick");
1439
+ if (b) b.textContent = hms((Date.now() - since) / 1000);
1440
+ }
1441
+ }, 1000);
1442
+
1443
+ let ES = null;
1444
+ let ES_SLUG = null;
1445
+ let EVENTS = [];
1446
+ let REPLAY = []; // events arriving during a reconnect replay
1447
+ let replaying = true; // the first batch after connecting is always a replay
1448
+
1449
+ function connect(slug) {
1450
+ if (!slug || slug === ES_SLUG) return;
1451
+ ES_SLUG = slug;
1452
+ ES?.close();
1453
+ EVENTS = [];
1454
+ REPLAY = [];
1455
+ replaying = true;
1456
+ try {
1457
+ ES = new EventSource(`api/stream?run=${encodeURIComponent(slug)}`);
1458
+ } catch { return; }
1459
+ ES.addEventListener("event", (m) => {
1460
+ let e;
1461
+ try { e = JSON.parse(m.data); } catch { return; }
1462
+ (replaying ? REPLAY : EVENTS).push(e);
1463
+ if (!replaying) schedule();
1464
+ });
1465
+ // EventSource reconnects on its own, and the server replays the log from byte
1466
+ // zero every time it does. Appending that replay to history we already hold
1467
+ // duplicated every finding and reply once per dropped connection, and the
1468
+ // duplicates compounded. "ready" ends a replay, so the events that arrived
1469
+ // during it ARE the whole history: keep those and drop what came before.
1470
+ ES.addEventListener("ready", () => {
1471
+ if (REPLAY.length) { EVENTS = REPLAY.slice(); REPLAY = []; }
1472
+ replaying = false;
1473
+ schedule();
1474
+ });
1475
+ ES.onerror = () => {
1476
+ // The next batch is a fresh replay, not a continuation.
1477
+ replaying = true;
1478
+ REPLAY = [];
1479
+ };
1480
+ }
1481
+
1482
+ // Chunks arrive faster than anyone can read. Coalesce a burst into one paint.
1483
+ let pending = null;
1484
+ function schedule() {
1485
+ if (pending) return;
1486
+ pending = setTimeout(() => {
1487
+ pending = null;
1488
+ THREADS = foldConversation(EVENTS);
1489
+ drawConvo();
1490
+ }, 120);
1491
+ }
1492
+
1493
+ /* The same fold as lib/loop.js conversation(), in the browser: applying it to
1494
+ the live stream avoids a round trip per token, and the server-side copy stays
1495
+ authoritative for anything that reads a finished run. */
1496
+ function foldConversation(events) {
1497
+ const threads = new Map();
1498
+ const thread = (a) => {
1499
+ let t = threads.get(a);
1500
+ if (!t) threads.set(a, (t = { agent: a, turns: [] }));
1501
+ return t;
1502
+ };
1503
+ const owner = new Map();
1504
+ const claimOf = new Map();
1505
+ for (const e of events) {
1506
+ switch (e.t) {
1507
+ case "finding.raised":
1508
+ owner.set(e.id, e.agent);
1509
+ // Deliberately NOT a turn of its own. The reviewer already said this in
1510
+ // its report, verbatim and in its own shape; a parsed copy underneath
1511
+ // is the same words twice. The claim is kept only so a verdict further
1512
+ // down can name what it is answering.
1513
+ claimOf.set(e.id, e.claim ?? "");
1514
+ // The reviewer still gets a thread even if this is the only thing it
1515
+ // ever emitted: in production a report always precedes its findings,
1516
+ // but a console that hides a reviewer entirely when it does not is
1517
+ // worse than one that shows an empty column.
1518
+ thread(e.agent);
1519
+ break;
1520
+ case "finding.reproduced":
1521
+ thread(owner.get(e.id) ?? "?").turns.push({ who: "claude", kind: "reproduced",
1522
+ id: e.id, claim: claimOf.get(e.id) ?? "",
1523
+ text: typeof e.evidence === "string" ? e.evidence : "", ts: e.ts });
1524
+ break;
1525
+ case "finding.resolved":
1526
+ thread(owner.get(e.id) ?? "?").turns.push({ who: "claude", kind: "verdict",
1527
+ id: e.id, claim: claimOf.get(e.id) ?? "",
1528
+ verdict: e.verdict, text: e.reason ?? "", test: e.test ?? "", ts: e.ts });
1529
+ break;
1530
+ case "finding.turn": {
1531
+ // A reviewer's rebuttal is an excerpt cut from the reply it arrived in,
1532
+ // and that reply is already rendered whole as its `answer` turn — so the
1533
+ // text is deliberately dropped, exactly as a raised finding's is. What
1534
+ // survives is the linkage: which claim is being re-argued, and by whom.
1535
+ // Claude's own turn is the exception: it is not an excerpt of anything
1536
+ // shown elsewhere, so dropping its text would lose it entirely.
1537
+ const who = e.who ?? "?";
1538
+ const turn = { who, kind: "rebuttal", id: e.id,
1539
+ claim: claimOf.get(e.id) ?? "", ts: e.ts };
1540
+ if (who === "claude") turn.text = e.text ?? "";
1541
+ thread(owner.get(e.id) ?? e.agent ?? "?").turns.push(turn);
1542
+ break;
1543
+ }
1544
+ // A reviewer that does not stream. codex writes nothing until it exits,
1545
+ // so without this its column is empty for nine minutes and "thinking" is
1546
+ // indistinguishable from "wedged".
1547
+ case "agent.alive": {
1548
+ // `forAgent` names the reviewer whose finding is being judged, so the
1549
+ // main agent's heartbeat lands inside that reviewer's conversation
1550
+ // rather than opening a thread of its own.
1551
+ const t = thread(e.forAgent ?? e.agent);
1552
+ // Found by round, not by position. Two rounds running concurrently
1553
+ // interleave their heartbeats, so "is it the last turn?" failed on
1554
+ // every alternation and a nine-minute round grew 81 bubbles instead of
1555
+ // one.
1556
+ const w = t.turns.find(
1557
+ (x) => x.kind === "waiting" && x.round === e.round && x.who === e.agent,
1558
+ );
1559
+ if (w) { w.seconds = e.seconds; w.quietSeconds = e.quietSeconds ?? null; break; }
1560
+ // Real output already supersedes it; do not regress to a placeholder.
1561
+ // Per speaker, not per thread: the reviewer's report ends the reviewer's
1562
+ // turn, but the main agent triaging afterwards is a new speaker on the
1563
+ // same thread — keying this on the thread dropped every triage
1564
+ // heartbeat, which is the longest silence in the round and the reason
1565
+ // the heartbeat exists.
1566
+ if (t.turns.some((x) => (x.kind === "streaming" || x.kind === "report") && x.round === e.round && x.who === e.agent)) break;
1567
+ t.turns.push({ who: e.agent, kind: "waiting", round: e.round, seconds: e.seconds, ts: e.ts });
1568
+ break;
1569
+ }
1570
+ case "agent.chunk": {
1571
+ const t = thread(e.agent);
1572
+ const last = t.turns[t.turns.length - 1];
1573
+ if (last?.kind === "streaming" && last.round === e.round) last.text += e.text ?? "";
1574
+ else {
1575
+ const w = t.turns.findIndex((x) => x.kind === "waiting" && x.round === e.round);
1576
+ const turn = { who: e.agent, kind: "streaming", round: e.round, text: e.text ?? "", ts: e.ts };
1577
+ if (w >= 0) t.turns.splice(w, 1, turn); else t.turns.push(turn);
1578
+ }
1579
+ break;
1580
+ }
1581
+ case "agent.report": {
1582
+ const t = thread(e.agent);
1583
+ const i = t.turns.findIndex(
1584
+ (x) => (x.kind === "streaming" || x.kind === "waiting") && x.round === e.round,
1585
+ );
1586
+ const turn = { who: e.agent, kind: "report", round: e.round,
1587
+ verdict: e.verdict, text: e.report ?? "", seconds: e.seconds, ts: e.ts };
1588
+ if (i >= 0) t.turns.splice(i, 1, turn); else t.turns.push(turn);
1589
+ break;
1590
+ }
1591
+ case "reply.sent":
1592
+ thread(e.agent).turns.push({ who: "claude", kind: "reply", text: e.text ?? "", ts: e.ts });
1593
+ break;
1594
+ case "reply.answered":
1595
+ thread(e.agent).turns.push({ who: e.agent, kind: "answer",
1596
+ text: e.report ?? "", verdict: e.verdict, seconds: e.seconds, ts: e.ts });
1597
+ break;
1598
+ case "commit.pushed":
1599
+ for (const t of threads.values()) {
1600
+ t.turns.push({ who: "claude", kind: "commit", sha: e.sha, text: e.subject ?? "", ts: e.ts });
1601
+ }
1602
+ break;
1603
+ }
1604
+ }
1605
+ return [...threads.values()];
1606
+ }
1607
+
1608
+ let ALL = [RUN];
1609
+ let sig = null; // last payload seen, so an unchanged poll costs nothing
1610
+ let assetStamp = null; // index.html mtime; a change means reload, not re-render
1611
+ let served = false;
1612
+
1613
+ /** Put the selected run in the query string, as a history entry you can go back through. */
1614
+ function setRunParam(slug, { replace = false } = {}) {
1615
+ if (!slug) return;
1616
+ const url = new URL(location.href);
1617
+ if (url.searchParams.get("run") === slug) return;
1618
+ url.searchParams.set("run", slug);
1619
+ history[replace ? "replaceState" : "pushState"]({ run: slug }, "", url);
1620
+ }
1621
+
1622
+ /** The run named by ?run=, if it is one we actually have. */
1623
+ function runFromParam(runs) {
1624
+ const want = new URL(location.href).searchParams.get("run");
1625
+ if (!want) return null;
1626
+ return runs.find((r) => r.slug === want) ?? null;
1627
+ }
1628
+
1629
+ // Back and forward move between PRs rather than leaving the console.
1630
+ addEventListener("popstate", () => {
1631
+ const pick = runFromParam(ALL);
1632
+ if (pick && pick !== RUN) { RUN = pick; render(); }
1633
+ });
1634
+
1635
+ function stamp(note) {
1636
+ const el = $("t-kicker");
1637
+ if (!el || !served) return;
1638
+ const t = new Date().toLocaleTimeString();
1639
+ el.textContent = `${el.textContent.replace(/ · (updated|live).*$/, "")} · updated ${t}${note ?? ""}`;
1640
+ }
1641
+
1642
+ async function poll({ first = false } = {}) {
1643
+ let payload = null;
1644
+ try {
1645
+ const res = await fetch("api/run", { cache: "no-store" });
1646
+ if (res.ok) payload = await res.text();
1647
+ } catch (_) {
1648
+ return false; // standalone, or the server went away — keep what is on screen
1649
+ }
1650
+
1651
+ // The page itself may have been rebuilt while this tab was open.
1652
+ try {
1653
+ const h = await (await fetch("api/health", { cache: "no-store" })).json();
1654
+ if (h?.assets) {
1655
+ if (assetStamp && h.assets !== assetStamp) { location.reload(); return true; }
1656
+ assetStamp = h.assets;
1657
+ }
1658
+ } catch (_) { /* health is optional */ }
1659
+
1660
+ if (payload === null) return first ? false : true;
1661
+ served = true;
1662
+ if (payload === sig) { stamp(); return true; }
1663
+ sig = payload;
1664
+
1665
+ let live;
1666
+ try { live = JSON.parse(payload); } catch { return true; }
1667
+ const runs = Array.isArray(live?.targets) ? live.targets : live ? [live] : [];
1668
+ const usable = runs.filter((x) => x && x.target && Array.isArray(x.rounds));
1669
+ if (!usable.length) return true;
1670
+
1671
+ // Keep whatever the reader was looking at when new runs arrive. ?run= wins:
1672
+ // it is an explicit request for one PR, and a poll that quietly moved off it
1673
+ // would make the URL a lie.
1674
+ const keep = RUN?.slug;
1675
+ ALL = usable;
1676
+ RUN = runFromParam(usable)
1677
+ ?? usable.find((r) => r.slug === keep)
1678
+ ?? usable[0];
1679
+ // Stamp the address on first load so what is on screen is always linkable,
1680
+ // without pushing a history entry nobody asked for.
1681
+ setRunParam(RUN?.slug, { replace: true });
1682
+ render();
1683
+ stamp();
1684
+ return true;
1685
+ }
1686
+
1687
+ (async () => {
1688
+ const ok = await poll({ first: true });
1689
+ render();
1690
+ if (!ok) return; // opened standalone: nothing to poll, embedded run stands
1691
+
1692
+ let timer = setInterval(poll, 3000);
1693
+ // Do not poll a tab nobody is looking at; refresh at once when it returns.
1694
+ document.addEventListener("visibilitychange", () => {
1695
+ clearInterval(timer);
1696
+ if (!document.hidden) { poll(); timer = setInterval(poll, 3000); }
1697
+ });
1698
+ })();
1699
+ </script>