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