bareloop 0.28.0 → 0.30.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.
@@ -0,0 +1,2156 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>bareloop panel</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap" rel="stylesheet">
10
+ <style>
11
+ /* Reused verbatim from design/panel-mockup.html (the visual contract, PANEL-BUILD.md §6) —
12
+ Tokyo Night box-drawing TUI look, same tokens, same responsive breakpoint. Fake-data rows
13
+ and the Settings/Chat-authoring styling are kept (Settings/Chat are out of P1's scope but
14
+ the CSS is harmless dead weight rather than a second stylesheet to maintain). */
15
+ :root{
16
+ --bg:#1a1b26; --panel:#1f2335; --panel2:#24283b; --border:#292e42; --border-strong:#3b4261;
17
+ --text:#c0caf5; --text-dim:#a9b1d6; --text-faint:#565f89;
18
+ --green:#9ece6a; --green-bg:#1c2b23;
19
+ --red:#f7768e; --red-bg:#2b1822;
20
+ --amber:#e0af68; --amber-bg:#2b2416;
21
+ --grey-dot:#878eac; --grey-bg:#20242f;
22
+ --cyan:#7dcfff; --cyan-bg:#182634;
23
+ --magenta:#bb9af7;
24
+ --accent:#7aa2f7;
25
+ --btn-bg:#1f2335; --btn-text:#c0caf5;
26
+ --primary-bg:#7aa2f7; --primary-text:#1a1b26; --primary-border:#7aa2f7;
27
+ font: 14px/1.5 "Courier Prime", "Courier New", Courier, ui-monospace, monospace;
28
+ }
29
+ *{box-sizing:border-box;}
30
+ html,body{margin:0;padding:0;background:var(--bg);color:var(--text);}
31
+ body{overflow-x:hidden;}
32
+ button{font:inherit;cursor:pointer;}
33
+ table{border-collapse:collapse;width:100%;}
34
+ th,td{text-align:left;padding:6px 8px;border-bottom:1px dotted var(--border-strong);font-size:12px;}
35
+ th{color:var(--text-dim);font-weight:700;background:var(--panel2);text-transform:uppercase;letter-spacing:0.04em;font-size:11px;border-bottom:1px solid var(--border-strong);}
36
+ /* item: Audit tab header stays visible while the row list scrolls. On
37
+ desktop >=900px .pane-body itself is height-bound and scrolls (see the
38
+ @media(min-width:900px) block above); on mobile #BareloopPanel/.main
39
+ are unbound and the PAGE scrolls instead, so .pane-body never actually
40
+ develops its own scroll offset there and a sticky th anchored to it
41
+ would scroll away with the page. Giving the table its own bounded,
42
+ always-scrolling wrapper makes the header genuinely sticky on both
43
+ breakpoints, and also owns the horizontal scroll for the Path column
44
+ on narrow (390px) screens instead of leaving it to .pane-body. */
45
+ .audit-table-scroll{overflow:auto;max-height:min(60vh,520px);}
46
+ /* z-index keeps rows from showing through the solid th background as
47
+ they scroll under it. */
48
+ [data-testid="audit-table"] th{position:sticky;top:0;z-index:1;}
49
+ td{border-right:1px dotted var(--border);}
50
+ td:last-child{border-right:none;}
51
+ a{color:var(--accent);text-decoration:underline;}
52
+ :focus-visible{outline:2px solid var(--accent);outline-offset:2px;}
53
+
54
+ @media (prefers-color-scheme: light){
55
+ :root:not([data-theme="dark"]){
56
+ --bg:#e1e2e7; --panel:#d0d5e3; --panel2:#e9e9ed; --border:#c4c8da; --border-strong:#a8b0cc;
57
+ --text:#3760bf; --text-dim:#58679f; --text-faint:#848cb5;
58
+ --green:#567238; --green-bg:#dfe7d4;
59
+ --red:#c92353; --red-bg:#fbe0e8;
60
+ --amber:#82643a; --amber-bg:#f0e6d3;
61
+ --grey-dot:#626887; --grey-bg:#e2e2e8;
62
+ --cyan:#007197; --cyan-bg:#d6ecf1;
63
+ --magenta:#9854f1;
64
+ --accent:#2668c2;
65
+ --btn-bg:#e9e9ed; --btn-text:#3760bf;
66
+ --primary-bg:#2668c2; --primary-text:#e1e2e7; --primary-border:#2668c2;
67
+ }
68
+ }
69
+ :root[data-theme="light"]{
70
+ --bg:#e1e2e7; --panel:#d0d5e3; --panel2:#e9e9ed; --border:#c4c8da; --border-strong:#a8b0cc;
71
+ --text:#3760bf; --text-dim:#58679f; --text-faint:#848cb5;
72
+ --green:#567238; --green-bg:#dfe7d4;
73
+ --red:#c92353; --red-bg:#fbe0e8;
74
+ --amber:#82643a; --amber-bg:#f0e6d3;
75
+ --grey-dot:#626887; --grey-bg:#e2e2e8;
76
+ --cyan:#007197; --cyan-bg:#d6ecf1;
77
+ --magenta:#9854f1;
78
+ --accent:#2668c2;
79
+ --btn-bg:#e9e9ed; --btn-text:#3760bf;
80
+ --primary-bg:#2668c2; --primary-text:#e1e2e7; --primary-border:#2668c2;
81
+ }
82
+ .theme-toggle{padding:4px 9px;min-height:32px;border:1px solid var(--border-strong);background:var(--btn-bg);color:var(--text);}
83
+
84
+ h2,h4{text-transform:uppercase;letter-spacing:0.06em;font-weight:700;}
85
+
86
+ #BareloopPanel{display:flex;flex-direction:column;min-height:100vh;}
87
+ /* item 4 (2026-09-25): on desktop the left list scrolls inside its own
88
+ pane and the right pane stays in view — the PAGE itself never scrolls
89
+ the list away. Bounding #BareloopPanel/.main to the viewport height
90
+ (desktop only — phone keeps its natural stacked-page scroll, see the
91
+ max-width:899px block below) is what lets each .pane-body's own
92
+ `overflow:auto` actually take effect instead of the whole document
93
+ growing taller than 100vh and dragging everything, list included, up
94
+ off screen together. */
95
+ @media (min-width: 900px){
96
+ html, body{height:100%;}
97
+ #BareloopPanel{height:100vh;min-height:0;overflow:hidden;}
98
+ .main{overflow:hidden;}
99
+ }
100
+ .topbar{
101
+ display:flex;align-items:center;justify-content:space-between;
102
+ padding:10px 16px;border-bottom:2px solid var(--border-strong);
103
+ background:var(--panel);flex-wrap:wrap;gap:8px;
104
+ }
105
+ .topbar > div:first-child{display:flex;align-items:baseline;gap:0;white-space:nowrap;overflow:hidden;}
106
+ .topbar .brand{font-weight:700;font-size:17px;letter-spacing:0.06em;flex:none;}
107
+ .topbar .brand::before{content:"■ ";}
108
+ .topbar .tagline{font-size:12px;color:var(--text-dim);margin-left:8px;overflow:hidden;text-overflow:ellipsis;}
109
+ .topbar .tagline::before{content:"· ";color:var(--text-faint);}
110
+ @media (max-width: 600px){ .topbar .tagline{display:none;} }
111
+ .topbar-right{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
112
+
113
+ [hidden]{display:none !important;}
114
+ .main{display:flex;flex:1;min-height:0;}
115
+ .left-pane{width:420px;flex:none;border-right:1px solid var(--border-strong);display:flex;flex-direction:column;background:var(--panel);}
116
+ .right-pane{flex:1;min-width:0;display:flex;flex-direction:column;}
117
+
118
+ .tabrow{display:flex;border-bottom:1px solid var(--border-strong);background:var(--panel2);counter-reset:tabnum;}
119
+ .tabrow button{
120
+ flex:1;padding:10px 6px;min-height:44px;background:transparent;border:none;
121
+ border-right:1px dotted var(--border-strong);font-size:13px;color:var(--text-dim);
122
+ counter-increment:tabnum;
123
+ }
124
+ .tabrow button::before{content:"[" counter(tabnum) "] ";color:var(--text-faint);font-weight:700;}
125
+ .tabrow button:last-child{border-right:none;}
126
+
127
+ .pane-body{padding:12px;overflow:auto;flex:1;min-height:0;}
128
+ .tabpanel{display:none;}
129
+ .tabpanel.active{display:block;}
130
+
131
+ .dot{display:inline-flex;align-items:center;justify-content:center;margin-right:6px;vertical-align:middle;font-weight:700;font-size:12px;}
132
+ .dot::before{content:"[·]";color:var(--grey-dot);}
133
+ .dot.green::before{content:"[✓]";color:var(--green);}
134
+ .dot.red::before{content:"[✗]";color:var(--red);}
135
+ /* Panel P2 defect 1 (hamr-watched run mujjtrvd, 2026-09-27): the [▶] glyph
136
+ itself pulses wherever a LIVE run shows it (runs list rows, the
137
+ Workflows job row when its represented run is live, the run header) —
138
+ `.dot.amber` is minted ONLY for glyph "▶" (glyphForOutcome,
139
+ src/panel/server.js: outcome null -> "▶", never for a died [?] or a
140
+ finished [✓]/[✗] run), so scoping the animation to this one selector
141
+ already excludes every other state, no extra "is it live" check needed
142
+ here. Same rhythm as the map box's own running-part dot (this file,
143
+ `buildMapSvg`'s `s.state === "running"` branch): opacity 1 -> 0.3 -> 1,
144
+ 1.2s, matching design/panel-mockup.html's pulsing amber dot exactly —
145
+ one fixed rhythm, never re-derived per call site. */
146
+ @keyframes pulse-glyph{0%,100%{opacity:1;}50%{opacity:0.3;}}
147
+ .dot.amber::before{content:"[▶]";color:var(--amber);animation:pulse-glyph 1.2s ease-in-out infinite;}
148
+ @media (prefers-reduced-motion: reduce){
149
+ .dot.amber::before{animation:none;}
150
+ }
151
+ .dot.grey::before{content:"[·]";color:var(--grey-dot);}
152
+ /* died (hamr's ruling B, 2026-09-25): no ending was ever recorded — a
153
+ DIFFERENT fact from a real [✗] close/arbiter "no", so it gets its own
154
+ glyph and its own color (magenta), never red or amber. */
155
+ .dot.magenta::before{content:"[?]";color:var(--magenta);}
156
+
157
+ .badge{display:inline-block;font-size:11px;padding:1px 4px;color:var(--text-dim);background:none;font-weight:700;}
158
+ .badge::before{content:"[";color:var(--text-faint);}
159
+ .badge::after{content:"]";color:var(--text-faint);}
160
+ .badge.green{color:var(--green);}
161
+ .badge.red{color:var(--red);}
162
+ .badge.amber{color:var(--amber);}
163
+ .badge.grey{color:var(--grey-dot);}
164
+ .badge.magenta{color:var(--magenta);}
165
+ .badge.cyan{color:var(--cyan);}
166
+
167
+ .hint{font-size:11px;color:var(--text-faint);margin-top:4px;}
168
+ .btn{padding:8px 14px;min-height:44px;border:1px solid var(--border-strong);background:var(--panel2);color:var(--text);white-space:nowrap;flex:none;position:relative;}
169
+ .btn::before{content:"[ ";color:var(--text-faint);}
170
+ .btn::after{content:" ]";color:var(--text-faint);}
171
+ .btn:disabled{color:var(--text-faint);border-color:var(--border);cursor:not-allowed;}
172
+ .btn:disabled::before,.btn:disabled::after{color:var(--text-faint);opacity:0.4;}
173
+ .btn.small{padding:0 9px;height:32px;min-height:0;font-size:12px;}
174
+
175
+ .wf-row,.hist-row{
176
+ display:flex;flex-direction:column;row-gap:4px;min-width:0;
177
+ padding:8px 8px 8px 18px;position:relative;border:1px solid var(--border);margin-bottom:6px;background:var(--panel2);cursor:pointer;
178
+ }
179
+ .wf-row::before{content:" ";position:absolute;left:6px;top:8px;color:var(--accent);font-weight:700;}
180
+ .wf-row:hover,.hist-row:hover{border-color:var(--border-strong);}
181
+ .wf-row.selected::before{content:">";}
182
+ /* line 1: glyph + name, kept on ONE line — truncated with an ellipsis
183
+ (never wrapped) when the job/runid text is longer than the row; the
184
+ full text is still reachable via the span's own title attribute. */
185
+ .wf-line1{display:flex;align-items:center;column-gap:6px;min-width:0;}
186
+ .wf-name{font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;}
187
+ /* line 2: the meta bits, also kept on ONE line with an ellipsis rather
188
+ than wrapping onto a 3rd/4th line. */
189
+ .wf-meta-line{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;}
190
+ .wf-meta{font-size:12px;color:var(--text-dim);}
191
+ .wf-meta + .wf-meta::before{content:" · ";color:var(--text-faint);}
192
+ .wf-row.selected,.hist-row.selected{border:2px solid var(--accent);border-radius:4px;}
193
+ /* item 2 (2026-09-26 merge): a Workflows-view job row expands inline to
194
+ list ALL its runs (build spec's own design) — the caret glyph on the
195
+ job row, and the indent wrapper the expanded runs render into. */
196
+ .wf-job{margin-bottom:6px;}
197
+ .wf-job .wf-row{margin-bottom:0;}
198
+ .wf-caret{display:inline-block;width:11px;flex:none;color:var(--text-faint);font-size:10px;}
199
+ .wf-runs{margin:4px 0 8px 16px;padding-left:8px;border-left:2px solid var(--border);}
200
+ .wf-runs .hist-row{margin-bottom:4px;}
201
+
202
+ .rp-header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:2px solid var(--border-strong);background:var(--panel);flex-wrap:wrap;gap:8px;}
203
+ .rp-header h2{margin:0;font-size:15px;}
204
+ .rp-header h2::before{content:"┤ ";color:var(--text-faint);}
205
+ .rp-header h2::after{content:" ├";color:var(--text-faint);}
206
+ .rp-header-title{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
207
+ /* item 4: phone-only jump-back link — hidden on desktop (the two panes
208
+ are already both on screen there, nothing to jump back to). */
209
+ .back-to-list-link{display:none;font-size:12px;color:var(--accent);text-decoration:underline;background:none;border:none;padding:0;cursor:pointer;}
210
+ @media (max-width: 899px){ .back-to-list-link{display:inline-block;} }
211
+ .rp-tabrow{display:flex;border-bottom:1px solid var(--border-strong);background:var(--panel2);counter-reset:rtabnum;}
212
+ .rp-tabrow button{padding:10px 14px;min-height:44px;background:transparent;border:none;border-right:1px dotted var(--border-strong);font-size:13px;color:var(--text-dim);counter-increment:rtabnum;}
213
+ .rp-tabrow button::before{content:"[" counter(rtabnum) "] ";color:var(--text-faint);font-weight:700;}
214
+ .tabrow button[aria-selected="true"],.rp-tabrow button[aria-selected="true"]{background:var(--panel);color:var(--accent);font-weight:700;box-shadow:inset 0 -2px 0 var(--accent);}
215
+ .tabrow button[aria-selected="true"]::before,.rp-tabrow button[aria-selected="true"]::before{color:var(--accent);}
216
+
217
+ .counters{display:block;padding:9px 12px;border:none;border-bottom:2px solid var(--border-strong);background:var(--panel2);color:var(--text-dim);margin:-12px -12px 14px;font-size:12px;font-weight:700;white-space:normal;overflow-wrap:break-word;}
218
+ .counters b,.counters span{color:var(--accent);}
219
+
220
+ .summary-box{position:relative;border:1px solid var(--border-strong);padding:16px 12px 12px;margin-top:16px;background:var(--panel2);font-size:12px;}
221
+ .summary-box.red{border-color:var(--red);}
222
+ .summary-box.amber{border-color:var(--amber);}
223
+ .summary-box.magenta{border-color:var(--magenta);}
224
+ .summary-box::before{content:"┤ SUMMARY ├";position:absolute;top:-10px;left:10px;background:var(--panel2);padding:0 6px;font-size:11px;color:var(--text-faint);letter-spacing:0.08em;}
225
+ .summary-headline{font-weight:700;margin-bottom:8px;font-size:13px;}
226
+ .summary-row{display:flex;gap:0;padding:2px 0;}
227
+ .sum-label{flex:none;width:66px;color:var(--text-faint);}
228
+ .sum-val{color:var(--text);}
229
+ .summary-box .num{color:var(--accent);font-weight:700;}
230
+
231
+ .map-box{border:1px solid var(--border-strong);padding:10px;overflow-x:auto;margin-top:16px;margin-bottom:14px;background:var(--panel2);}
232
+ .map-box::before{content:"┤ MAP ├";display:block;font-size:11px;color:var(--text-faint);letter-spacing:0.08em;margin:-2px 0 8px 0;}
233
+ .map-box svg{display:block;max-width:100%;height:auto;}
234
+ .map-legend{display:flex;gap:14px;font-size:11px;color:var(--text-dim);margin-top:6px;flex-wrap:wrap;}
235
+
236
+ .step-card{position:relative;border:1px solid var(--border);padding:10px;margin-bottom:8px;background:var(--panel2);}
237
+ .step-card.running{border:1px double var(--amber);}
238
+ .step-card .step-head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;}
239
+ .step-card h4{margin:0;font-size:13px;}
240
+ .step-meta{font-size:12px;color:var(--text-dim);margin-top:4px;}
241
+ /* build item C: Audit tab attempt/rounds detail (reused by audit-part-toggle expansion) */
242
+ .attempts-list{margin-top:8px;border-top:1px dotted var(--border-strong);padding-top:8px;}
243
+ .attempt-row{margin-bottom:6px;}
244
+ .attempt-toggle{margin-bottom:4px;}
245
+ .rounds-list{margin:6px 0 6px 12px;padding-left:8px;border-left:2px solid var(--border-strong);}
246
+ /* item 1 (2026-09-26): a round's own model call renders as this table's
247
+ header ROW (visually distinct from its tool-call rows below it). */
248
+ tr.round-header-row td{background:var(--panel2);font-weight:700;}
249
+ /* the rounds table shares the Flat table's sticky-header/scroll-wrapper
250
+ rule (`.audit-table-scroll`) by being wrapped in the same class — this
251
+ extra selector (vs. the Flat table's own `[data-testid="audit-table"]`
252
+ one) covers every table inside that wrapper, Flat's included. */
253
+ .audit-table-scroll table th{position:sticky;top:0;z-index:1;}
254
+ .attempt-check{margin-top:4px;font-size:12px;}
255
+ .rounds-page-info{margin-top:6px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
256
+
257
+ /* build item C: Audit tab grouped-by-part rows */
258
+ .audit-part{border:1px solid var(--border);margin-bottom:6px;background:var(--panel2);}
259
+ .audit-part-toggle{width:100%;text-align:left;display:block;white-space:normal;min-height:44px;padding:8px 10px;}
260
+ .audit-part-toggle::before,.audit-part-toggle::after{content:"";}
261
+ .audit-part-body{padding:8px 10px 10px;border-top:1px dotted var(--border-strong);}
262
+ .part-card{cursor:pointer;}
263
+ .part-card:hover{border-color:var(--border-strong);}
264
+
265
+ .chips{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
266
+ .chip{padding:5px 10px;min-height:32px;border:1px solid var(--border-strong);background:var(--panel2);font-size:12px;color:var(--text-dim);}
267
+ .chip::before{content:"[ ";color:var(--text-faint);}
268
+ .chip::after{content:" ]";color:var(--text-faint);}
269
+ .chip[aria-pressed="true"]{background:var(--panel);color:var(--accent);border-color:var(--accent);font-weight:700;}
270
+ /* Compact filter-bar chips (History + Workflows, shared component): single
271
+ letters/bare glyphs, no "[ ]" brackets — hamr's 2026-09-25 feedback on
272
+ the live panel ("icons are taking too much space", "one letter without
273
+ brackets"). */
274
+ .filterbar{margin-bottom:8px;}
275
+ .search-input{
276
+ display:block;width:100%;box-sizing:border-box;padding:6px 8px;min-height:32px;margin-bottom:6px;
277
+ border:1px solid var(--border-strong);background:var(--bg);color:var(--text);font:inherit;
278
+ }
279
+ .search-input::placeholder{color:var(--text-faint);}
280
+ .filterbar .chips{gap:4px;margin-bottom:4px;}
281
+ .chip.fchip{padding:3px 7px;min-height:28px;min-width:28px;font-size:12px;text-align:center;}
282
+ .chip.fchip::before,.chip.fchip::after{content:"";}
283
+ .row-blocked{background:var(--red-bg);}
284
+ .raw-log{margin-top:12px;}
285
+ .raw-log summary{cursor:pointer;font-size:13px;padding:6px 0;}
286
+ .raw-log pre{background:#0b0b0f;color:#e8e8f0;padding:10px;border:1px solid var(--border-strong);font-size:11px;overflow-x:auto;white-space:pre-wrap;word-break:break-word;}
287
+
288
+ .job-card{position:relative;border:1px solid var(--border-strong);padding:14px 10px 10px;background:var(--panel2);margin-top:10px;margin-bottom:10px;}
289
+ .job-card h4{margin:0 0 8px;font-size:13px;position:absolute;top:-10px;left:10px;background:var(--panel2);padding:0 6px;}
290
+ .job-card h4::before{content:"┤ ";color:var(--text-faint);}
291
+ .job-card h4::after{content:" ├";color:var(--text-faint);}
292
+ .field{margin-bottom:10px;}
293
+ label{display:block;font-size:11px;font-weight:700;color:var(--text-dim);margin-bottom:2px;text-transform:uppercase;letter-spacing:0.04em;}
294
+ .ro-value{padding:6px 8px;min-height:32px;box-sizing:border-box;border:1px solid var(--border);background:var(--bg);color:var(--text-dim);}
295
+ .ro-value.scroll{max-height:160px;overflow-y:auto;white-space:pre-wrap;word-break:break-word;}
296
+ .field-row{display:flex;flex-wrap:wrap;gap:10px;}
297
+ .field-row .field{flex:1 1 140px;margin-bottom:10px;}
298
+
299
+ .chat-thread{padding:20px 8px;color:var(--text-faint);font-size:13px;}
300
+
301
+ @media (max-width: 899px){
302
+ .main{flex-direction:column;}
303
+ .left-pane{width:100%;border-right:none;border-bottom:1px solid var(--border-strong);}
304
+ .right-pane{width:100%;}
305
+ .topbar{flex-direction:column;align-items:flex-start;}
306
+ .topbar-right{width:100%;justify-content:space-between;}
307
+ .btn.small{height:44px;min-height:44px;padding:0 12px;}
308
+ .chip{min-height:44px;}
309
+ .chip.fchip{min-height:40px;min-width:40px;}
310
+ .rp-header{margin:8px 8px 0;}
311
+ .rp-tabrow{margin:0 8px;}
312
+ }
313
+ </style>
314
+ </head>
315
+ <body>
316
+
317
+ <div id="BareloopPanel">
318
+
319
+ <header class="topbar">
320
+ <div>
321
+ <span class="brand">bareloop</span>
322
+ <span class="tagline">automate a job, verified</span>
323
+ </div>
324
+ <div class="topbar-right">
325
+ <span class="hint" id="host-line"></span>
326
+ <button class="theme-toggle" id="theme-toggle" type="button" aria-label="Toggle light/dark theme">&#9789;</button>
327
+ </div>
328
+ </header>
329
+
330
+ <div class="main" id="main-view">
331
+
332
+ <nav class="left-pane" aria-label="Job navigation" id="left-pane-anchor">
333
+ <div class="tabrow" role="tablist" aria-label="Left panel views">
334
+ <button role="tab" id="tab-chat" aria-controls="panel-chat" aria-selected="true" data-testid="left-tab-chat">Chat</button>
335
+ <button role="tab" id="tab-runs" aria-controls="panel-runs" aria-selected="false" data-testid="left-tab-runs">Runs</button>
336
+ </div>
337
+
338
+ <div class="pane-body" id="left-pane-body">
339
+
340
+ <section id="panel-chat" class="tabpanel active" role="tabpanel" aria-labelledby="tab-chat">
341
+ <div class="chat-thread">
342
+ Authoring a new job from this page comes later (PANEL-BUILD.md P3). This build
343
+ (P1) is read-only: it shows runs already started from the command line
344
+ (<code>bareloop run</code> / <code>bareloop run-u</code>). Use the Runs tab to
345
+ look at one.
346
+ </div>
347
+ </section>
348
+
349
+ <section id="panel-runs" class="tabpanel" role="tabpanel" aria-labelledby="tab-runs" hidden>
350
+ <div class="chips" role="group" aria-label="Runs view" style="margin-bottom:8px;">
351
+ <button class="chip" type="button" aria-pressed="true" data-runs-view="workflows" data-testid="runs-view-workflows">Workflows</button>
352
+ <button class="chip" type="button" aria-pressed="false" data-runs-view="history" data-testid="runs-view-history">History</button>
353
+ </div>
354
+ <div id="runs-filterbar-anchor"></div>
355
+ <div id="wf-list" data-testid="wf-list"></div>
356
+ <div id="history-list" data-testid="history-list" hidden></div>
357
+ </section>
358
+
359
+ </div>
360
+ </nav>
361
+
362
+ <section class="right-pane" aria-label="Active workflow" id="right-pane-anchor">
363
+ <div class="rp-header">
364
+ <button type="button" class="back-to-list-link" id="back-to-list" data-testid="back-to-list">&#8593; list</button>
365
+ <div class="rp-header-title">
366
+ <span class="dot grey" id="active-wf-dot"></span>
367
+ <h2 id="active-wf-name">(none selected)</h2>
368
+ <span class="badge" id="active-wf-verdict">—</span>
369
+ <span class="badge" id="active-wf-date">—</span>
370
+ </div>
371
+ </div>
372
+
373
+ <div class="rp-tabrow" role="tablist" aria-label="Workflow views">
374
+ <button role="tab" id="tab-run" aria-controls="panel-run" aria-selected="true" data-testid="right-tab-run">Run</button>
375
+ <button role="tab" id="tab-audit" aria-controls="panel-audit" aria-selected="false" data-testid="right-tab-audit">Audit / logs</button>
376
+ <button role="tab" id="tab-details" aria-controls="panel-details" aria-selected="false" data-testid="right-tab-details">Job</button>
377
+ </div>
378
+
379
+ <div class="pane-body" id="right-pane-body">
380
+
381
+ <section id="panel-run" class="tabpanel active" role="tabpanel" aria-labelledby="tab-run">
382
+ <div id="run-empty" class="hint" style="font-size:13px;padding:24px 0;">select a run from Workflows or History</div>
383
+ <div id="run-content" hidden>
384
+ <div class="counters" id="run-counters" data-testid="live-counters"></div>
385
+ <div class="summary-box" id="run-summary" data-testid="run-summary"></div>
386
+ <div class="map-box" data-testid="step-map" id="map-box"><div id="map-mount"></div></div>
387
+ <div id="step-list"></div>
388
+ </div>
389
+ </section>
390
+
391
+ <section id="panel-audit" class="tabpanel" role="tabpanel" aria-labelledby="tab-audit" hidden>
392
+ <div id="audit-select-empty" class="hint" style="font-size:13px;padding:24px 0;">select a run first</div>
393
+ <div id="audit-body" hidden>
394
+ <div class="audit-toolbar" style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:10px;">
395
+ <div class="chips" role="group" aria-label="Filter audit rows" style="margin-bottom:0;">
396
+ <button class="chip" aria-pressed="true" data-filter="all" data-testid="chip-all">All</button>
397
+ <button class="chip" aria-pressed="false" data-filter="write" data-testid="chip-writes">Writes</button>
398
+ <button class="chip" aria-pressed="false" data-filter="blocked" data-testid="chip-blocked">Blocked</button>
399
+ </div>
400
+ <div class="chips" role="group" aria-label="Audit view" style="margin-bottom:0;">
401
+ <button class="chip" aria-pressed="true" data-view="grouped" data-testid="audit-view-grouped">Grouped</button>
402
+ <button class="chip" aria-pressed="false" data-view="flat" data-testid="audit-view-flat">Flat</button>
403
+ </div>
404
+ </div>
405
+ <div id="audit-groups" data-testid="audit-groups"></div>
406
+ <div id="audit-flat-wrap" hidden>
407
+ <div id="audit-empty" class="hint" data-testid="audit-empty" style="font-size:13px;padding:24px 0;">select a run first</div>
408
+ <div id="audit-content" data-testid="audit-content" hidden>
409
+ <div class="audit-table-scroll">
410
+ <table data-testid="audit-table">
411
+ <thead><tr><th>Round</th><th>Step</th><th>Action</th><th>Path</th><th>Decision</th><th>Time</th></tr></thead>
412
+ <tbody id="audit-tbody"></tbody>
413
+ </table>
414
+ </div>
415
+ <details class="raw-log" data-testid="raw-spine-log">
416
+ <summary>Raw gate-audit log</summary>
417
+ <pre id="audit-rawlog"></pre>
418
+ </details>
419
+ </div>
420
+ </div>
421
+ </div>
422
+ </section>
423
+
424
+ <section id="panel-details" class="tabpanel" role="tabpanel" aria-labelledby="tab-details" hidden data-testid="panel-details">
425
+ <div id="job-empty" class="hint" style="font-size:13px;padding:24px 0;">select a run first</div>
426
+ <div class="job-card" id="job-card" hidden data-testid="job-card-readonly">
427
+ <h4 id="details-title">Job card — signed</h4>
428
+ <div class="field"><label>Check type</label><div class="ro-value" id="details-verdict">—</div></div>
429
+ <div class="field"><label>Model</label><div class="ro-value" id="details-model">—</div></div>
430
+ <div class="field-row">
431
+ <div class="field"><label>$ cap</label><div class="ro-value" id="details-cap-money">—</div></div>
432
+ <div class="field"><label>Time cap</label><div class="ro-value" id="details-cap-time">—</div></div>
433
+ </div>
434
+ <div class="field" id="details-description-field"><label>Description</label><div class="ro-value" id="details-description">—</div></div>
435
+ <div class="field"><label>Goal</label><div class="ro-value scroll" id="details-goal">—</div></div>
436
+ <div class="field"><label>Success</label><div class="ro-value" id="details-success">—</div></div>
437
+ <div class="field"><label>Guardrails</label><div class="ro-value" id="details-guardrails">—</div></div>
438
+ <div class="field"><label>Source</label><div class="ro-value" id="details-source">—</div></div>
439
+ <div class="field"><label>Destination</label><div class="ro-value" id="details-dest">—</div></div>
440
+ <div class="field"><label>Tools</label><div class="ro-value" id="details-tools">—</div></div>
441
+ <div class="hint" id="job-note"></div>
442
+ </div>
443
+ </section>
444
+
445
+ </div>
446
+ </section>
447
+
448
+ </div>
449
+
450
+ </div>
451
+
452
+ <script>
453
+ (function(){
454
+ "use strict";
455
+
456
+ var PORT = "__BARELOOP_PANEL_PORT__";
457
+ document.getElementById("host-line").textContent = "127.0.0.1:" + PORT;
458
+
459
+ function wireTabs(tabButtons){
460
+ tabButtons.forEach(function(btn){
461
+ btn.addEventListener("click", function(){
462
+ tabButtons.forEach(function(b){
463
+ b.setAttribute("aria-selected", "false");
464
+ var p = document.getElementById(b.getAttribute("aria-controls"));
465
+ if(p){ p.hidden = true; p.classList.remove("active"); }
466
+ });
467
+ btn.setAttribute("aria-selected", "true");
468
+ var panel = document.getElementById(btn.getAttribute("aria-controls"));
469
+ if(panel){ panel.hidden = false; panel.classList.add("active"); }
470
+ if(btn.id === "tab-run"){ reflowMap(); }
471
+ });
472
+ });
473
+ }
474
+ wireTabs([document.getElementById("tab-chat"), document.getElementById("tab-runs")]);
475
+ wireTabs([document.getElementById("tab-run"), document.getElementById("tab-audit"), document.getElementById("tab-details")]);
476
+
477
+ // ---- fetch helpers ----
478
+ function getJSON(path){
479
+ return fetch(path, { headers: { accept: "application/json" } }).then(function(r){
480
+ if(!r.ok) throw new Error(path + ": HTTP " + r.status);
481
+ return r.json();
482
+ });
483
+ }
484
+
485
+ // ---- step map renderer (verbatim algorithm from design/panel-mockup.html's
486
+ // buildStepMapSVG — ONE renderer, no second hand-authored map anywhere on this page) ----
487
+ function stepMapColors(state){
488
+ if(state === "done") return {stroke:"var(--green)", textFill:"#2e7d32"};
489
+ if(state === "running") return {stroke:"var(--amber)", textFill:"#b8860b"};
490
+ if(state === "stopped") return {stroke:"var(--red)", textFill:"var(--red)"};
491
+ // died (hamr's ruling B, 2026-09-25): distinct from stopped/red — no
492
+ // verdict was ever reached, so this box never shares the red look.
493
+ if(state === "died") return {stroke:"var(--magenta)", textFill:"var(--magenta)"};
494
+ return {stroke:"var(--border-strong)", textFill:"var(--text-faint)"};
495
+ }
496
+ function escapeXml(s){
497
+ return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
498
+ }
499
+ var stepMapUidCounter = 0;
500
+ var MAP_MARGIN = 10, MAP_GAP = 28;
501
+ var CHAR_W = 7.2;
502
+ var TITLE_HPAD = 16;
503
+ var MIN_BOX_W = 100;
504
+ var BOX_H = 60, BOX_H_2LINE = 78;
505
+ var ROW_GAP = 70;
506
+ // s.noNumber (F196): a synthetic placeholder box (e.g. "died during
507
+ // planning") is a LABEL, never a real step — it renders without the
508
+ // leading "<n> " step number a real step gets.
509
+ // item 4 (2026-09-25): each step box shows its attempts INLINE, e.g.
510
+ // "attempt 1 [✗] · attempt 2 [✓]" — appended to the box's own title text
511
+ // (the existing wrapTitleLines 2-line wrap handles overflow the same way
512
+ // it already does for a long step id; no third renderer, no new box shape).
513
+ // item 1 (2026-09-27, panel P2 defect): an attempt's own outcome is null
514
+ // in exactly two situations — its window is still open on a LIVE run (the
515
+ // box carrying it is the run's own live/`running` part), or the run DIED
516
+ // before that window ever closed. hamr's glyph ruling reserves "?" for
517
+ // DIED only; a live open attempt reads the same "▶" the run glyph itself
518
+ // uses for "in progress / unresolved". `boxIsLive` is the ONE place that
519
+ // context is decided (every caller passes its own box's `state ===
520
+ // "running"` — never re-derives the rule itself), so a finished or died
521
+ // run's attempts (boxIsLive always false there) render exactly as before.
522
+ function attemptGlyph(outcome, boxIsLive){
523
+ if(outcome === "green") return "✓";
524
+ if(outcome === null || outcome === undefined) return boxIsLive ? "▶" : "?";
525
+ return "✗";
526
+ }
527
+ function attemptsInlineText(attempts, boxIsLive){
528
+ if(!attempts || !attempts.length) return "";
529
+ return attempts.map(function(a){ return "attempt " + a.n + " " + attemptGlyph(a.outcome, boxIsLive); }).join(" · ");
530
+ }
531
+ function stepTitleText(idx, s){
532
+ var base = s.noNumber ? s.title : (idx + 1) + ' ' + s.title;
533
+ var at = attemptsInlineText(s.attempts, s.state === "running");
534
+ return at ? base + ' — ' + at : base;
535
+ }
536
+ // item 5 (F197): a box's step NUMBER must count only over boxes without
537
+ // `noNumber` (a real plan step), skipping any noNumber box (scout+plan,
538
+ // fix loop, a synthetic "died during planning" placeholder) wherever it
539
+ // sits in the list — never the box's raw array position. Returns, per
540
+ // box, the `idx` value stepTitleText expects (its own +1 makes the
541
+ // display number), so a noNumber box's entry is unused/irrelevant.
542
+ function stepNumberIndices(steps){
543
+ var n = 0;
544
+ return steps.map(function(s){
545
+ if(s.noNumber) return -1;
546
+ n++;
547
+ return n - 1;
548
+ });
549
+ }
550
+ // build item B (2026-09-26 rewrite): the map+card list are now built from
551
+ // `detail.parts` — src/replay.js's own ONE ordered part list (scout, plan,
552
+ // each step occurrence, replan, fix, judge, or a single "run" box for an
553
+ // old-shape spine) — never a second client-side re-derivation off
554
+ // scoutPlan/steps/fixLoop separately (F198's own bug class: two ordering
555
+ // passes that could drift apart). `partIndex` on every box lets a click
556
+ // jump straight to that SAME part's Audit-tab row (build item B's
557
+ // click-through), with no re-lookup needed.
558
+ function partBoxState(part, isLastLivePart){
559
+ if(part.outcome === "green" || part.outcome === "satisfied" || part.outcome === "already-green") return "done";
560
+ if(typeof part.outcome === "string") return "stopped";
561
+ if(isLastLivePart) return "running";
562
+ return "done"; // scout/plan/replan/judge: no per-part verdict, but the part itself already happened
563
+ }
564
+ // scout/plan/replan/judge parts carry no per-part verdict at all — their
565
+ // ONE attempt is always the wholePartAttempt synthetic placeholder
566
+ // (outcome:null, src/replay.js), never a real green/red. Showing that as
567
+ // an inline "attempt 1 ?" on the map/card would read as an unresolved
568
+ // result when nothing was ever judged there; `step`/`fix` (real attempts)
569
+ // and `run` (its own real, non-null outcome) are the only kinds worth an
570
+ // inline attempt/result glyph.
571
+ function partHasNoVerdict(part){ return part.kind === "scout" || part.kind === "plan" || part.kind === "replan" || part.kind === "judge"; }
572
+ // ONE shared result-glyph rule for both the Run tab's cards and the Audit
573
+ // tab's grouped-row headers — `null` (never a fabricated "?") for a
574
+ // no-verdict part kind; a multi-attempt part (a step with retries, or the
575
+ // fix loop) joins every attempt's own glyph ("✗✗✗✓"); everything else
576
+ // (a single-attempt step, or the whole "run" box on an old-shape spine)
577
+ // reads the part's own outcome.
578
+ function partResultGlyph(part, box){
579
+ if(partHasNoVerdict(part)) return null;
580
+ var boxIsLive = box.state === "running";
581
+ if(box.attempts.length > 1) return box.attempts.map(function(a){ return attemptGlyph(a.outcome, boxIsLive); }).join("");
582
+ var outcome = (part.outcome !== null && part.outcome !== undefined) ? part.outcome : (box.attempts[0] ? box.attempts[0].outcome : null);
583
+ return attemptGlyph(outcome, boxIsLive);
584
+ }
585
+ function buildOrderedBoxes(parts, isLive){
586
+ var list = Array.isArray(parts) ? parts : [];
587
+ return list.map(function(part, idx){
588
+ var isLastLivePart = !!isLive && idx === list.length - 1;
589
+ var occSuffix = part.kind === "step" && part.occurrence > 1 ? " (try " + part.occurrence + ")" : "";
590
+ return {
591
+ kind: part.kind,
592
+ partIndex: idx,
593
+ title: part.label + occSuffix,
594
+ state: partBoxState(part, isLastLivePart),
595
+ noNumber: part.kind !== "step",
596
+ // display-only attempts (map inline text + card/audit result glyph
597
+ // fallback) — [] for a no-verdict part kind even though `part.attempts`
598
+ // itself still carries its one real synthetic window (needed by the
599
+ // Audit tab's expand-to-rounds, read off `box.part.attempts` directly).
600
+ attempts: partHasNoVerdict(part) ? [] : (Array.isArray(part.attempts) ? part.attempts : []),
601
+ blocked: typeof part.blocked === "number" ? part.blocked : 0,
602
+ part: part,
603
+ };
604
+ });
605
+ }
606
+ function naturalBoxWidth(steps){
607
+ var numIdx = stepNumberIndices(steps);
608
+ var maxChars = 0;
609
+ steps.forEach(function(s, idx){ maxChars = Math.max(maxChars, stepTitleText(numIdx[idx], s).length); });
610
+ return Math.max(MIN_BOX_W, Math.ceil(maxChars * CHAR_W) + TITLE_HPAD);
611
+ }
612
+ function computeMapLayout(n, availW, naturalW){
613
+ if(!availW) return {perRow:n, boxW:naturalW};
614
+ var usableW = availW - MAP_MARGIN * 2;
615
+ for(var perRow = n; perRow >= 2; perRow--){
616
+ var totalW = perRow * naturalW + (perRow - 1) * MAP_GAP;
617
+ if(totalW <= usableW){
618
+ var stretched = (usableW - (perRow - 1) * MAP_GAP) / perRow;
619
+ return {perRow:perRow, boxW:Math.max(naturalW, stretched)};
620
+ }
621
+ }
622
+ return {perRow:1, boxW:Math.max(MIN_BOX_W, usableW)};
623
+ }
624
+ function wrapTitleLines(text, maxWidth){
625
+ var maxChars = Math.max(1, Math.floor((maxWidth - TITLE_HPAD) / CHAR_W));
626
+ if(text.length <= maxChars) return [text];
627
+ var words = text.split(' ');
628
+ var line1 = '', i = 0;
629
+ for(; i < words.length; i++){
630
+ var cand = line1 ? line1 + ' ' + words[i] : words[i];
631
+ if(line1 && cand.length > maxChars) break;
632
+ line1 = cand;
633
+ }
634
+ if(!line1) return [text.slice(0, maxChars), text.slice(maxChars)];
635
+ var line2 = words.slice(i).join(' ');
636
+ return line2 ? [line1, line2] : [line1];
637
+ }
638
+ // ONE shared rule for "does this box draw a retry loop, and what try number" — a multi-attempt
639
+ // box (a re-run step, or the fix loop; `run` on an old-shape spine falls in here too, naturally,
640
+ // never special-cased) gets a dashed self-loop labelled with its final try number; anything
641
+ // else (incl. every no-verdict kind, whose attempts is already forced to [] upstream) gets 0.
642
+ function boxRetryTry(box){
643
+ var attempts = Array.isArray(box.attempts) ? box.attempts : [];
644
+ return attempts.length > 1 ? attempts.length : 0;
645
+ }
646
+ // item 2 (2026-09-27): the ONE place a retry loop's x-coordinate (curve
647
+ // start/control/end, and the "try N" label) is kept inside the box that
648
+ // owns it — MIN_BOX_W (100) is comfortably wider than 2*RETRY_INSET, so
649
+ // this never inverts the clamp range.
650
+ var RETRY_INSET = 4;
651
+ function clampRetryX(v, boxX, boxW){
652
+ var lo = boxX + RETRY_INSET, hi = boxX + boxW - RETRY_INSET;
653
+ return Math.min(hi, Math.max(lo, v));
654
+ }
655
+ function buildStepMapSVG(steps, availWidth){
656
+ var n = steps.length;
657
+ var naturalW = naturalBoxWidth(steps);
658
+ var layout = computeMapLayout(n, availWidth, naturalW);
659
+ var perRow = layout.perRow, boxW = layout.boxW, gap = MAP_GAP;
660
+ var rows = Math.ceil(n / perRow);
661
+ var margin = MAP_MARGIN;
662
+ var numIdx = stepNumberIndices(steps);
663
+ var titleLines = steps.map(function(s, idx){ return wrapTitleLines(stepTitleText(numIdx[idx], s), boxW); });
664
+ var maxLines = titleLines.reduce(function(m, l){ return Math.max(m, l.length); }, 1);
665
+ var boxH = maxLines > 1 ? BOX_H_2LINE : BOX_H;
666
+ var rowH = boxH + ROW_GAP;
667
+ var totalW = margin * 2 + perRow * boxW + (perRow - 1) * gap;
668
+ var totalH = rows * rowH;
669
+ var uid = "sm" + (stepMapUidCounter++);
670
+ var body =
671
+ '<defs>' +
672
+ '<marker id="' + uid + '-arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">' +
673
+ '<path d="M0,0 L6,3 L0,6 z" fill="var(--text-dim)"></path></marker>' +
674
+ '<marker id="' + uid + '-arrow-grey" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">' +
675
+ '<path d="M0,0 L6,3 L0,6 z" fill="var(--text-faint)"></path></marker>' +
676
+ '</defs>';
677
+ steps.forEach(function(s, idx){
678
+ var row = Math.floor(idx / perRow);
679
+ var col = idx % perRow;
680
+ var rtl = (row % 2) === 1;
681
+ var slotCol = rtl ? (perRow - 1 - col) : col;
682
+ var x = margin + slotCol * (boxW + gap);
683
+ var y = 30 + row * rowH;
684
+ var colors = stepMapColors(s.state);
685
+ var lines = titleLines[idx];
686
+ var nextRow = idx < n - 1 ? Math.floor((idx + 1) / perRow) : null;
687
+ var hasDrop = nextRow !== null && nextRow !== row;
688
+ var stateY = y + boxH - 18;
689
+ var partIdxAttr = typeof s.partIndex === "number" ? (' data-part-index="' + s.partIndex + '" style="cursor:pointer;"') : '';
690
+ body += '<rect x="' + x + '" y="' + y + '" width="' + boxW + '" height="' + boxH +
691
+ '" rx="0" fill="var(--panel2)" stroke="' + colors.stroke + '" stroke-width="2"' + partIdxAttr + '></rect>';
692
+ if(s.blocked > 0){
693
+ body += '<text x="' + (x + boxW - 6) + '" y="' + (y + 12) +
694
+ '" text-anchor="end" font-size="10" fill="var(--red)">' + s.blocked + ' blocked</text>';
695
+ }
696
+ if(lines.length === 1){
697
+ body += '<text x="' + (x + boxW / 2) + '" y="' + (y + 25) +
698
+ '" text-anchor="middle" font-size="12" fill="var(--text)">' + escapeXml(lines[0]) + '</text>';
699
+ } else {
700
+ body += '<text x="' + (x + boxW / 2) + '" y="' + (y + 17) +
701
+ '" text-anchor="middle" font-size="12" fill="var(--text)">' + escapeXml(lines[0]) + '</text>';
702
+ body += '<text x="' + (x + boxW / 2) + '" y="' + (y + 29) +
703
+ '" text-anchor="middle" font-size="12" fill="var(--text)">' + escapeXml(lines[1]) + '</text>';
704
+ }
705
+ body += '<text x="' + (x + boxW / 2) + '" y="' + stateY +
706
+ '" text-anchor="middle" font-size="10" fill="' + colors.textFill + '">' + escapeXml(s.state) + '</text>';
707
+ // P2 (2026-09-27 ruling): a running box gets the mockup's pulsing
708
+ // amber dot (design/panel-mockup.html ~line 1648) — same fixed color
709
+ // and animation, never re-derived from the state's own stroke color.
710
+ if(s.state === "running"){
711
+ body += '<circle cx="' + (x + 20) + '" cy="' + (y + 12) + '" r="4" fill="#b8860b">' +
712
+ '<animate attributeName="opacity" values="1;0.3;1" dur="1.2s" repeatCount="indefinite"></animate></circle>';
713
+ }
714
+ var retryTry = boxRetryTry(s);
715
+ if(retryTry){
716
+ // dashed self-loop under the box, same proportions as the design mockup's curve,
717
+ // scaled to this box's own width. When this same box also carries the row-to-row
718
+ // snake drop (which leaves from dead-center, x=cx), shift the loop's start away from
719
+ // center by a fixed pixel offset so the two never begin at the same point.
720
+ // item 2 (2026-09-27): at a narrow box width, subtracting that same
721
+ // fixed pixel shift from proportionally-small x-fractions (e.g. the
722
+ // curve's own endpoint at 2.8% of boxW) can push the coordinate past
723
+ // the box's own left edge — the fixed shift was sized for desktop
724
+ // widths and never scaled down with boxW. clampRetryX is the ONE
725
+ // place every retry x-coordinate (curve start/control/end AND the
726
+ // "try N" label) is kept inside [x + RETRY_INSET, x + boxW -
727
+ // RETRY_INSET] — never re-clamped ad hoc per coordinate — so the
728
+ // whole loop and its label always stay inside the box that owns it,
729
+ // at every width, while keeping the mockup's proportions untouched
730
+ // wherever they already fit (every desktop-width box already fits).
731
+ var retryShift = hasDrop ? 14 : 0;
732
+ var bottom = y + boxH;
733
+ var sx = clampRetryX(x + boxW * 0.50 - retryShift, x, boxW);
734
+ var sy = bottom + 5;
735
+ var c1x = clampRetryX(x + boxW * 0.278 - retryShift, x, boxW), c1y = bottom + 30;
736
+ var c2x = clampRetryX(x + boxW * 0.167 - retryShift, x, boxW), c2y = bottom + 30;
737
+ var ex = clampRetryX(x + boxW * 0.028 - retryShift, x, boxW), ey = bottom + 2;
738
+ var labelX = clampRetryX(x + boxW * 0.267 - retryShift, x, boxW);
739
+ body += '<path d="M ' + sx + ' ' + sy + ' C ' + c1x + ' ' + c1y + ', ' + c2x + ' ' + c2y + ', ' + ex + ' ' + ey +
740
+ '" fill="none" stroke="var(--text-faint)" stroke-width="1.5" stroke-dasharray="3,3" marker-end="url(#' + uid + '-arrow-grey)"></path>';
741
+ body += '<text x="' + labelX + '" y="' + (bottom + 32) +
742
+ '" text-anchor="middle" font-size="10" fill="var(--text-faint)">try ' + retryTry + '</text>';
743
+ }
744
+ if(idx < n - 1){
745
+ if(nextRow === row){
746
+ var midY = y + boxH / 2;
747
+ if(!rtl){
748
+ var nx = x + boxW + gap;
749
+ body += '<line x1="' + (x + boxW) + '" y1="' + midY + '" x2="' + (nx - 2) + '" y2="' + midY +
750
+ '" stroke="var(--text-dim)" stroke-width="2" marker-end="url(#' + uid + '-arrow)"></line>';
751
+ } else {
752
+ var px = x - gap - boxW;
753
+ body += '<line x1="' + x + '" y1="' + midY + '" x2="' + (px + boxW + 2) + '" y2="' + midY +
754
+ '" stroke="var(--text-dim)" stroke-width="2" marker-end="url(#' + uid + '-arrow)"></line>';
755
+ }
756
+ } else {
757
+ var cx = x + boxW / 2;
758
+ body += '<line x1="' + cx + '" y1="' + (y + boxH) + '" x2="' + cx + '" y2="' + (y + rowH - 2) +
759
+ '" stroke="var(--text-dim)" stroke-width="2" marker-end="url(#' + uid + '-arrow)"></line>';
760
+ }
761
+ }
762
+ });
763
+ return '<svg width="' + totalW + '" height="' + totalH + '" viewBox="0 0 ' + totalW + ' ' + totalH + '" role="img" aria-label="step map">' + body + '</svg>';
764
+ }
765
+ function stepMapLegendHTML(){
766
+ return '<div class="map-legend">' +
767
+ '<span><span class="dot green"></span>done</span>' +
768
+ '<span><span class="dot amber"></span>running</span>' +
769
+ '<span><span class="dot red"></span>stopped</span>' +
770
+ '<span><span class="dot grey"></span>waiting</span>' +
771
+ '<span><span class="dot magenta"></span>died</span>' +
772
+ '<span>⤾ dashed = retry</span>' +
773
+ '</div>';
774
+ }
775
+ var lastSteps = null;
776
+ var lastWidth = null;
777
+ function mapAvailWidth(wrapper){
778
+ if(!wrapper) return null;
779
+ var w = wrapper.getBoundingClientRect().width;
780
+ if(!w) return null;
781
+ return Math.max(0, w - 20);
782
+ }
783
+ function renderStepMap(steps){
784
+ var mount = document.getElementById("map-mount");
785
+ var wrapper = document.getElementById("map-box");
786
+ lastSteps = steps;
787
+ if(!steps || !steps.length){
788
+ mount.innerHTML = "";
789
+ wrapper.hidden = true;
790
+ return;
791
+ }
792
+ wrapper.hidden = false;
793
+ var w = mapAvailWidth(wrapper);
794
+ lastWidth = w;
795
+ mount.innerHTML = buildStepMapSVG(steps, w) + stepMapLegendHTML();
796
+ // build item B: a map box click jumps to the Audit tab, that same part
797
+ // expanded and scrolled into view — same click-through as the card list,
798
+ // sharing partIndex so the two can never disagree about which row a box
799
+ // maps to. Delegated once per render (mount.innerHTML above already
800
+ // wiped any previous listeners' targets along with the old markup).
801
+ mount.querySelectorAll("rect[data-part-index]").forEach(function(rect){
802
+ rect.addEventListener("click", function(){
803
+ var idx = Number(rect.getAttribute("data-part-index"));
804
+ document.getElementById("tab-audit").click();
805
+ openAuditPart(idx);
806
+ });
807
+ });
808
+ }
809
+ function reflowMap(){
810
+ var wrapper = document.getElementById("map-box");
811
+ if(!wrapper || wrapper.hidden || !lastSteps) return;
812
+ var w = mapAvailWidth(wrapper);
813
+ if(w === null || w === lastWidth) return;
814
+ lastWidth = w;
815
+ document.getElementById("map-mount").innerHTML = buildStepMapSVG(lastSteps, w) + stepMapLegendHTML();
816
+ }
817
+ if(typeof ResizeObserver !== "undefined"){
818
+ var ro = new ResizeObserver(function(){ reflowMap(); });
819
+ var mb = document.getElementById("map-box");
820
+ if(mb) ro.observe(mb);
821
+ } else {
822
+ window.addEventListener("resize", reflowMap);
823
+ }
824
+
825
+ // F196: a `synthetic` step (e.g. the "died during planning" placeholder
826
+ // box) is a LABEL, never a real step — excluded from the summary's "N of
827
+ // M done" count and from the numbered step-card list below the map (the
828
+ // map itself still keeps the one box, rendered via renderStepMap directly
829
+ // off detail.steps, unfiltered).
830
+ function realSteps(steps){ return steps.filter(function(s){ return !s.synthetic; }); }
831
+
832
+ // ---- glyph/badge helpers (only [✓]/[✗]/[▶]/[·]/[?] — never the words green/red/soft-green/died) ----
833
+ function glyphClass(g){
834
+ if(g === "✓") return "green";
835
+ if(g === "✗") return "red";
836
+ if(g === "▶") return "amber";
837
+ if(g === "?") return "magenta";
838
+ return "grey";
839
+ }
840
+
841
+ // ---- Runs tab: ONE run-row builder shared by History (flat) and the
842
+ // Workflows view's per-job expansion (item 2, 2026-09-26 merge) — the two
843
+ // never drift on how a run row reads.
844
+ function buildRunRowEl(r){
845
+ var row = document.createElement("div");
846
+ // build item (2026-09-26 fix): compute selected state here, off the
847
+ // module's own `currentRunid`, so a row freshly built by a RE-RENDER
848
+ // (switching Runs view mode, or a Workflows parent auto-expanding onto
849
+ // an older run picked from History) shows selected without needing a
850
+ // click on this exact element — `selectRun`'s imperative class toggle
851
+ // (used when a click passes its own `rowEl`) stays layered on top of
852
+ // this, never replaced by it.
853
+ var selected = r.runid === currentRunid;
854
+ row.className = "hist-row" + (selected ? " selected" : "");
855
+ row.setAttribute("data-testid", "hist-row-" + r.runid);
856
+ row.setAttribute("tabindex", "0");
857
+ row.setAttribute("role", "button");
858
+ row.setAttribute("aria-pressed", selected ? "true" : "false");
859
+ var histFullName = r.job + ' (' + r.runid + ')';
860
+ if(r.fileMissing){
861
+ row.innerHTML =
862
+ '<span class="wf-line1"><span class="dot grey"></span>' +
863
+ '<span class="wf-name" title="' + escapeXml(histFullName) + '">' + escapeXml(histFullName) + '</span></span>' +
864
+ '<span class="wf-meta-line">' +
865
+ '<span class="wf-meta">file missing</span>' +
866
+ '<span class="wf-meta">' + escapeXml((r.at || "").slice(0,10) || "unknown") + '</span>' +
867
+ '</span>';
868
+ } else {
869
+ row.innerHTML =
870
+ '<span class="wf-line1"><span class="dot ' + glyphClass(r.glyph) + '"></span>' +
871
+ '<span class="wf-name" title="' + escapeXml(histFullName) + '">' + escapeXml(histFullName) + '</span></span>' +
872
+ '<span class="wf-meta-line">' +
873
+ '<span class="wf-meta">' + escapeXml(r.checkType) + '</span>' +
874
+ '<span class="wf-meta">' + escapeXml(r.spend) + '</span>' +
875
+ '<span class="wf-meta">' + escapeXml(r.wall) + '</span>' +
876
+ '<span class="wf-meta">' + escapeXml(r.date || "unknown") + '</span>' +
877
+ '</span>';
878
+ }
879
+ row.addEventListener("click", function(){ selectRun(r.runid, row, ".hist-row"); document.getElementById("tab-run").click(); scrollRunIntoViewMobile(); });
880
+ row.addEventListener("keydown", function(e){ if(e.key === "Enter" || e.key === " "){ e.preventDefault(); row.click(); } });
881
+ return row;
882
+ }
883
+
884
+ // ---- History view ----
885
+ function renderHistory(runs){
886
+ var el = document.getElementById("history-list");
887
+ el.innerHTML = "";
888
+ var listed = (runs || []).filter(function(r){ return !r.fileMissing; });
889
+ if(!listed.length){
890
+ el.innerHTML = '<div class="hint">no runs listed yet</div>';
891
+ return;
892
+ }
893
+ (runs || []).forEach(function(r){ el.appendChild(buildRunRowEl(r)); });
894
+ }
895
+
896
+ // ---- Workflows view (item 2, 2026-09-26 merge): groups the ONE /api/runs
897
+ // payload by job name client-side — the sole owner of that grouping now
898
+ // (the old /api/workflows endpoint is gone; see src/panel/server.js). Runs
899
+ // arrive newest-first (server contract, see listRuns), so the FIRST run
900
+ // seen for a job is its latest — no re-sort needed, and job order (first-
901
+ // job-seen order) is already newest-first by construction.
902
+ function groupRunsByJob(runs){
903
+ var byJob = {};
904
+ var order = [];
905
+ (runs || []).forEach(function(r){
906
+ if(!byJob[r.job]){ byJob[r.job] = []; order.push(r.job); }
907
+ byJob[r.job].push(r);
908
+ });
909
+ return order.map(function(job){
910
+ var jobRuns = byJob[job];
911
+ var last = jobRuns[0];
912
+ return {
913
+ job: job,
914
+ runs: jobRuns,
915
+ runCount: jobRuns.length,
916
+ lastRunid: last.runid,
917
+ lastAt: last.at,
918
+ lastGlyph: last.glyph || "▶",
919
+ lastCheckType: last.checkType || "unknown",
920
+ lastModel: last.model || null,
921
+ lastSpend: last.spend || "unknown",
922
+ lastWall: last.wall || "unknown",
923
+ lastDate: last.date,
924
+ };
925
+ });
926
+ }
927
+
928
+ // Matches a job if ANY of its runs match the filters (not only the
929
+ // latest) — a one-line adapter over filterRuns (never a reimplementation
930
+ // of the AND/OR/time/search semantics), same posture as the old
931
+ // filterWorkflows but now correct against the job's FULL run list.
932
+ function filterWorkflows(jobs, filters, nowMs){
933
+ return (jobs || []).filter(function(g){
934
+ return filterRuns(g.runs, filters, nowMs).length > 0;
935
+ });
936
+ }
937
+
938
+ function filtersActive(filters){
939
+ return !!(filters && (
940
+ (filters.checkTypes && filters.checkTypes.length) ||
941
+ (filters.results && filters.results.length) ||
942
+ (filters.time && filters.time !== "all") ||
943
+ (filters.search && String(filters.search).trim())
944
+ ));
945
+ }
946
+
947
+ // Pure rule (item 2 build spec): when filters/search are active, a job
948
+ // whose LATEST run doesn't match but an older one does auto-expands so the
949
+ // matching run is visible without an extra click; inactive filters never
950
+ // force an expand.
951
+ function autoExpandJob(group, matchingRuns, active){
952
+ if(!active || !matchingRuns.length) return false;
953
+ var latestId = group.runs.length ? group.runs[0].runid : null;
954
+ var latestIncluded = matchingRuns.some(function(r){ return r.runid === latestId; });
955
+ return !latestIncluded;
956
+ }
957
+
958
+ // build item (2026-09-26 fix, F: parent-row/older-run mismatch): ONE owner
959
+ // for "which run does this parent row stand for" — both its own selected
960
+ // state and what its click opens read this, never two separate rules that
961
+ // could drift. Unfiltered (or when the latest still matches), a job's
962
+ // parent always stands for its latest run, same as before this fix. With
963
+ // a filter/search that matches an OLDER run but excludes the latest, the
964
+ // parent stands for the newest MATCHING run instead — so its click opens
965
+ // the run the search actually found, and (once picked) the parent's own
966
+ // selected state follows that same run rather than silently reverting to
967
+ // the latest.
968
+ function representedRun(group, candidateRuns, active){
969
+ if(active && candidateRuns.length){
970
+ var latestIncluded = candidateRuns.some(function(r){ return r.runid === group.lastRunid; });
971
+ if(!latestIncluded) return candidateRuns[0].runid;
972
+ }
973
+ return group.lastRunid;
974
+ }
975
+
976
+ // Is `currentRunid` one of this job's OLDER runs (i.e. a run that
977
+ // belongs to this job but isn't its latest)? Selecting a run from History
978
+ // and switching to Workflows lands here: the job must auto-expand and its
979
+ // matching child row must carry the selected marker even though no
980
+ // filter/search is active and no click happened inside Workflows itself.
981
+ function activeOlderRun(group){
982
+ if(!currentRunid || currentRunid === group.lastRunid) return null;
983
+ var found = group.runs.some(function(r){ return r.runid === currentRunid; });
984
+ return found ? currentRunid : null;
985
+ }
986
+
987
+ // manual expand/collapse memory, keyed by job name — survives a re-render
988
+ // triggered by a filter/search change within the same page load, reset on
989
+ // full page reload (per-viewer convenience only, never persisted). A
990
+ // manual collapse click still wins over auto-expand-for-selection on the
991
+ // next render (simplest rule: one flag, no "but selection forces it back
992
+ // open" special case) — collapsing a job you're not looking at any more
993
+ // is a normal, expected action.
994
+ var wfExpanded = {};
995
+
996
+ function renderWorkflows(jobs){
997
+ var el = document.getElementById("wf-list");
998
+ el.innerHTML = "";
999
+ if(!jobs.length){
1000
+ el.innerHTML = '<div class="hint">no runs listed yet — run `bareloop run-u` or `bareloop run`, or `bareloop runs backfill <dir>`</div>';
1001
+ return;
1002
+ }
1003
+ var active = filtersActive(currentRunsFilters());
1004
+ jobs.forEach(function(g){
1005
+ // build item (2026-09-26 fix): the parent row IS the job's latest run
1006
+ // (clicking it opens that run), so the inline child list must be the
1007
+ // job's OTHER runs only — never repeat the latest as its own first
1008
+ // child. Expand affordance (caret, aria-expanded, toggle) is gated on
1009
+ // there being a child to actually show: with filters/search active,
1010
+ // that's the FILTERED other-runs list, so a job whose only match is
1011
+ // its own latest run gets no caret and never expands — clicking it
1012
+ // still opens the run, same as any job row. With no filters active
1013
+ // this is unchanged (childRuns = all other runs, same as runCount>1).
1014
+ var candidateRuns = active ? filterRuns(g.runs, currentRunsFilters(), Date.now()) : g.runs;
1015
+ var childRuns = candidateRuns.filter(function(r){ return r.runid !== g.lastRunid; });
1016
+ var hasOtherRuns = childRuns.length > 0;
1017
+ // build item (2026-09-26 fix): auto-expand for a filter/search match
1018
+ // on an older run (existing rule) OR because the run picked elsewhere
1019
+ // (e.g. History) IS one of this job's older runs — either way the
1020
+ // matching child must be visible without an extra click.
1021
+ var olderSelected = activeOlderRun(g);
1022
+ var autoExpand = hasOtherRuns && (autoExpandJob(g, candidateRuns, active) || !!olderSelected);
1023
+ var expanded = hasOtherRuns && (Object.prototype.hasOwnProperty.call(wfExpanded, g.job) ? wfExpanded[g.job] : autoExpand);
1024
+ var targetRunid = representedRun(g, candidateRuns, active);
1025
+
1026
+ var wrap = document.createElement("div");
1027
+ wrap.className = "wf-job";
1028
+
1029
+ var row = document.createElement("div");
1030
+ // build item (2026-09-26): a job (parent) row also opens its latest
1031
+ // run in the Run tab, same as a child row — so it carries the same
1032
+ // "selected" visual state a child row gets from selectRun (the class
1033
+ // is computed HERE, at render time, rather than left on the clicked
1034
+ // element, since this function tears down and rebuilds every wf-row
1035
+ // on every click — see below). `targetRunid` (see `representedRun`
1036
+ // above) is normally the latest run, so this is unchanged in the
1037
+ // common case; a filter/search that matched an older run instead
1038
+ // makes the parent follow THAT run once it's the selected one.
1039
+ var isSelected = currentRunid === targetRunid;
1040
+ row.className = "wf-row" + (isSelected ? " selected" : "");
1041
+ row.setAttribute("data-wf", g.job);
1042
+ row.setAttribute("data-testid", "wf-row-" + g.job);
1043
+ row.setAttribute("tabindex", "0");
1044
+ row.setAttribute("role", "button");
1045
+ row.setAttribute("aria-expanded", expanded ? "true" : "false");
1046
+ row.setAttribute("aria-pressed", isSelected ? "true" : "false");
1047
+ var wfFullName = g.job + ' (' + g.lastRunid + ')';
1048
+ row.innerHTML =
1049
+ '<span class="wf-line1"><span class="wf-caret">' + (hasOtherRuns ? (expanded ? "▼" : "▶") : "") + '</span><span class="dot ' + glyphClass(g.lastGlyph) + '"></span>' +
1050
+ '<span class="wf-name" title="' + escapeXml(wfFullName) + '">' + escapeXml(wfFullName) + '</span></span>' +
1051
+ '<span class="wf-meta-line">' +
1052
+ '<span class="wf-meta">' + escapeXml(g.lastCheckType) + '</span>' +
1053
+ '<span class="wf-meta">' + escapeXml(g.lastSpend) + '</span>' +
1054
+ '<span class="wf-meta">' + escapeXml(g.lastWall) + '</span>' +
1055
+ '<span class="wf-meta">' + escapeXml(g.lastDate || "unknown") + '</span>' +
1056
+ '<span class="wf-meta">' + g.runCount + ' run' + (g.runCount === 1 ? '' : 's') + '</span>' +
1057
+ '</span>';
1058
+ row.addEventListener("click", function(){
1059
+ // a single-run job has nothing to expand — the click still opens
1060
+ // the run, it just never toggles `wfExpanded` for it.
1061
+ if(hasOtherRuns) wfExpanded[g.job] = !expanded;
1062
+ // build item (2026-09-26): clicking the parent also opens its
1063
+ // latest run (as it did before the Workflows/History merge) — pass
1064
+ // rowEl null since renderWorkflows(jobs) below rebuilds every row
1065
+ // right after, so a class set directly on this element would be
1066
+ // discarded; the fresh render marks selection off `currentRunid`
1067
+ // instead (see `isSelected` above). build item (2026-09-26 fix):
1068
+ // opens `targetRunid`, not always `g.lastRunid` — with a filter/
1069
+ // search active that excludes the latest but matches an older run,
1070
+ // that's the run the search actually found (see `representedRun`).
1071
+ selectRun(targetRunid, null, ".hist-row");
1072
+ document.getElementById("tab-run").click();
1073
+ scrollRunIntoViewMobile();
1074
+ renderWorkflows(jobs);
1075
+ });
1076
+ row.addEventListener("keydown", function(e){ if(e.key === "Enter" || e.key === " "){ e.preventDefault(); row.click(); } });
1077
+ wrap.appendChild(row);
1078
+
1079
+ if(expanded){
1080
+ var runsWrap = document.createElement("div");
1081
+ runsWrap.className = "wf-runs";
1082
+ childRuns.forEach(function(r){ runsWrap.appendChild(buildRunRowEl(r)); });
1083
+ wrap.appendChild(runsWrap);
1084
+ }
1085
+ el.appendChild(wrap);
1086
+ });
1087
+ }
1088
+
1089
+ // ---- Filter bar (item 5 + hamr's 2026-09-25 live-panel feedback) ----
1090
+ // ONE component, ONE state shape ({checkTypes, results, time}), used by
1091
+ // BOTH the History tab (filters individual runs) and the Workflows tab
1092
+ // (filters by each workflow's OWN latest-run fields). Compact: check type
1093
+ // is a single letter (tooltip carries the word), result is a bare glyph
1094
+ // (no brackets — the generic ".chip" style still wraps other chips in
1095
+ // "[ ]"; ".fchip" turns that off), time is a short label, then "clear".
1096
+ // No "unknown" check-type chip — runs/workflows with unknown type still
1097
+ // show whenever no type chip is selected (checkTypes stays empty).
1098
+ function filterBarHTML(scope){
1099
+ return (
1100
+ '<div class="filterbar" data-scope="' + scope + '" data-testid="' + scope + '-filters">' +
1101
+ '<input type="text" class="search-input" placeholder="search job, run id or model…" ' +
1102
+ 'id="' + scope + '-filter-search" data-testid="' + scope + '-filter-search" autocomplete="off">' +
1103
+ '<div class="chips" role="group" aria-label="Filter">' +
1104
+ '<button class="chip fchip" type="button" aria-pressed="false" data-filter-group="checkType" data-filter-value="deterministic" title="deterministic" data-testid="chip-' + scope + '-ct-deterministic">D</button>' +
1105
+ '<button class="chip fchip" type="button" aria-pressed="false" data-filter-group="checkType" data-filter-value="rubric" title="rubric" data-testid="chip-' + scope + '-ct-rubric">R</button>' +
1106
+ '<button class="chip fchip" type="button" aria-pressed="false" data-filter-group="result" data-filter-value="&#10003;" title="pass" data-testid="chip-' + scope + '-res-pass">&#10003;</button>' +
1107
+ '<button class="chip fchip" type="button" aria-pressed="false" data-filter-group="result" data-filter-value="&#10007;" title="fail" data-testid="chip-' + scope + '-res-fail">&#10007;</button>' +
1108
+ '<button class="chip fchip" type="button" aria-pressed="false" data-filter-group="result" data-filter-value="?" title="died" data-testid="chip-' + scope + '-res-died">?</button>' +
1109
+ '<button class="chip fchip" type="button" aria-pressed="false" data-filter-group="result" data-filter-value="&#9654;" title="running" data-testid="chip-' + scope + '-res-running">&#9654;</button>' +
1110
+ '<button class="chip fchip" type="button" aria-pressed="false" data-filter-group="time" data-filter-value="7d" title="last 7 days" data-testid="chip-' + scope + '-time-7d">7d</button>' +
1111
+ '<button class="chip fchip" type="button" aria-pressed="false" data-filter-group="time" data-filter-value="30d" title="last 30 days" data-testid="chip-' + scope + '-time-30d">30d</button>' +
1112
+ '<button class="chip fchip" type="button" aria-pressed="true" data-filter-group="time" data-filter-value="all" title="all time" data-testid="chip-' + scope + '-time-all">all</button>' +
1113
+ '<button class="chip fchip" type="button" id="' + scope + '-filter-clear" data-testid="' + scope + '-filter-clear">clear</button>' +
1114
+ '</div>' +
1115
+ '<div class="hint" id="' + scope + '-filter-count" data-testid="' + scope + '-filter-count"></div>' +
1116
+ '</div>'
1117
+ );
1118
+ }
1119
+
1120
+ // Pure function: chips toggle on/off independently and combine AND across
1121
+ // the three groups (checkType/result/time), OR within a group (an empty
1122
+ // group array means "no filter" for that group — everything passes it).
1123
+ // `time` is single-select, default "all". `nowMs` is injectable for tests.
1124
+ // Field names (checkType/glyph/at) match a History run row's own shape.
1125
+ // item 1 (2026-09-25): case-insensitive substring match on job name AND
1126
+ // runid, combined AND with the chip groups (an empty/whitespace-only query
1127
+ // means "no search filter" — everything passes it, same posture as an
1128
+ // empty chip group). item C (2026-09-26): also matches the run's model
1129
+ // name (e.g. "deepseek", "sonnet") — `model` is `null`/absent on an older
1130
+ // archived run, same posture as a missing job/runid.
1131
+ function matchesSearch(job, runid, model, query){
1132
+ if(!query) return true;
1133
+ var q = String(query).trim().toLowerCase();
1134
+ if(!q) return true;
1135
+ var j = String(job || "").toLowerCase();
1136
+ var r = String(runid || "").toLowerCase();
1137
+ var m = String(model || "").toLowerCase();
1138
+ return j.indexOf(q) !== -1 || r.indexOf(q) !== -1 || m.indexOf(q) !== -1;
1139
+ }
1140
+ function filterRuns(runs, filters, nowMs){
1141
+ nowMs = (typeof nowMs === "number") ? nowMs : Date.now();
1142
+ var ct = (filters && filters.checkTypes) || [];
1143
+ var rs = (filters && filters.results) || [];
1144
+ var time = (filters && filters.time) || "all";
1145
+ var q = (filters && filters.search) || "";
1146
+ return (runs || []).filter(function(r){
1147
+ if(ct.length && ct.indexOf(r.checkType) === -1) return false;
1148
+ if(rs.length && rs.indexOf(r.glyph) === -1) return false;
1149
+ if(!matchesSearch(r.job, r.runid, r.model, q)) return false;
1150
+ if(time !== "all"){
1151
+ var days = time === "7d" ? 7 : 30;
1152
+ var t = typeof r.at === "string" ? Date.parse(r.at) : NaN;
1153
+ if(!isFinite(t)) return false;
1154
+ if(nowMs - t > days * 24 * 60 * 60 * 1000) return false;
1155
+ }
1156
+ return true;
1157
+ });
1158
+ }
1159
+
1160
+ /**
1161
+ * One filter-bar instance: its own localStorage key, its own DOM scope
1162
+ * (`.filterbar[data-scope="..."]`), its own item list — but the same
1163
+ * wiring as every other instance.
1164
+ * @param {{scope:string, storageKey:string, filterFn:Function, applyFn:Function}} opts
1165
+ */
1166
+ function createFilterBar(opts){
1167
+ var scope = opts.scope;
1168
+ var storageKey = opts.storageKey;
1169
+ var state = { checkTypes: [], results: [], time: "all", search: "" };
1170
+ var allItems = [];
1171
+
1172
+ function load(){
1173
+ try{
1174
+ var raw = localStorage.getItem(storageKey);
1175
+ if(raw){
1176
+ var parsed = JSON.parse(raw);
1177
+ if(parsed && typeof parsed === "object"){
1178
+ state = {
1179
+ checkTypes: Array.isArray(parsed.checkTypes) ? parsed.checkTypes : [],
1180
+ results: Array.isArray(parsed.results) ? parsed.results : [],
1181
+ time: typeof parsed.time === "string" ? parsed.time : "all",
1182
+ search: typeof parsed.search === "string" ? parsed.search : "",
1183
+ };
1184
+ }
1185
+ }
1186
+ }catch(e){ /* per-viewer convenience only; a blocked/private store just means no memory */ }
1187
+ }
1188
+ function save(){
1189
+ try{ localStorage.setItem(storageKey, JSON.stringify(state)); }catch(e){ /* best-effort only */ }
1190
+ }
1191
+ function paint(){
1192
+ document.querySelectorAll('.filterbar[data-scope="' + scope + '"] .chip[data-filter-group]').forEach(function(chip){
1193
+ var group = chip.getAttribute("data-filter-group");
1194
+ var value = chip.getAttribute("data-filter-value");
1195
+ var active;
1196
+ if(group === "time"){ active = state.time === value; }
1197
+ else if(group === "checkType"){ active = state.checkTypes.indexOf(value) !== -1; }
1198
+ else { active = state.results.indexOf(value) !== -1; }
1199
+ chip.setAttribute("aria-pressed", active ? "true" : "false");
1200
+ });
1201
+ var searchEl = document.getElementById(scope + "-filter-search");
1202
+ if(searchEl && searchEl.value !== state.search) searchEl.value = state.search;
1203
+ }
1204
+ function apply(){
1205
+ var filtered = opts.filterFn(allItems, state, Date.now());
1206
+ opts.applyFn(filtered);
1207
+ var countEl = document.getElementById(scope + "-filter-count");
1208
+ if(countEl) countEl.textContent = "showing " + filtered.length + " of " + allItems.length + " runs";
1209
+ }
1210
+ document.querySelectorAll('.filterbar[data-scope="' + scope + '"] .chip[data-filter-group]').forEach(function(chip){
1211
+ chip.addEventListener("click", function(){
1212
+ var group = chip.getAttribute("data-filter-group");
1213
+ var value = chip.getAttribute("data-filter-value");
1214
+ if(group === "time"){
1215
+ state.time = value; // single-select within time
1216
+ } else if(group === "checkType"){
1217
+ var idx = state.checkTypes.indexOf(value);
1218
+ if(idx === -1) state.checkTypes.push(value); else state.checkTypes.splice(idx, 1);
1219
+ } else if(group === "result"){
1220
+ var idx2 = state.results.indexOf(value);
1221
+ if(idx2 === -1) state.results.push(value); else state.results.splice(idx2, 1);
1222
+ }
1223
+ paint();
1224
+ save();
1225
+ apply();
1226
+ });
1227
+ });
1228
+ var clearBtn = document.getElementById(scope + "-filter-clear");
1229
+ if(clearBtn){
1230
+ clearBtn.addEventListener("click", function(){
1231
+ state = { checkTypes: [], results: [], time: "all", search: "" };
1232
+ paint();
1233
+ save();
1234
+ apply();
1235
+ });
1236
+ }
1237
+ var searchInput = document.getElementById(scope + "-filter-search");
1238
+ if(searchInput){
1239
+ searchInput.addEventListener("input", function(){
1240
+ state.search = searchInput.value;
1241
+ save();
1242
+ apply();
1243
+ });
1244
+ }
1245
+ load();
1246
+ paint();
1247
+
1248
+ return {
1249
+ setItems: function(items){ allItems = items || []; apply(); },
1250
+ getState: function(){ return state; },
1251
+ refresh: function(){ apply(); },
1252
+ };
1253
+ }
1254
+
1255
+ // item 2 (2026-09-26 merge): Workflows/History view-mode memory — same
1256
+ // per-viewer-convenience posture as every other localStorage read/write on
1257
+ // this page (try/catch, never assumed available).
1258
+ var RUNS_VIEW_KEY = "bareloop-panel-runs-view";
1259
+ function loadRunsViewMode(){
1260
+ try{
1261
+ var v = localStorage.getItem(RUNS_VIEW_KEY);
1262
+ if(v === "workflows" || v === "history") return v;
1263
+ }catch(e){ /* per-viewer convenience only */ }
1264
+ return "workflows";
1265
+ }
1266
+ function saveRunsViewMode(v){
1267
+ try{ localStorage.setItem(RUNS_VIEW_KEY, v); }catch(e){ /* best-effort only */ }
1268
+ }
1269
+
1270
+ // item 2 (2026-09-26 merge): ONE shared filter bar drives whichever view
1271
+ // (Workflows/History) is currently on — one scope, one localStorage key,
1272
+ // so switching views never loses the search/chip state.
1273
+ document.getElementById("runs-filterbar-anchor").innerHTML = filterBarHTML("runs");
1274
+
1275
+ var runsViewMode = loadRunsViewMode();
1276
+ function runsFilterFn(items, filters, nowMs){
1277
+ if(runsViewMode === "history") return filterRuns(items, filters, nowMs);
1278
+ return filterWorkflows(groupRunsByJob(items), filters, nowMs);
1279
+ }
1280
+ function runsApplyFn(filtered){
1281
+ if(runsViewMode === "history") renderHistory(filtered);
1282
+ else renderWorkflows(filtered);
1283
+ }
1284
+ var runsFilterBar = createFilterBar({
1285
+ scope: "runs",
1286
+ storageKey: "bareloop-panel-runs-filters",
1287
+ filterFn: runsFilterFn,
1288
+ applyFn: runsApplyFn,
1289
+ });
1290
+ function currentRunsFilters(){
1291
+ return runsFilterBar ? runsFilterBar.getState() : { checkTypes: [], results: [], time: "all", search: "" };
1292
+ }
1293
+ function paintRunsViewToggle(){
1294
+ document.querySelectorAll('.chip[data-runs-view]').forEach(function(c){
1295
+ c.setAttribute("aria-pressed", c.getAttribute("data-runs-view") === runsViewMode ? "true" : "false");
1296
+ });
1297
+ document.getElementById("wf-list").hidden = runsViewMode !== "workflows";
1298
+ document.getElementById("history-list").hidden = runsViewMode !== "history";
1299
+ }
1300
+ document.querySelectorAll('.chip[data-runs-view]').forEach(function(chip){
1301
+ chip.addEventListener("click", function(){
1302
+ var next = chip.getAttribute("data-runs-view");
1303
+ if(next === runsViewMode) return;
1304
+ runsViewMode = next;
1305
+ saveRunsViewMode(runsViewMode);
1306
+ paintRunsViewToggle();
1307
+ runsFilterBar.refresh();
1308
+ });
1309
+ });
1310
+ paintRunsViewToggle();
1311
+
1312
+ // item 4: phone-only "scroll the run view into view" on row select, and
1313
+ // the matching "↑ list" jump back. Desktop never runs either — both panes
1314
+ // are already on screen there (the <900px breakpoint here matches the
1315
+ // CSS media query above, both named off the same 900px number).
1316
+ function isMobileLayout(){
1317
+ return typeof window.matchMedia === "function" && window.matchMedia("(max-width: 899px)").matches;
1318
+ }
1319
+ function scrollRunIntoViewMobile(){
1320
+ if(!isMobileLayout()) return;
1321
+ var target = document.getElementById("right-pane-anchor");
1322
+ if(target && target.scrollIntoView) target.scrollIntoView({ behavior: "smooth", block: "start" });
1323
+ }
1324
+ var backToListBtn = document.getElementById("back-to-list");
1325
+ if(backToListBtn){
1326
+ backToListBtn.addEventListener("click", function(){
1327
+ var target = document.getElementById("left-pane-anchor");
1328
+ if(target && target.scrollIntoView) target.scrollIntoView({ behavior: "smooth", block: "start" });
1329
+ });
1330
+ }
1331
+
1332
+ function clearSelection(selector){
1333
+ document.querySelectorAll(".wf-row, .hist-row").forEach(function(r){ r.classList.remove("selected"); r.setAttribute("aria-pressed","false"); });
1334
+ }
1335
+
1336
+ // ---- Run tab ----
1337
+ function money(n){ return (n === null || n === undefined) ? "unknown" : "$" + Number(n).toFixed(4); }
1338
+ function duration(ms){
1339
+ if(ms === null || ms === undefined) return "unknown";
1340
+ if(ms < 0) return "unknown";
1341
+ var s = ms / 1000;
1342
+ if(s < 60) return s.toFixed(1) + "s";
1343
+ var m = Math.floor(s / 60), rem = Math.round(s - m * 60);
1344
+ if(rem === 60){ m += 1; rem = 0; }
1345
+ return m + "m" + String(rem).padStart(2, "0") + "s";
1346
+ }
1347
+
1348
+ // ---- Audit row cell builders — ONE shared renderer for a gate-audit row,
1349
+ // used by both the Flat table (renderAudit) and the Grouped view's
1350
+ // per-attempt rounds table (renderRoundsTable) so the two views can never
1351
+ // drift on how a model-call row vs a tool-call row reads (item 1,
1352
+ // 2026-09-26). Each `r` here is a real gate-audit row shape (either a
1353
+ // Flat-view row off `/audit`, or a round's own `toolCalls[]` entry off
1354
+ // `/rounds` — both carry the same fields: `kind`, `action`, `path`,
1355
+ // `pathShort`, `decision`, `time`/`ts`, plus a model-call row's own
1356
+ // `costUsd`/`tokens`/`durationMs`).
1357
+ function auditIsModelCall(r){ return r.kind === "model-call"; }
1358
+ function auditIsBlocked(r){ return !auditIsModelCall(r) && (r.decision === "block" || r.decision === "deny" || r.decision === false); }
1359
+ function auditRoundCellHtml(r){ return (typeof r.round === "number") ? String(r.round) : "&mdash;"; }
1360
+ function auditActionCellHtml(r){ return auditIsModelCall(r) ? "model call" : escapeXml(r.action || "unknown"); }
1361
+ // item 2 (2026-09-26): shows the SHORT (tree-root-relative) path with the
1362
+ // full absolute path in a `title` tooltip — never losing the full path,
1363
+ // never showing a short path that isn't real (server sends both fields;
1364
+ // `pathShort` falls back to `path` when no tree root was resolvable).
1365
+ function auditPathCellHtml(r){
1366
+ if(auditIsModelCall(r)) return "&mdash;";
1367
+ var full = r.path || "unknown";
1368
+ var shown = r.pathShort || r.path || "unknown";
1369
+ return '<span title="' + escapeXml(full) + '">' + escapeXml(shown) + '</span>';
1370
+ }
1371
+ function auditDecisionCellHtml(r){
1372
+ if(auditIsModelCall(r)){
1373
+ var bits = [];
1374
+ bits.push(typeof r.costUsd === "number" ? money(r.costUsd) : "unknown");
1375
+ bits.push((typeof r.tokens === "number" ? r.tokens : "unknown") + " tokens");
1376
+ bits.push(duration(r.durationMs));
1377
+ return escapeXml(bits.join(" · "));
1378
+ }
1379
+ return "<span class=\"badge " + (auditIsBlocked(r) ? "red" : "green") + "\">" + (r.decision || "unknown") + "</span>";
1380
+ }
1381
+ function auditRowClassName(r){
1382
+ var isModelCall = auditIsModelCall(r);
1383
+ var isWrite = !isModelCall && (r.action === "write" || r.action === "edit");
1384
+ var cls = isWrite ? "row-write" : "";
1385
+ if(auditIsBlocked(r)) cls = (cls ? cls + " " : "") + "row-blocked";
1386
+ return cls;
1387
+ }
1388
+
1389
+ // item 7: tools/cache summary rows — built ONLY from replay's own already-
1390
+ // computed `behaviour`/`memoryCache` fields (never recomputed a second way).
1391
+ // mirrors src/behaviour.js's own displayLabel() verbatim (strip shell_/ctx_
1392
+ // prefixes) so the panel's tool names read the SAME as `bareloop replay`'s.
1393
+ function toolDisplayLabel(key){ return String(key).replace(/^shell_/, "").replace(/^ctx_/, ""); }
1394
+ // item 3 (2026-09-25): EVERY tool used, with counts — no top-5 truncation
1395
+ // (was `entries.slice(0, 5)` + "…"; the build spec calls for the full list).
1396
+ // item 3 (2026-09-26): Run summary "model:" line — `detail.model` +
1397
+ // `detail.provider` off job-start's own real fields (src/run.js:360/368),
1398
+ // never guessed. `provider` is shown alongside the model only when the
1399
+ // spine actually recorded one (e.g. "deepseek-flash (openai-api)"); a
1400
+ // spine predating either field reads "not recorded", never a default
1401
+ // model name.
1402
+ function modelLine(detail){
1403
+ if(!detail.model) return "not recorded";
1404
+ return detail.provider ? (detail.model + " (" + detail.provider + ")") : detail.model;
1405
+ }
1406
+ function toolsLine(behaviour){
1407
+ if(!behaviour) return "unknown (no log saved)";
1408
+ if(behaviour.totalCalls === 0) return "0 calls";
1409
+ var entries = Object.keys(behaviour.byTool).map(function(k){ return [toolDisplayLabel(k), behaviour.byTool[k]]; });
1410
+ entries.sort(function(a,b){ return b[1] - a[1]; });
1411
+ var all = entries.map(function(e){ return e[0] + " " + e[1]; });
1412
+ return behaviour.totalCalls + " calls — " + all.join(" · ");
1413
+ }
1414
+ // item 3 (2026-09-26 revision, hamr's word): "offered" — the FULL granted-
1415
+ // tools list from the run's own resolved spec (`job.toolsList`), shown
1416
+ // whenever a granted list exists at all — never subtracted against what
1417
+ // was actually used (that used/never-used split was the ORIGINAL item 3
1418
+ // shape; hamr asked it removed in favor of just showing everything the
1419
+ // run was offered). `null` when the spec carries no granted-tools list at
1420
+ // all (never shown).
1421
+ function offeredLine(toolsList){
1422
+ if(!toolsList || !toolsList.length) return null;
1423
+ return toolsList.join(" · ");
1424
+ }
1425
+ // build item B: a part card's own 2nd line — every tool used, with counts,
1426
+ // sorted by count descending (no "N calls —" prefix, that's line 1's job).
1427
+ // `null` byTool (no gate-audit sidecar) reads the same "unknown (no log
1428
+ // saved)" toolsLine already uses; an empty-but-known object reads "no tools".
1429
+ function toolBreakdownLine(byTool){
1430
+ if(!byTool) return "unknown (no log saved)";
1431
+ var entries = Object.keys(byTool).map(function(k){ return [toolDisplayLabel(k), byTool[k]]; });
1432
+ if(!entries.length) return "no tools";
1433
+ entries.sort(function(a,b){ return b[1] - a[1]; });
1434
+ return entries.map(function(e){ return e[0] + " " + e[1]; }).join(" · ");
1435
+ }
1436
+ function cacheLine(memoryCache){
1437
+ if(!memoryCache) return "not recorded";
1438
+ var kb = (memoryCache.bytesWithheld / 1024).toFixed(1);
1439
+ return memoryCache.pointered + " re-reads answered from memory · " + kb + " KB not re-sent";
1440
+ }
1441
+
1442
+ // item 3: the Run summary and Job tab load via two independent fetches
1443
+ // (see selectRun below) that can resolve in either order; the Job fetch is
1444
+ // the only one this row needs now (offeredLine no longer looks at run
1445
+ // behaviour at all), but paintOfferedRow() stays idempotent-safe to call
1446
+ // from either render function.
1447
+ var lastJobToolsList = null;
1448
+ function paintOfferedRow(){
1449
+ var row = document.getElementById("summary-offered-row");
1450
+ var val = document.getElementById("summary-offered-val");
1451
+ if(!row || !val) return;
1452
+ var line = offeredLine(lastJobToolsList);
1453
+ if(line){ val.textContent = line; row.hidden = false; }
1454
+ else { row.hidden = true; val.textContent = ""; }
1455
+ }
1456
+
1457
+ function renderRun(detail){
1458
+ var name = document.getElementById("active-wf-name");
1459
+ var dot = document.getElementById("active-wf-dot");
1460
+ var verdict = document.getElementById("active-wf-verdict");
1461
+ var date = document.getElementById("active-wf-date");
1462
+
1463
+ if(detail.fileMissing){
1464
+ name.textContent = detail.job + " (" + detail.runid + ")";
1465
+ dot.className = "dot grey";
1466
+ verdict.textContent = "unknown";
1467
+ date.textContent = "file missing";
1468
+ document.getElementById("run-empty").hidden = false;
1469
+ document.getElementById("run-empty").textContent = "spine file no longer on disk";
1470
+ document.getElementById("run-content").hidden = true;
1471
+ document.getElementById("audit-select-empty").hidden = false;
1472
+ document.getElementById("audit-body").hidden = true;
1473
+ return;
1474
+ }
1475
+
1476
+ document.getElementById("audit-select-empty").hidden = true;
1477
+ document.getElementById("audit-body").hidden = false;
1478
+
1479
+ name.textContent = detail.job + " (" + detail.runid + ")";
1480
+ dot.className = "dot " + glyphClass(detail.glyph);
1481
+ verdict.textContent = detail.checkType;
1482
+ if(detail.checkTypeTitle){ verdict.title = detail.checkTypeTitle; } else { verdict.removeAttribute("title"); }
1483
+ date.textContent = detail.date || "unknown";
1484
+
1485
+ document.getElementById("run-empty").hidden = true;
1486
+ document.getElementById("run-content").hidden = false;
1487
+
1488
+ // died: the two REAL figures the panel has (spentUsd/wallMs, both null —
1489
+ // no job-end means no final totals were ever recorded) are replaced with
1490
+ // the priced-rounds/first-to-last-record FLOORS the server derived —
1491
+ // never "unknown" when a priced round actually exists.
1492
+ var spendText = detail.died ? (detail.spendFloorUsd === null ? "unknown" : "at least " + money(detail.spendFloorUsd)) : money(detail.spentUsd);
1493
+ var wallText = detail.died ? (detail.wallFloorMs === null ? "unknown" : "at least " + duration(detail.wallFloorMs)) : duration(detail.wallMs);
1494
+ // item 6 (2026-09-25): "took" for a finished (job-end reached) OR died
1495
+ // run — both are OVER, so the wall figure is a completed duration, never
1496
+ // an in-progress one. "elapsed" is reserved for the one state that IS
1497
+ // still in progress: a live [▶] run (glyph "▶" and not died).
1498
+ var isLive = detail.glyph === "▶" && !detail.died;
1499
+ var wallPhrase = isLive ? (wallText + " elapsed") : ("took " + wallText);
1500
+
1501
+ document.getElementById("run-counters").innerHTML =
1502
+ spendText + " of $" + (detail.budgetUsd === null ? "unknown" : Number(detail.budgetUsd).toFixed(2)) +
1503
+ " &middot; " + wallPhrase;
1504
+
1505
+ var box = document.getElementById("run-summary");
1506
+ box.className = "summary-box " + (detail.died ? "magenta" : (detail.glyph === "✗" ? "red" : (detail.glyph === "▶" ? "amber" : "")));
1507
+ // F196: a synthetic placeholder box (e.g. "died during planning") is a
1508
+ // LABEL, not a real step — excluded from this count so a run that died
1509
+ // before any step started reads "0 of 0 done" / "none started", never
1510
+ // "0 of 1 done".
1511
+ var rSteps = realSteps(detail.steps);
1512
+ var stepsDone = rSteps.filter(function(s){ return s.state === "done"; }).length;
1513
+ var stepsText = rSteps.length === 0 ? "none started" : ('<span class="num">' + stepsDone + '</span> of <span class="num">' + rSteps.length + '</span> done');
1514
+ box.innerHTML =
1515
+ '<div class="summary-headline"><span class="dot ' + glyphClass(detail.glyph) + '"></span><b>' + escapeXml(detail.checkType) + '</b> &middot; ' +
1516
+ '<span class="num">' + spendText + '</span> &middot; <span class="num">' + wallText + '</span></div>' +
1517
+ '<div class="summary-row" data-testid="summary-model"><span class="sum-label">model:</span><span class="sum-val">' + escapeXml(modelLine(detail)) + '</span></div>' +
1518
+ (detail.judgeModel ? '<div class="summary-row" data-testid="summary-judge"><span class="sum-label">judge:</span><span class="sum-val">' + escapeXml(detail.judgeModel) + '</span></div>' : '') +
1519
+ '<div class="summary-row"><span class="sum-label">steps:</span><span class="sum-val">' + stepsText +
1520
+ (detail.replans ? ' &middot; ' + detail.replans + ' replan' + (detail.replans === 1 ? '' : 's') : '') + '</span></div>' +
1521
+ '<div class="summary-row"><span class="sum-label">why:</span><span class="sum-val">' + escapeXml(detail.stopReason || (detail.glyph === "✓" ? "run ended clean" : "not recorded")) + '</span></div>' +
1522
+ '<div class="summary-row"><span class="sum-label">close:</span><span class="sum-val">' + escapeXml(detail.close && detail.close.verdict ? detail.close.verdict : "none — the run ended before any close ran") + '</span></div>' +
1523
+ '<div class="summary-row" data-testid="summary-tools"><span class="sum-label">tools:</span><span class="sum-val">' + escapeXml(toolsLine(detail.behaviour)) + '</span></div>' +
1524
+ '<div class="summary-row" data-testid="summary-offered" id="summary-offered-row" hidden><span class="sum-label">offered:</span><span class="sum-val" id="summary-offered-val"></span></div>' +
1525
+ '<div class="summary-row" data-testid="summary-cache"><span class="sum-label">cache:</span><span class="sum-val">' + escapeXml(cacheLine(detail.memoryCache)) + '</span></div>';
1526
+
1527
+ paintOfferedRow();
1528
+
1529
+ // build item B (2026-09-26 rewrite): the map AND the card list are both
1530
+ // built from ONE list — `detail.parts` (src/replay.js's own ordered part
1531
+ // list) via `buildOrderedBoxes` — so they can never drift apart (F198's
1532
+ // own bug class). Cards are SUMMARY ONLY here, never expandable — every
1533
+ // attempt/rounds detail now lives exclusively on the Audit tab; a card
1534
+ // (or its map box) click switches to Audit with that same part expanded
1535
+ // and scrolled into view (build item B's click-through).
1536
+ var isLiveRun = detail.glyph === "▶" && !detail.died;
1537
+ var orderedBoxes = buildOrderedBoxes(detail.parts, isLiveRun);
1538
+ renderStepMap(orderedBoxes);
1539
+
1540
+ var cardStepNumbers = stepNumberIndices(orderedBoxes);
1541
+
1542
+ var listEl = document.getElementById("step-list");
1543
+ listEl.innerHTML = "";
1544
+ orderedBoxes.forEach(function(box, idx){
1545
+ var part = box.part;
1546
+ var badgeClass = box.state === "done" ? "green" : (box.state === "stopped" ? "red" : (box.state === "running" ? "amber" : (box.state === "died" ? "magenta" : "grey")));
1547
+ var titleText = box.kind === "step" ? ((cardStepNumbers[idx] + 1) + ' &middot; ' + escapeXml(box.title)) : escapeXml(box.title);
1548
+ var resultGlyph = partResultGlyph(part, box);
1549
+ var line1Bits = [
1550
+ (typeof part.rounds === "number" ? part.rounds : 0) + " call" + (part.rounds === 1 ? "" : "s"),
1551
+ typeof part.toolCalls === "number" ? (part.toolCalls + " tool" + (part.toolCalls === 1 ? "" : "s")) : "unknown tools",
1552
+ duration(part.wallMs),
1553
+ part.unpricedRounds > 0 ? "unknown (" + part.unpricedRounds + " unpriced round" + (part.unpricedRounds === 1 ? "" : "s") + ")" : money(part.spentUsd),
1554
+ ];
1555
+ if(resultGlyph !== null) line1Bits.push(resultGlyph);
1556
+ var card = document.createElement("div");
1557
+ card.className = "step-card part-card" + (box.state === "running" ? " running" : "");
1558
+ card.setAttribute("data-testid", "part-card-" + idx);
1559
+ card.setAttribute("tabindex", "0");
1560
+ card.setAttribute("role", "button");
1561
+ card.innerHTML =
1562
+ '<div class="step-head"><h4>' + titleText + '</h4>' +
1563
+ '<span class="badge ' + badgeClass + '">' + escapeXml(box.state) + '</span>' +
1564
+ (box.blocked > 0 ? '<span class="badge red" data-testid="part-blocked-badge">' + box.blocked + ' blocked</span>' : '') +
1565
+ '</div>' +
1566
+ '<div class="step-meta" title="a part is one piece of the run (scout, plan, a step, replan, fix, judge); a call is one model round">' + line1Bits.join(" &middot; ") + '</div>' +
1567
+ '<div class="step-meta">' + escapeXml(toolBreakdownLine(box.part.byTool)) + '</div>';
1568
+ listEl.appendChild(card);
1569
+ var jumpToAudit = function(){
1570
+ document.getElementById("tab-audit").click();
1571
+ openAuditPart(idx);
1572
+ };
1573
+ card.addEventListener("click", jumpToAudit);
1574
+ card.addEventListener("keydown", function(e){ if(e.key === "Enter" || e.key === " "){ e.preventDefault(); jumpToAudit(); } });
1575
+ });
1576
+
1577
+ // build item C: the Audit tab's grouped-by-part view is built here too —
1578
+ // off the SAME `detail.parts`/`buildOrderedBoxes` this function just used
1579
+ // for the map+cards, never a second independently-fetched data source
1580
+ // (the flat one-row-per-call table still comes from its own `/audit`
1581
+ // fetch, wired separately in selectRun below).
1582
+ renderAuditGroups(detail);
1583
+ }
1584
+
1585
+ // build item C: Audit tab attempt row -> its rounds (server-built,
1586
+ // lazy-fetched on expand via /api/runs/:runid/rounds?part=<i>&attempt=<n>).
1587
+ function buildAttemptsList(container, partIndex, attempts, boxIsLive){
1588
+ container.innerHTML = "";
1589
+ attempts.forEach(function(a){
1590
+ var row = document.createElement("div");
1591
+ row.className = "attempt-row";
1592
+ row.setAttribute("data-testid", "audit-attempt-" + partIndex + "-" + a.n);
1593
+ row.innerHTML =
1594
+ '<button class="btn small attempt-toggle" type="button" aria-expanded="false">' +
1595
+ 'attempt ' + a.n + ' ' + attemptGlyph(a.outcome, boxIsLive) +
1596
+ '</button>' +
1597
+ '<div class="rounds-list" hidden></div>';
1598
+ container.appendChild(row);
1599
+ var btn = row.querySelector(".attempt-toggle");
1600
+ var roundsEl = row.querySelector(".rounds-list");
1601
+ var built = false;
1602
+ btn.addEventListener("click", function(){
1603
+ var expanded = btn.getAttribute("aria-expanded") === "true";
1604
+ btn.setAttribute("aria-expanded", expanded ? "false" : "true");
1605
+ roundsEl.hidden = expanded;
1606
+ if(!expanded && !built){
1607
+ built = true;
1608
+ loadRounds(roundsEl, partIndex, a, 0);
1609
+ }
1610
+ });
1611
+ });
1612
+ }
1613
+
1614
+ function roundsUrl(partIndex, attempt, offset){
1615
+ var params = "part=" + encodeURIComponent(partIndex) + "&attempt=" + encodeURIComponent(attempt.n) + "&offset=" + offset;
1616
+ return "/api/runs/" + encodeURIComponent(currentRunid) + "/rounds?" + params;
1617
+ }
1618
+
1619
+ // item 1 (2026-09-26): the Grouped view's rounds render as a real TABLE,
1620
+ // same column set as Flat minus Step (implied by the group): Round ·
1621
+ // Action · Path · Decision · Time — reusing the SAME auditXCellHtml
1622
+ // builders Flat's renderAudit uses, never a second hand-rolled format.
1623
+ // A round's own model call IS a model-call row (`kind:'model-call'`) —
1624
+ // the "round N · $cost · tokens · duration" header hamr approved renders
1625
+ // as this table's own header ROW (`.round-header-row`, visually distinct),
1626
+ // its tool calls following as normal rows (a denied one reads
1627
+ // `.row-blocked`, same red as Flat).
1628
+ function renderRoundHeaderRow(r){
1629
+ var pseudo = {
1630
+ kind: "model-call", round: r.n, time: r.ts,
1631
+ costUsd: r.costUsd, tokens: r.tokens, durationMs: r.durationMs,
1632
+ };
1633
+ return '<tr class="round-header-row">' +
1634
+ '<td>' + auditRoundCellHtml(pseudo) + '</td>' +
1635
+ '<td>' + auditActionCellHtml(pseudo) + '</td>' +
1636
+ '<td>' + auditPathCellHtml(pseudo) + '</td>' +
1637
+ '<td>' + auditDecisionCellHtml(pseudo) + '</td>' +
1638
+ '<td>' + escapeXml(pseudo.time || "unknown") + '</td>' +
1639
+ '</tr>';
1640
+ }
1641
+ function renderRoundToolRow(tc){
1642
+ var rowCls = auditRowClassName(tc);
1643
+ return '<tr' + (rowCls ? ' class="' + rowCls + '"' : '') + '>' +
1644
+ '<td>' + auditRoundCellHtml(tc) + '</td>' +
1645
+ '<td>' + auditActionCellHtml(tc) + '</td>' +
1646
+ '<td>' + auditPathCellHtml(tc) + '</td>' +
1647
+ '<td>' + auditDecisionCellHtml(tc) + '</td>' +
1648
+ '<td>' + escapeXml(tc.time || "unknown") + '</td>' +
1649
+ '</tr>';
1650
+ }
1651
+ function renderRoundRows(r, toolLogSaved){
1652
+ var rows = renderRoundHeaderRow(r);
1653
+ if(toolLogSaved){
1654
+ rows += (r.toolCalls && r.toolCalls.length)
1655
+ ? r.toolCalls.map(renderRoundToolRow).join("")
1656
+ : '<tr><td colspan="5" class="hint">no tool calls</td></tr>';
1657
+ } else {
1658
+ rows += '<tr><td colspan="5" class="hint">tool calls: no log saved</td></tr>';
1659
+ }
1660
+ return rows;
1661
+ }
1662
+
1663
+ function renderRoundsPage(roundsEl, partIndex, attempt, data, appendMode){
1664
+ var rowsHtml = data.rounds.map(function(r){ return renderRoundRows(r, data.toolLogSaved); }).join("");
1665
+ var checkLine = data.check
1666
+ ? '<div class="attempt-check">check: <span class="badge ' + (data.check.outcome === "green" ? "green" : "red") + '">' + escapeXml(data.check.outcome || "unknown") + '</span>' +
1667
+ (data.check.detail ? ' — ' + escapeXml(data.check.detail) : '') + '</div>'
1668
+ : '';
1669
+ var shownTo = data.offset + data.rounds.length;
1670
+ var hasMore = shownTo < data.totalRounds;
1671
+ var pageInfo = '<div class="hint">showing ' + (data.offset + 1) + '–' + shownTo + ' of ' + data.totalRounds + '</div>';
1672
+ var loadMoreHtml = hasMore ? '<button class="btn small rounds-load-more" type="button">load more</button>' : '';
1673
+ if(appendMode){
1674
+ var existingMore = roundsEl.querySelector(".rounds-load-more");
1675
+ if(existingMore) existingMore.remove();
1676
+ var existingInfo = roundsEl.querySelector(".rounds-page-info");
1677
+ if(existingInfo) existingInfo.remove();
1678
+ var tbody = roundsEl.querySelector(".rounds-table tbody");
1679
+ tbody.insertAdjacentHTML("beforeend", rowsHtml);
1680
+ } else {
1681
+ roundsEl.innerHTML =
1682
+ '<div class="audit-table-scroll"><table class="rounds-table" data-testid="rounds-table">' +
1683
+ '<thead><tr><th>Round</th><th>Action</th><th>Path</th><th>Decision</th><th>Time</th></tr></thead>' +
1684
+ '<tbody>' + rowsHtml + '</tbody></table></div>';
1685
+ }
1686
+ roundsEl.insertAdjacentHTML("beforeend", '<div class="rounds-page-info">' + pageInfo + checkLine + '</div>');
1687
+ if(hasMore){
1688
+ var loadMoreWrap = roundsEl.querySelector(".rounds-page-info");
1689
+ loadMoreWrap.insertAdjacentHTML("beforeend", loadMoreHtml);
1690
+ loadMoreWrap.querySelector(".rounds-load-more").addEventListener("click", function(){
1691
+ loadRounds(roundsEl, partIndex, attempt, shownTo, true);
1692
+ });
1693
+ }
1694
+ }
1695
+
1696
+ function loadRounds(roundsEl, partIndex, attempt, offset, appendMode){
1697
+ if(!appendMode) roundsEl.innerHTML = '<div class="hint">loading…</div>';
1698
+ getJSON(roundsUrl(partIndex, attempt, offset)).then(function(data){
1699
+ renderRoundsPage(roundsEl, partIndex, attempt, data, !!appendMode);
1700
+ }).catch(function(e){
1701
+ roundsEl.innerHTML = '<div class="hint">failed to load rounds: ' + escapeXml(e.message) + '</div>';
1702
+ });
1703
+ }
1704
+
1705
+ // ---- Audit tab: GROUPED view (build item C, default) ----
1706
+ // One collapsed row per part (build item B/C's ONE shared list,
1707
+ // `detail.parts`), same one-line stats + blocked badge a Run-tab card
1708
+ // shows — expand -> attempts (parts with >1, e.g. a multi-attempt step or
1709
+ // the fix loop) or straight to a rounds table (every other part kind,
1710
+ // which carries exactly one synthetic whole-part attempt).
1711
+ function partHasWrites(part){
1712
+ var bt = part.byTool;
1713
+ if(!bt) return false;
1714
+ return !!(bt.edit || bt.write);
1715
+ }
1716
+ function partLine1Text(part){
1717
+ return [
1718
+ (typeof part.rounds === "number" ? part.rounds : 0) + " call" + (part.rounds === 1 ? "" : "s"),
1719
+ typeof part.toolCalls === "number" ? (part.toolCalls + " tool" + (part.toolCalls === 1 ? "" : "s")) : "unknown tools",
1720
+ duration(part.wallMs),
1721
+ part.unpricedRounds > 0 ? "unknown (" + part.unpricedRounds + " unpriced round" + (part.unpricedRounds === 1 ? "" : "s") + ")" : money(part.spentUsd),
1722
+ ].join(" &middot; ");
1723
+ }
1724
+ var currentAuditFilter = "all";
1725
+ function auditPartMatchesFilter(rowWrap, filter){
1726
+ if(filter === "all") return true;
1727
+ if(filter === "write") return rowWrap.getAttribute("data-writes") === "1";
1728
+ if(filter === "blocked") return rowWrap.getAttribute("data-blocked") === "1";
1729
+ return true;
1730
+ }
1731
+ function applyAuditFilter(filter){
1732
+ currentAuditFilter = filter;
1733
+ document.querySelectorAll(".audit-part").forEach(function(rowWrap){
1734
+ var matches = auditPartMatchesFilter(rowWrap, filter);
1735
+ rowWrap.style.display = matches ? "" : "none";
1736
+ if(matches && filter !== "all"){
1737
+ var btn = rowWrap.querySelector(".audit-part-toggle");
1738
+ if(btn && btn.getAttribute("aria-expanded") !== "true") btn.click();
1739
+ }
1740
+ });
1741
+ }
1742
+ function renderAuditGroups(detail){
1743
+ var container = document.getElementById("audit-groups");
1744
+ container.innerHTML = "";
1745
+ var parts = Array.isArray(detail.parts) ? detail.parts : [];
1746
+ var isLiveRun = detail.glyph === "▶" && !detail.died;
1747
+ var boxes = buildOrderedBoxes(parts, isLiveRun);
1748
+ var numberMap = stepNumberIndices(boxes);
1749
+ boxes.forEach(function(box, idx){
1750
+ var part = box.part;
1751
+ var titleText = box.kind === "step" ? ((numberMap[idx] + 1) + ' &middot; ' + escapeXml(box.title)) : escapeXml(box.title);
1752
+ var badgeClass = box.state === "done" ? "green" : (box.state === "stopped" ? "red" : (box.state === "running" ? "amber" : (box.state === "died" ? "magenta" : "grey")));
1753
+ var rowWrap = document.createElement("div");
1754
+ rowWrap.className = "audit-part";
1755
+ rowWrap.setAttribute("data-testid", "audit-part-" + idx);
1756
+ if(box.blocked > 0) rowWrap.setAttribute("data-blocked", "1");
1757
+ if(partHasWrites(part)) rowWrap.setAttribute("data-writes", "1");
1758
+ var headBtn = document.createElement("button");
1759
+ headBtn.type = "button";
1760
+ headBtn.className = "btn audit-part-toggle";
1761
+ var startExpanded = box.state === "running";
1762
+ headBtn.setAttribute("aria-expanded", startExpanded ? "true" : "false");
1763
+ headBtn.setAttribute("data-testid", "audit-part-toggle-" + idx);
1764
+ var groupResultGlyph = partResultGlyph(part, box);
1765
+ headBtn.innerHTML =
1766
+ '<span class="badge ' + badgeClass + '">' + escapeXml(box.state) + '</span> ' + titleText + ' &middot; ' + partLine1Text(part) +
1767
+ (groupResultGlyph !== null ? ' &middot; ' + groupResultGlyph : '') +
1768
+ (box.blocked > 0 ? ' <span class="badge red" data-testid="audit-part-blocked-' + idx + '">' + box.blocked + ' blocked</span>' : '');
1769
+ var body = document.createElement("div");
1770
+ body.className = "audit-part-body";
1771
+ body.hidden = !startExpanded;
1772
+ body.setAttribute("data-testid", "audit-part-body-" + idx);
1773
+ var attempts = Array.isArray(part.attempts) ? part.attempts : [];
1774
+ var built = false;
1775
+ var buildBody = function(){
1776
+ built = true;
1777
+ var toolsHeader = document.createElement("div");
1778
+ toolsHeader.className = "hint";
1779
+ toolsHeader.textContent = toolBreakdownLine(part.byTool);
1780
+ body.appendChild(toolsHeader);
1781
+ if(attempts.length > 1){
1782
+ // parts WITH an attempt sub-loop (a multi-attempt step, or the fix
1783
+ // loop) expand to attempt rows first — each attempt expands to its
1784
+ // own rounds table on its own click, same as the Run tab used to.
1785
+ var attemptsWrap = document.createElement("div");
1786
+ attemptsWrap.className = "attempts-list";
1787
+ body.appendChild(attemptsWrap);
1788
+ buildAttemptsList(attemptsWrap, idx, attempts, box.state === "running");
1789
+ } else {
1790
+ // parts with NO sub-loop (scout, plan, replan, judge, run, or a
1791
+ // single-attempt step) expand STRAIGHT to their rounds table.
1792
+ var roundsEl = document.createElement("div");
1793
+ roundsEl.className = "rounds-list";
1794
+ body.appendChild(roundsEl);
1795
+ loadRounds(roundsEl, idx, attempts[0] || { n: 1 }, 0);
1796
+ }
1797
+ };
1798
+ headBtn.addEventListener("click", function(){
1799
+ var expanded = headBtn.getAttribute("aria-expanded") === "true";
1800
+ headBtn.setAttribute("aria-expanded", expanded ? "false" : "true");
1801
+ body.hidden = expanded;
1802
+ if(!expanded && !built) buildBody();
1803
+ });
1804
+ rowWrap.appendChild(headBtn);
1805
+ rowWrap.appendChild(body);
1806
+ container.appendChild(rowWrap);
1807
+ if(startExpanded && !built) buildBody(); // a live run's currently-running part opens by default
1808
+ });
1809
+ applyAuditFilter(currentAuditFilter);
1810
+ }
1811
+ // build item B: a Run-tab card (or map box) click switches to the Audit
1812
+ // tab, that same part's row expanded and scrolled into view — sharing
1813
+ // partIndex so the two tabs can never disagree about which row a click maps to.
1814
+ function openAuditPart(idx){
1815
+ setAuditView("grouped");
1816
+ var row = document.querySelector('[data-testid="audit-part-' + idx + '"]');
1817
+ if(!row) return;
1818
+ var btn = row.querySelector(".audit-part-toggle");
1819
+ if(btn && btn.getAttribute("aria-expanded") !== "true") btn.click();
1820
+ if(typeof row.scrollIntoView === "function") row.scrollIntoView({ block: "center" });
1821
+ }
1822
+ function setAuditView(view){
1823
+ document.getElementById("audit-groups").hidden = view !== "grouped";
1824
+ document.getElementById("audit-flat-wrap").hidden = view !== "flat";
1825
+ document.querySelectorAll(".chip[data-view]").forEach(function(c){ c.setAttribute("aria-pressed", c.getAttribute("data-view") === view ? "true" : "false"); });
1826
+ }
1827
+ document.querySelectorAll(".chip[data-view]").forEach(function(chip){
1828
+ chip.addEventListener("click", function(){ setAuditView(chip.getAttribute("data-view")); });
1829
+ });
1830
+
1831
+ // ---- Audit tab: FLAT view (the old one-row-per-call time-ordered table) ----
1832
+ var auditRowEls = [];
1833
+ function renderAudit(result){
1834
+ var empty = document.getElementById("audit-empty");
1835
+ var content = document.getElementById("audit-content");
1836
+ if(!result || result.empty){
1837
+ empty.hidden = false;
1838
+ var reason = result ? result.reason : "no-sidecar";
1839
+ empty.textContent = reason === "sidecar-empty"
1840
+ ? "log saved, no tool calls recorded"
1841
+ : "no log was saved for this run (older version)";
1842
+ content.hidden = true;
1843
+ return;
1844
+ }
1845
+ empty.hidden = true;
1846
+ content.hidden = false;
1847
+ var tbody = document.getElementById("audit-tbody");
1848
+ tbody.innerHTML = "";
1849
+ result.rows.forEach(function(r){
1850
+ var tr = document.createElement("tr");
1851
+ var rowCls = auditRowClassName(r);
1852
+ if(rowCls) tr.className = rowCls;
1853
+ var roundCell = auditRoundCellHtml(r);
1854
+ var decisionCell = auditDecisionCellHtml(r);
1855
+ // build item C rewrite (2026-09-26): the Step cell is derived from this
1856
+ // row's own ROUND's seq matched against `summary.parts`' own attempt
1857
+ // windows (never phase-string guessing, never ts-guessing) —
1858
+ // `r.partLabel` is a real part label (scout/plan/a step id/replan/fix/
1859
+ // judge/run); `r.reason` names WHY it's null when it is: "no-round"
1860
+ // (before the very first model call) vs "unassigned" (a round exists
1861
+ // but its seq matched no part window).
1862
+ var stepCell = r.partLabel === null
1863
+ ? "<td title=\"" + (r.reason === "unassigned" ? "this round's part wasn't recorded" : "before the first model call") + "\">&mdash;</td>"
1864
+ : "<td>" + escapeXml(r.partLabel) + (typeof r.attemptN === "number" ? " &middot; a" + r.attemptN : "") + "</td>";
1865
+ // item 2 (2026-09-26): the Path cell shows the SHORT (tree-root-relative)
1866
+ // path with the full absolute path in a title tooltip — a model-call row
1867
+ // never carries a path at all (the real record: `phase:'record'`/
1868
+ // `action.type:'llm'` has no `action.path`), so "&mdash;" says so
1869
+ // plainly (see {@link auditPathCellHtml}).
1870
+ var pathCell = auditPathCellHtml(r);
1871
+ tr.innerHTML =
1872
+ "<td>" + roundCell + "</td>" +
1873
+ stepCell +
1874
+ "<td>" + auditActionCellHtml(r) + "</td>" +
1875
+ "<td>" + pathCell + "</td>" +
1876
+ "<td>" + decisionCell + "</td>" +
1877
+ "<td>" + escapeXml(r.time || "unknown") + "</td>";
1878
+ tbody.appendChild(tr);
1879
+ });
1880
+ document.getElementById("audit-rawlog").textContent = result.raw || "";
1881
+ auditRowEls = Array.prototype.slice.call(tbody.querySelectorAll("tr"));
1882
+ // Scoped to [data-filter] — the audit tab's OWN chips (chip-all/writes/
1883
+ // blocked). A bare ".chip" here also matched the History filter bar's
1884
+ // chips (same class, different data- attributes), binding a second
1885
+ // listener on them and forcing their aria-pressed back to "true" on
1886
+ // every click regardless of the actual toggle state (hamr's live-panel
1887
+ // feedback, 2026-09-25: "selection is buggy", "clear doesnt work").
1888
+ document.querySelectorAll(".chip[data-filter]").forEach(function(c){ c.setAttribute("aria-pressed", c.getAttribute("data-filter") === "all" ? "true" : "false"); });
1889
+ auditRowEls.forEach(function(row){ row.style.display = ""; });
1890
+ }
1891
+ document.querySelectorAll(".chip[data-filter]").forEach(function(chip){
1892
+ chip.addEventListener("click", function(){
1893
+ document.querySelectorAll(".chip[data-filter]").forEach(function(c){ c.setAttribute("aria-pressed","false"); });
1894
+ chip.setAttribute("aria-pressed","true");
1895
+ var f = chip.getAttribute("data-filter");
1896
+ auditRowEls.forEach(function(row){
1897
+ if(f === "all"){ row.style.display = ""; }
1898
+ else if(f === "write"){ row.style.display = row.classList.contains("row-write") ? "" : "none"; }
1899
+ else if(f === "blocked"){ row.style.display = row.classList.contains("row-blocked") ? "" : "none"; }
1900
+ });
1901
+ // same All/Writes/Blocked chip group also drives the grouped view
1902
+ // (build item C): auto-open only the parts that match, hide the rest.
1903
+ applyAuditFilter(f);
1904
+ });
1905
+ });
1906
+
1907
+ // ---- Job tab ----
1908
+ function renderJob(job){
1909
+ var empty = document.getElementById("job-empty");
1910
+ var card = document.getElementById("job-card");
1911
+ if(!job){ empty.hidden = false; card.hidden = true; return; }
1912
+ empty.hidden = true;
1913
+ card.hidden = false;
1914
+ document.getElementById("details-title").textContent = "Job — " + job.job + (job.resolved ? " (signed)" : "");
1915
+ document.getElementById("details-verdict").textContent = job.checkType;
1916
+ var verdictEl = document.getElementById("details-verdict");
1917
+ if(job.checkTypeTitle){ verdictEl.title = job.checkTypeTitle; } else { verdictEl.removeAttribute("title"); }
1918
+ document.getElementById("details-model").textContent = job.model;
1919
+ var descField = document.getElementById("details-description-field");
1920
+ if(job.description){ descField.hidden = false; document.getElementById("details-description").textContent = job.description; }
1921
+ else { descField.hidden = true; }
1922
+ document.getElementById("details-goal").textContent = job.goal;
1923
+ document.getElementById("details-success").textContent = job.success;
1924
+ document.getElementById("details-guardrails").textContent = job.guardrails;
1925
+ document.getElementById("details-source").textContent = job.source;
1926
+ document.getElementById("details-dest").textContent = job.destination;
1927
+ document.getElementById("details-tools").textContent = job.tools;
1928
+ document.getElementById("details-cap-money").textContent = job.budgetUsd === null ? "unknown" : ("$" + Number(job.budgetUsd).toFixed(2));
1929
+ document.getElementById("details-cap-time").textContent = job.maxWallMs === null ? "unknown" : duration(job.maxWallMs);
1930
+ document.getElementById("job-note").textContent = job.note || "";
1931
+ lastJobToolsList = Array.isArray(job.toolsList) ? job.toolsList : null;
1932
+ paintOfferedRow();
1933
+ }
1934
+
1935
+ // ---- selection + loading ----
1936
+ var currentRunid = null;
1937
+ // P2: guards a stale in-flight response (detail/audit/job, OR the poll
1938
+ // loop's own run-detail refetch) from overwriting whatever run is
1939
+ // actually selected by the time it resolves — bumped on every selectRun
1940
+ // call, checked before any of those responses touch the DOM.
1941
+ var selectToken = 0;
1942
+ // P2: true while the OPEN run's own detail poll should keep firing (set
1943
+ // from the run's own `glyph`/`died` fields, same rule `renderRun` already
1944
+ // uses for `isLive` — never re-derived a second way).
1945
+ var runIsLive = false;
1946
+ function selectRun(runid, rowEl, siblingSelector){
1947
+ clearSelection();
1948
+ document.querySelectorAll(siblingSelector).forEach(function(r){ r.classList.remove("selected"); r.setAttribute("aria-pressed","false"); });
1949
+ if(rowEl){ rowEl.classList.add("selected"); rowEl.setAttribute("aria-pressed","true"); }
1950
+ currentRunid = runid;
1951
+ lastJobToolsList = null;
1952
+ lastRunDetailSig = null;
1953
+ runIsLive = false;
1954
+ var myToken = ++selectToken;
1955
+ getJSON("/api/runs/" + encodeURIComponent(runid)).then(function(detail){
1956
+ if(myToken !== selectToken) return; // a different run was selected before this resolved
1957
+ lastRunDetailSig = sig(detail);
1958
+ runIsLive = detail.glyph === "▶" && !detail.died;
1959
+ renderRun(detail);
1960
+ }).catch(function(e){
1961
+ if(myToken !== selectToken) return;
1962
+ document.getElementById("run-empty").hidden = false;
1963
+ document.getElementById("run-empty").textContent = "failed to load run: " + e.message;
1964
+ document.getElementById("run-content").hidden = true;
1965
+ });
1966
+ getJSON("/api/runs/" + encodeURIComponent(runid) + "/audit").then(function(result){
1967
+ if(myToken !== selectToken) return;
1968
+ renderAudit(result);
1969
+ }).catch(function(){ if(myToken === selectToken) renderAudit(null); });
1970
+ getJSON("/api/runs/" + encodeURIComponent(runid) + "/job").then(function(job){
1971
+ if(myToken !== selectToken) return;
1972
+ renderJob(job);
1973
+ }).catch(function(){ if(myToken === selectToken) renderJob(null); });
1974
+ }
1975
+
1976
+ // Q2 (2026-09-27 ruling): the Audit tab does NOT live-refresh on the 2s
1977
+ // poll — it re-fetches only when the person opens/switches to it.
1978
+ document.getElementById("tab-audit").addEventListener("click", function(){
1979
+ if(!currentRunid) return;
1980
+ var myToken = selectToken;
1981
+ getJSON("/api/runs/" + encodeURIComponent(currentRunid) + "/audit").then(function(result){
1982
+ if(myToken !== selectToken) return;
1983
+ renderAudit(result);
1984
+ }).catch(function(){});
1985
+ });
1986
+
1987
+ // ---- P2: live polling — ONE owner for both the left Runs list and the
1988
+ // open live run's map/cards/counters/summary (hamr's ruling, 2026-09-27:
1989
+ // 2s interval for the open run's own detail; the open run's own detail
1990
+ // refetch stops once it is no longer live; the Audit tab is wired
1991
+ // separately above and never joins this loop). A single `setInterval`
1992
+ // drives both fetches — never two independent timers that could race
1993
+ // each other. ----
1994
+ //
1995
+ // F195 (docs/logs/FINDINGS.md): /api/runs re-replays every spine on
1996
+ // every call (~457ms CPU at 250 rows). hamr's ruling B, 2026-09-27:
1997
+ // "refresh less often, up to 30s at most, choose the lesser when
1998
+ // possible" — the Runs list polls every 10s (RUNS_LIST_POLL_MS) instead
1999
+ // of every 2s tick; the open run's own detail is unaffected. The 2s
2000
+ // `pollTick` still fires the list check every tick, but `refreshRunsList`
2001
+ // itself skips the fetch unless RUNS_LIST_POLL_MS has elapsed since the
2002
+ // last one — one timer, one owner, just a throttle inside it. The one
2003
+ // exception: the instant the open run's own poll sees it stop being live
2004
+ // (a real verdict or died), `pollTick` forces ONE immediate list refresh
2005
+ // so that run's left-list glyph flips right away instead of waiting up
2006
+ // to 10s.
2007
+ var POLL_MS = 2000;
2008
+ var RUNS_LIST_POLL_MS = 10000;
2009
+ var pollTimer = null;
2010
+ var lastRunsSig = null;
2011
+ var lastRunDetailSig = null;
2012
+ var lastRunsListRefreshAt = 0;
2013
+ var pollFailStreak = 0;
2014
+
2015
+ function sig(obj){
2016
+ try { return JSON.stringify(obj); } catch(e){ return null; }
2017
+ }
2018
+
2019
+ // Saves/restores scrollTop of the given element ids around a re-render —
2020
+ // both `.pane-body` scroll containers keep their offset across a poll-
2021
+ // driven rebuild instead of snapping back to the top.
2022
+ function withScrollPreserved(ids, fn){
2023
+ var saved = ids.map(function(id){
2024
+ var el = document.getElementById(id);
2025
+ return el ? el.scrollTop : null;
2026
+ });
2027
+ fn();
2028
+ ids.forEach(function(id, i){
2029
+ if(saved[i] === null) return;
2030
+ var el = document.getElementById(id);
2031
+ if(el) el.scrollTop = saved[i];
2032
+ });
2033
+ }
2034
+
2035
+ function refreshRunsList(force){
2036
+ var now = Date.now();
2037
+ if(!force && (now - lastRunsListRefreshAt) < RUNS_LIST_POLL_MS) return Promise.resolve();
2038
+ lastRunsListRefreshAt = now;
2039
+ return getJSON("/api/runs").then(function(result){
2040
+ pollFailStreak = 0;
2041
+ var runs = result.runs || [];
2042
+ var next = sig(runs);
2043
+ if(next === lastRunsSig) return; // unchanged payload — never rebuild the DOM for nothing
2044
+ lastRunsSig = next;
2045
+ withScrollPreserved(["left-pane-body"], function(){
2046
+ // setItems re-runs the SAME filter/search state and view mode
2047
+ // (runsFilterFn/runsApplyFn close over runsViewMode and the filter
2048
+ // bar's own state) — a poll tick never resets either.
2049
+ runsFilterBar.setItems(runs);
2050
+ });
2051
+ }).catch(function(){
2052
+ pollFailStreak++;
2053
+ // Keep the last good render; a transient failure is invisible. Only a
2054
+ // PERSISTENT failure (3+ ticks, ~6s) gets a quiet, non-alarming note —
2055
+ // the page itself is never wiped.
2056
+ if(pollFailStreak === 3){
2057
+ var countEl = document.getElementById("runs-filter-count");
2058
+ if(countEl) countEl.textContent = "runs list: fetch failing, showing last known data";
2059
+ }
2060
+ });
2061
+ }
2062
+
2063
+ function refreshOpenRun(){
2064
+ if(!currentRunid || !runIsLive) return Promise.resolve();
2065
+ var runid = currentRunid;
2066
+ var myToken = selectToken;
2067
+ return getJSON("/api/runs/" + encodeURIComponent(runid)).then(function(detail){
2068
+ // stale guard: a different run was selected (or re-selected) while
2069
+ // this request was in flight — never let it overwrite what's open now.
2070
+ if(myToken !== selectToken || runid !== currentRunid) return;
2071
+ var next = sig(detail);
2072
+ var changed = next !== lastRunDetailSig;
2073
+ lastRunDetailSig = next;
2074
+ runIsLive = detail.glyph === "▶" && !detail.died;
2075
+ if(changed){
2076
+ withScrollPreserved(["right-pane-body"], function(){ renderRun(detail); });
2077
+ }
2078
+ }).catch(function(){ /* keep last good render; retry next tick */ });
2079
+ }
2080
+
2081
+ function pollTick(forceList){
2082
+ if(document.hidden) return;
2083
+ refreshRunsList(forceList);
2084
+ var wasLive = runIsLive;
2085
+ return refreshOpenRun().then(function(){
2086
+ // the open run just stopped being live (real verdict or died) —
2087
+ // don't make the left-list glyph wait up to RUNS_LIST_POLL_MS to
2088
+ // catch up; force one immediate refresh.
2089
+ if(wasLive && !runIsLive) refreshRunsList(true);
2090
+ });
2091
+ }
2092
+
2093
+ function startPolling(){
2094
+ if(pollTimer) return;
2095
+ pollTimer = setInterval(pollTick, POLL_MS);
2096
+ }
2097
+ function stopPolling(){
2098
+ if(!pollTimer) return;
2099
+ clearInterval(pollTimer);
2100
+ pollTimer = null;
2101
+ }
2102
+ // Pause while the tab is hidden (cheap, standard); resume — with an
2103
+ // immediate catch-up tick, forcing BOTH fetches regardless of the list's
2104
+ // 10s throttle — on return, rather than waiting a full 2s (or up to 10s
2105
+ // for the list).
2106
+ document.addEventListener("visibilitychange", function(){
2107
+ if(!document.hidden) pollTick(true);
2108
+ });
2109
+
2110
+ getJSON("/api/runs").then(function(result){
2111
+ var runs = result.runs || [];
2112
+ lastRunsSig = sig(runs);
2113
+ runsFilterBar.setItems(runs);
2114
+ if(runs.length && !runs[0].fileMissing){
2115
+ document.getElementById("tab-runs").click();
2116
+ // newest run overall, opened directly — the Workflows view starts
2117
+ // collapsed, but its job row still renders "selected" (build item,
2118
+ // 2026-09-26: a job row shows selected state like a child row) since
2119
+ // `runsFilterBar.refresh()` re-renders AFTER `currentRunid` is set.
2120
+ selectRun(runs[0].runid, null, ".hist-row");
2121
+ document.getElementById("tab-run").click();
2122
+ runsFilterBar.refresh();
2123
+ }
2124
+ startPolling();
2125
+ }).catch(function(e){
2126
+ document.getElementById("wf-list").innerHTML = '<div class="hint">failed to load: ' + escapeXml(e.message) + '</div>';
2127
+ document.getElementById("history-list").innerHTML = '<div class="hint">failed to load: ' + escapeXml(e.message) + '</div>';
2128
+ startPolling(); // still start — a transient initial failure shouldn't block all future polling
2129
+ });
2130
+
2131
+ // theme toggle
2132
+ (function(){
2133
+ var root = document.documentElement;
2134
+ var btn = document.getElementById("theme-toggle");
2135
+ var stored = null;
2136
+ try{ stored = localStorage.getItem("bareloop-panel-theme"); }catch(e){}
2137
+ if(stored === "light" || stored === "dark"){ root.setAttribute("data-theme", stored); }
2138
+ function current(){
2139
+ var attr = root.getAttribute("data-theme");
2140
+ if(attr) return attr;
2141
+ return (window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches) ? "light" : "dark";
2142
+ }
2143
+ function paint(){ btn.textContent = current() === "dark" ? "☾" : "☀"; }
2144
+ paint();
2145
+ btn.addEventListener("click", function(){
2146
+ var next = current() === "dark" ? "light" : "dark";
2147
+ root.setAttribute("data-theme", next);
2148
+ try{ localStorage.setItem("bareloop-panel-theme", next); }catch(e){}
2149
+ paint();
2150
+ });
2151
+ })();
2152
+
2153
+ })();
2154
+ </script>
2155
+ </body>
2156
+ </html>