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.
- package/CHANGELOG.md +114 -0
- package/bareloop.context.md +245 -33
- package/package.json +1 -1
- package/src/authorreadout.js +323 -0
- package/src/authorrun.js +1345 -0
- package/src/behaviour.js +5 -2
- package/src/cli.js +190 -8
- package/src/index.js +13 -1
- package/src/interviewrun.js +842 -0
- package/src/panel/index.html +1910 -0
- package/src/panel/server.js +1527 -0
- package/src/planrun.js +1 -1
- package/src/replay.js +632 -13
- package/src/replayio.js +159 -0
- package/src/reuse.js +1 -1
- package/src/run.js +1 -1
- package/src/runlist.js +281 -0
- package/src/u-patient.js +73 -0
- package/src/u-readout.js +451 -0
- package/src/userrun.js +2299 -0
- package/types/authorreadout.d.ts +172 -0
- package/types/authorrun.d.ts +37 -0
- package/types/behaviour.d.ts +5 -3
- package/types/cli.d.ts +2 -1
- package/types/index.d.ts +2 -1
- package/types/interviewrun.d.ts +46 -0
- package/types/panel/server.d.ts +265 -0
- package/types/planrun.d.ts +2 -2
- package/types/replay.d.ts +104 -6
- package/types/replayio.d.ts +93 -0
- package/types/reuse.d.ts +2 -2
- package/types/run.d.ts +2 -2
- package/types/runlist.d.ts +112 -0
- package/types/u-patient.d.ts +39 -0
- package/types/u-readout.d.ts +247 -0
- package/types/userrun.d.ts +141 -0
|
@@ -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">☽</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">↑ 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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
|
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="✓" title="pass" data-testid="chip-' + scope + '-res-pass">✓</button>' +
|
|
1021
|
+
'<button class="chip fchip" type="button" aria-pressed="false" data-filter-group="result" data-filter-value="✗" title="fail" data-testid="chip-' + scope + '-res-fail">✗</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="▶" title="running" data-testid="chip-' + scope + '-res-running">▶</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) : "—"; }
|
|
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 "—";
|
|
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
|
+
" · " + 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> · ' +
|
|
1430
|
+
'<span class="num">' + spendText + '</span> · <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 ? ' · ' + 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) + ' · ' + 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(" · ") + '</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(" · ");
|
|
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) + ' · ' + 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 + ' · ' + partLine1Text(part) +
|
|
1681
|
+
(groupResultGlyph !== null ? ' · ' + 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") + "\">—</td>"
|
|
1778
|
+
: "<td>" + escapeXml(r.partLabel) + (typeof r.attemptN === "number" ? " · 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 "—" 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>
|