glassbox-trace 0.3.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/LICENSE +21 -0
- package/README.md +120 -0
- package/bin/glassbox.mjs +3 -0
- package/dist/glassbox.html +1653 -0
- package/package.json +54 -0
- package/src/cli.mjs +246 -0
- package/src/trace-core.js +768 -0
- package/src/viewer.html +875 -0
package/src/viewer.html
ADDED
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
<title>Glassbox</title>
|
|
2
|
+
<meta name="description" content="The flight recorder viewer for Claude Code and Agent SDK sessions. Drop a session .jsonl; get a timeline, token and cost burn, and auto-diagnostics. Nothing leaves your browser.">
|
|
3
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 64 64%27%3E%3Crect width=%2764%27 height=%2764%27 rx=%2712%27 fill=%27%23161C24%27/%3E%3Crect x=%2712%27 y=%2728%27 width=%2740%27 height=%278%27 rx=%272%27 fill=%27%23FF8A4D%27/%3E%3Crect x=%2712%27 y=%2716%27 width=%2724%27 height=%278%27 rx=%272%27 fill=%27%237DB0F0%27/%3E%3Crect x=%2724%27 y=%2740%27 width=%2728%27 height=%278%27 rx=%272%27 fill=%27%234FD0BB%27/%3E%3C/svg%3E">
|
|
4
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Condensed:wght@500;600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
|
|
5
|
+
<style>
|
|
6
|
+
:root{
|
|
7
|
+
--ground:#F2F4F7;--panel:#FFFFFF;--panel-2:#F7F8FA;--line:#CDD4DD;--line-2:#E6EAF0;
|
|
8
|
+
--ink:#161C24;--ink-2:#3C4655;--muted:#4F5A69;--faint:#5F6B7A;
|
|
9
|
+
--accent:#B8420A;--accent-ink:#FFFFFF;--accent-soft:rgba(184,66,10,.12);
|
|
10
|
+
--c-read:#3B7DD8;--c-write:#B27F00;--c-exec:#1F9D8A;--c-agent:#7C5CD6;--c-user:#C2417F;--c-mcp:#5B6A80;--c-other:#9AA4B2;--c-model:#8290A0;--c-idle:#E3E7ED;
|
|
11
|
+
--l-read:#0E1217;--l-write:#0E1217;--l-exec:#0E1217;--l-agent:#FFFFFF;--l-user:#FFFFFF;--l-mcp:#FFFFFF;--l-other:#0E1217;
|
|
12
|
+
--err:#C4342A;--warn:#8A5F00;--info:#2A63B8;--ok:#177A6B;
|
|
13
|
+
--err-soft:rgba(196,52,42,.12);--warn-soft:rgba(138,95,0,.14);--info-soft:rgba(42,99,184,.12);
|
|
14
|
+
--focus:#B8420A;
|
|
15
|
+
--shadow:0 10px 30px rgba(22,28,36,.14);
|
|
16
|
+
--sans:"IBM Plex Sans",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
|
17
|
+
--cond:"IBM Plex Sans Condensed","Arial Narrow",var(--sans);
|
|
18
|
+
--mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
19
|
+
--fs:14px;--pad:12px;--rowh:18px;
|
|
20
|
+
}
|
|
21
|
+
@media (prefers-color-scheme: dark){
|
|
22
|
+
:root:not([data-theme="light"]){
|
|
23
|
+
--ground:#0E1217;--panel:#161C24;--panel-2:#1B222C;--line:#33404F;--line-2:#222B36;
|
|
24
|
+
--ink:#E7EBF0;--ink-2:#C5CDD8;--muted:#A3AEBE;--faint:#8A98AA;
|
|
25
|
+
--accent:#FF8A4D;--accent-ink:#0E1217;--accent-soft:rgba(255,138,77,.16);
|
|
26
|
+
--c-read:#7DB0F0;--c-write:#F0C24A;--c-exec:#4FD0BB;--c-agent:#B39CF5;--c-user:#F088B8;--c-mcp:#A5B2C6;--c-other:#8B97A6;--c-model:#75859A;--c-idle:#1E2630;
|
|
27
|
+
--l-read:#0E1217;--l-write:#0E1217;--l-exec:#0E1217;--l-agent:#0E1217;--l-user:#0E1217;--l-mcp:#0E1217;--l-other:#0E1217;
|
|
28
|
+
--err:#F27C72;--warn:#E9B63A;--info:#7FB2F0;--ok:#4FD0BB;
|
|
29
|
+
--err-soft:rgba(242,124,114,.16);--warn-soft:rgba(233,182,58,.16);--info-soft:rgba(127,178,240,.16);
|
|
30
|
+
--focus:#FF8A4D;
|
|
31
|
+
--shadow:0 10px 30px rgba(0,0,0,.5);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
:root[data-theme="dark"]{
|
|
35
|
+
--ground:#0E1217;--panel:#161C24;--panel-2:#1B222C;--line:#33404F;--line-2:#222B36;
|
|
36
|
+
--ink:#E7EBF0;--ink-2:#C5CDD8;--muted:#A3AEBE;--faint:#8A98AA;
|
|
37
|
+
--accent:#FF8A4D;--accent-ink:#0E1217;--accent-soft:rgba(255,138,77,.16);
|
|
38
|
+
--c-read:#7DB0F0;--c-write:#F0C24A;--c-exec:#4FD0BB;--c-agent:#B39CF5;--c-user:#F088B8;--c-mcp:#A5B2C6;--c-other:#8B97A6;--c-model:#75859A;--c-idle:#1E2630;
|
|
39
|
+
--l-read:#0E1217;--l-write:#0E1217;--l-exec:#0E1217;--l-agent:#0E1217;--l-user:#0E1217;--l-mcp:#0E1217;--l-other:#0E1217;
|
|
40
|
+
--err:#F27C72;--warn:#E9B63A;--info:#7FB2F0;--ok:#4FD0BB;
|
|
41
|
+
--err-soft:rgba(242,124,114,.16);--warn-soft:rgba(233,182,58,.16);--info-soft:rgba(127,178,240,.16);
|
|
42
|
+
--focus:#FF8A4D;
|
|
43
|
+
--shadow:0 10px 30px rgba(0,0,0,.5);
|
|
44
|
+
}
|
|
45
|
+
:root[data-density="compact"]{--fs:12.5px;--pad:8px;--rowh:15px}
|
|
46
|
+
:root[data-density="comfortable"]{--fs:16px;--pad:14px;--rowh:22px}
|
|
47
|
+
*{box-sizing:border-box}
|
|
48
|
+
[hidden]{display:none!important}
|
|
49
|
+
body{margin:0;background:var(--ground);color:var(--ink);font:var(--fs)/1.45 var(--sans);-webkit-font-smoothing:antialiased}
|
|
50
|
+
button,input,select{font:inherit;color:inherit}
|
|
51
|
+
button{cursor:pointer}
|
|
52
|
+
a{color:var(--accent)}
|
|
53
|
+
.mono{font-family:var(--mono);font-variant-numeric:tabular-nums}
|
|
54
|
+
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
|
55
|
+
:focus-visible{outline:2px solid var(--focus);outline-offset:2px}
|
|
56
|
+
.app{max-width:1440px;margin:0 auto;padding:0 20px 80px}
|
|
57
|
+
|
|
58
|
+
/* masthead */
|
|
59
|
+
.mast{display:flex;align-items:center;gap:16px;padding:16px 0 12px;border-bottom:1px solid var(--line);flex-wrap:wrap}
|
|
60
|
+
.brand{display:flex;align-items:baseline;gap:10px}
|
|
61
|
+
.brand h1{font:600 26px/1 var(--cond);letter-spacing:.01em;margin:0}
|
|
62
|
+
.brand h1 span{color:var(--accent)}
|
|
63
|
+
.brand .tag{color:var(--muted);font-size:13px}
|
|
64
|
+
.drop{flex:1;min-width:260px;display:flex;align-items:center;gap:12px;border:1.5px dashed var(--line);border-radius:6px;padding:8px 12px;background:var(--panel);transition:border-color .15s,background .15s;cursor:pointer}
|
|
65
|
+
.drop.over{border-color:var(--accent);background:var(--accent-soft)}
|
|
66
|
+
.drop .hint{color:var(--muted);font-size:13px;flex:1}
|
|
67
|
+
.drop .hint b{color:var(--ink);font-weight:500}
|
|
68
|
+
.btn{border:1px solid var(--line);background:var(--panel);border-radius:5px;padding:6px 11px;font-size:13px;font-weight:500;color:var(--ink-2);white-space:nowrap;display:inline-flex;align-items:center;gap:6px}
|
|
69
|
+
.btn:hover{border-color:var(--muted)}
|
|
70
|
+
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-ink)}
|
|
71
|
+
.btn[aria-pressed="true"],.btn.on{background:var(--accent-soft);border-color:var(--accent);color:var(--ink)}
|
|
72
|
+
.btn.sm{padding:3px 8px;font-size:12px}
|
|
73
|
+
.btn:disabled{opacity:.45;cursor:not-allowed}
|
|
74
|
+
.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
75
|
+
.banner{display:flex;gap:12px;align-items:center;margin:12px 0 0;padding:10px 14px;border:1px solid var(--accent);background:var(--accent-soft);border-radius:6px;font-size:13.5px}
|
|
76
|
+
.banner p{margin:0;flex:1}
|
|
77
|
+
.banner code{font-family:var(--mono);font-size:12.5px}
|
|
78
|
+
|
|
79
|
+
/* session line */
|
|
80
|
+
.session{display:flex;gap:18px;flex-wrap:wrap;align-items:baseline;padding:10px 0 0;color:var(--muted);font-size:13px}
|
|
81
|
+
.session .title{color:var(--ink);font-weight:500;font-size:15px}
|
|
82
|
+
.session .k{color:var(--faint)}
|
|
83
|
+
|
|
84
|
+
/* stat strip */
|
|
85
|
+
.strip{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:1px;background:var(--line);border:1px solid var(--line);border-radius:6px;overflow:hidden;margin:16px 0}
|
|
86
|
+
.stat{background:var(--panel);padding:10px var(--pad);min-width:0;margin:0}
|
|
87
|
+
.stat dt{font:500 11px/1 var(--cond);letter-spacing:.08em;text-transform:uppercase;color:var(--faint);margin-bottom:6px}
|
|
88
|
+
.stat dd{margin:0}
|
|
89
|
+
.stat .v{font:500 20px/1.1 var(--mono);font-variant-numeric:tabular-nums;color:var(--ink);white-space:nowrap}
|
|
90
|
+
.stat .s{font-size:12px;line-height:1.3;color:var(--muted);margin-top:4px}
|
|
91
|
+
.stat.f .v{display:flex;gap:10px}
|
|
92
|
+
.sev{display:inline-flex;align-items:center;gap:5px}
|
|
93
|
+
.sev::before{content:"";width:8px;height:8px;border-radius:2px;background:currentColor}
|
|
94
|
+
.sev.error{color:var(--err)}.sev.warn{color:var(--warn)}.sev.info{color:var(--info)}
|
|
95
|
+
|
|
96
|
+
/* sections */
|
|
97
|
+
.sec{margin-top:22px}
|
|
98
|
+
.sec-h{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:8px;flex-wrap:wrap}
|
|
99
|
+
.sec-h h2{font:600 13px/1 var(--cond);letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:0}
|
|
100
|
+
.sec-h .aux{font-size:12px;color:var(--faint)}
|
|
101
|
+
.sec-h .tools{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
|
|
102
|
+
.panel{background:var(--panel);border:1px solid var(--line);border-radius:6px}
|
|
103
|
+
.two{display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:16px}
|
|
104
|
+
@media (max-width:900px){.two{grid-template-columns:1fr}}
|
|
105
|
+
.search{display:flex;align-items:center;gap:6px}
|
|
106
|
+
.search input{border:1px solid var(--line);border-radius:5px;padding:5px 9px;background:var(--panel);width:220px;font-size:13px}
|
|
107
|
+
.search .n{font:12px var(--mono);color:var(--muted);min-width:60px}
|
|
108
|
+
select.btn{padding-right:8px}
|
|
109
|
+
|
|
110
|
+
/* timeline */
|
|
111
|
+
.tl-wrap{position:relative;overflow:hidden;border-radius:6px}
|
|
112
|
+
.tl-wrap svg{display:block;width:100%;cursor:grab;user-select:none;touch-action:none}
|
|
113
|
+
.tl-wrap svg.drag{cursor:grabbing}
|
|
114
|
+
.tl-legend{display:flex;gap:14px;flex-wrap:wrap;font-size:12px;color:var(--muted);padding:8px var(--pad);border-top:1px solid var(--line-2);align-items:center}
|
|
115
|
+
.tl-legend i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:5px;vertical-align:-1px}
|
|
116
|
+
.span{stroke:none;outline:none}
|
|
117
|
+
.span:hover{filter:brightness(.85)}
|
|
118
|
+
.span.sel{stroke:var(--accent);stroke-width:2}
|
|
119
|
+
.span.hl{stroke:var(--accent);stroke-width:1.5;stroke-dasharray:3 2}
|
|
120
|
+
.span.err{stroke:var(--err);stroke-width:1.5}
|
|
121
|
+
.span:focus-visible{stroke:var(--focus);stroke-width:3}
|
|
122
|
+
.lane-label{font:500 11px var(--cond);letter-spacing:.06em;text-transform:uppercase;fill:var(--muted)}
|
|
123
|
+
.lane-sub{font:11px var(--mono);fill:var(--faint)}
|
|
124
|
+
.tick{font:10px var(--mono);fill:var(--faint)}
|
|
125
|
+
.turn-lbl{font:10px var(--mono);fill:var(--muted)}
|
|
126
|
+
.cursor-lbl{font:11px var(--mono);fill:var(--ink)}
|
|
127
|
+
.minimap{display:block;width:100%;height:44px;border-top:1px solid var(--line-2);cursor:pointer}
|
|
128
|
+
.tooltip{position:fixed;z-index:20;pointer-events:none;background:var(--ink);color:var(--ground);padding:7px 9px;border-radius:4px;font-size:12px;max-width:360px;box-shadow:var(--shadow);display:none;line-height:1.35}
|
|
129
|
+
.tooltip b{font-weight:600}
|
|
130
|
+
.tooltip .m{font-family:var(--mono);opacity:.85}
|
|
131
|
+
|
|
132
|
+
/* burn chart */
|
|
133
|
+
.burn svg{display:block;width:100%}
|
|
134
|
+
.burn .bar{outline:none}
|
|
135
|
+
.burn .bar:hover{filter:brightness(.85)}
|
|
136
|
+
.burn .bar:focus-visible rect:last-child{stroke:var(--focus);stroke-width:2}
|
|
137
|
+
.axis{font:10px var(--mono);fill:var(--faint)}
|
|
138
|
+
|
|
139
|
+
/* findings */
|
|
140
|
+
.findings{padding:4px 0}
|
|
141
|
+
.finding{display:grid;grid-template-columns:8px 1fr auto;gap:0 12px;padding:10px var(--pad);border-top:1px solid var(--line-2);cursor:pointer}
|
|
142
|
+
.finding:first-child{border-top:none}
|
|
143
|
+
.finding:hover{background:var(--panel-2)}
|
|
144
|
+
.finding .bar{border-radius:2px;align-self:stretch}
|
|
145
|
+
.finding.error .bar{background:var(--err)}.finding.warn .bar{background:var(--warn)}.finding.info .bar{background:var(--info)}
|
|
146
|
+
.finding .t{all:unset;cursor:pointer;font-weight:500;color:var(--ink);display:block}
|
|
147
|
+
.finding .t:focus-visible{outline:2px solid var(--focus);outline-offset:2px;border-radius:2px}
|
|
148
|
+
.finding .d{color:var(--muted);font-size:12.5px;margin-top:2px}
|
|
149
|
+
.finding .side{display:flex;flex-direction:column;align-items:flex-end;gap:6px}
|
|
150
|
+
.finding .id{font:11px var(--mono);color:var(--faint)}
|
|
151
|
+
.finding.active{background:var(--accent-soft)}
|
|
152
|
+
.empty{padding:22px var(--pad);color:var(--muted)}
|
|
153
|
+
|
|
154
|
+
/* table */
|
|
155
|
+
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
156
|
+
th,td{padding:7px 10px;text-align:left;border-top:1px solid var(--line-2);white-space:nowrap}
|
|
157
|
+
th{font:500 11px var(--cond);letter-spacing:.08em;text-transform:uppercase;color:var(--faint);border-top:none;position:sticky;top:0;background:var(--panel);z-index:1}
|
|
158
|
+
th button{all:unset;cursor:pointer;font:inherit;color:inherit;letter-spacing:inherit;text-transform:inherit}
|
|
159
|
+
th button:focus-visible{outline:2px solid var(--focus);outline-offset:2px}
|
|
160
|
+
th[aria-sort]:not([aria-sort="none"]) button{color:var(--ink)}
|
|
161
|
+
td.n,th.n{text-align:right;font-family:var(--mono);font-variant-numeric:tabular-nums}
|
|
162
|
+
td:first-child,th:first-child{position:sticky;left:0;background:var(--panel);z-index:2}
|
|
163
|
+
th:first-child{z-index:3}
|
|
164
|
+
tr.row{cursor:pointer}
|
|
165
|
+
tr.row:hover td{background:var(--panel-2)}
|
|
166
|
+
tr.row[aria-pressed="true"] td{background:var(--accent-soft)}
|
|
167
|
+
tr.row:focus-visible{outline:2px solid var(--focus);outline-offset:-2px}
|
|
168
|
+
.cat{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:7px;vertical-align:0}
|
|
169
|
+
.tbl-wrap{overflow:auto;max-height:420px}
|
|
170
|
+
.errpill{color:var(--err);font-weight:500}
|
|
171
|
+
|
|
172
|
+
/* turns */
|
|
173
|
+
.turn{border-top:1px solid var(--line-2)}
|
|
174
|
+
.turn:first-child{border-top:none}
|
|
175
|
+
.turn summary{display:grid;grid-template-columns:auto 1fr auto;gap:12px;align-items:baseline;padding:10px var(--pad);cursor:pointer;list-style:none}
|
|
176
|
+
.turn summary::-webkit-details-marker{display:none}
|
|
177
|
+
.turn summary:hover{background:var(--panel-2)}
|
|
178
|
+
.turn .no{font:12px var(--mono);color:var(--faint)}
|
|
179
|
+
.turn .p{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
|
|
180
|
+
.turn .p.meta{color:var(--faint);font-style:italic}
|
|
181
|
+
.turn .stats{font:12px var(--mono);color:var(--muted);white-space:nowrap}
|
|
182
|
+
.turn .body{padding:4px var(--pad) 12px;border-top:1px solid var(--line-2);background:var(--panel-2)}
|
|
183
|
+
.step{display:grid;grid-template-columns:60px 16px 1fr auto;gap:10px;align-items:baseline;padding:4px 4px;font-size:13px;cursor:pointer;border-radius:3px}
|
|
184
|
+
.step:hover{background:var(--accent-soft)}
|
|
185
|
+
.step[hidden]{display:none}
|
|
186
|
+
.step .t{font:11px var(--mono);color:var(--faint)}
|
|
187
|
+
.step .dot{width:8px;height:8px;border-radius:2px;align-self:center}
|
|
188
|
+
.step .what{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
|
|
189
|
+
.step .what .n{font-weight:500}
|
|
190
|
+
.step .what .a{color:var(--muted);font-family:var(--mono);font-size:12px}
|
|
191
|
+
.step .r{font:11px var(--mono);color:var(--muted);white-space:nowrap}
|
|
192
|
+
.prompt{padding:8px 0 6px;color:var(--ink-2);white-space:pre-wrap;word-break:break-word;max-height:160px;overflow:auto;font-size:13px}
|
|
193
|
+
mark{background:var(--accent-soft);color:inherit;border-radius:2px}
|
|
194
|
+
|
|
195
|
+
/* drawer */
|
|
196
|
+
.drawer{position:fixed;top:0;right:0;bottom:0;width:min(560px,92vw);background:var(--panel);border-left:1px solid var(--line);box-shadow:var(--shadow);z-index:30;display:flex;flex-direction:column;transform:translateX(102%);transition:transform .18s ease}
|
|
197
|
+
.drawer.open{transform:none}
|
|
198
|
+
@media (prefers-reduced-motion:reduce){.drawer{transition:none}}
|
|
199
|
+
.drawer .dh{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--line)}
|
|
200
|
+
.drawer .dh h3{margin:0;font:600 16px var(--sans);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
201
|
+
.drawer .db{overflow:auto;padding:14px 16px;flex:1}
|
|
202
|
+
.kv{display:grid;grid-template-columns:120px 1fr;gap:4px 12px;font-size:13px;margin:0 0 14px}
|
|
203
|
+
.kv dt{color:var(--faint)}
|
|
204
|
+
.kv dd{margin:0;font-family:var(--mono);font-variant-numeric:tabular-nums;word-break:break-all}
|
|
205
|
+
.drawer h4{font:600 11px var(--cond);letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:16px 0 6px}
|
|
206
|
+
pre{margin:0;background:var(--panel-2);border:1px solid var(--line-2);border-radius:4px;padding:10px;font:12px/1.45 var(--mono);white-space:pre-wrap;word-break:break-word;max-height:360px;overflow:auto}
|
|
207
|
+
.x{border:none;background:none;font-size:20px;line-height:1;color:var(--muted);padding:2px 6px;border-radius:4px}
|
|
208
|
+
.x:hover{color:var(--ink)}
|
|
209
|
+
|
|
210
|
+
/* dialogs */
|
|
211
|
+
dialog{border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--ink);padding:0;max-width:760px;width:92vw;box-shadow:var(--shadow)}
|
|
212
|
+
dialog::backdrop{background:rgba(10,14,20,.45)}
|
|
213
|
+
dialog .dh{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;align-items:center}
|
|
214
|
+
dialog .dh h3{margin:0;font:600 16px var(--sans);flex:1}
|
|
215
|
+
dialog .db{padding:14px 18px;max-height:70vh;overflow:auto}
|
|
216
|
+
dialog .df{padding:12px 18px;border-top:1px solid var(--line);display:flex;justify-content:flex-end;gap:8px}
|
|
217
|
+
.rates td input{width:80px;padding:3px 6px;border:1px solid var(--line);border-radius:4px;background:var(--panel-2);text-align:right;font-family:var(--mono)}
|
|
218
|
+
.note{color:var(--muted);font-size:12.5px}
|
|
219
|
+
.problems{margin:10px 0 0;padding:8px 12px;border:1px solid var(--warn);background:var(--warn-soft);border-radius:5px;font-size:12.5px}
|
|
220
|
+
kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;border-radius:3px;padding:0 4px;background:var(--panel-2)}
|
|
221
|
+
.keys{display:grid;grid-template-columns:auto 1fr;gap:6px 16px;font-size:13px;margin:8px 0 0}
|
|
222
|
+
.sessions tr.row td{white-space:normal}
|
|
223
|
+
.hidden{display:none!important}
|
|
224
|
+
.redacted{color:var(--faint);font-style:italic}
|
|
225
|
+
@media print{.mast,.tooltip,.drawer,.sec-h .tools,.tl-legend button,.banner{display:none!important}.panel{border-color:#999}body{background:#fff;color:#000}}
|
|
226
|
+
@media (max-width:600px){.lane-sub{display:none}.brand .tag{display:none}.turn summary{grid-template-columns:auto 1fr}.turn .stats{grid-column:1 / -1;white-space:normal}.step{grid-template-columns:48px 12px 1fr}.step .r{grid-column:3;justify-self:start}}
|
|
227
|
+
</style>
|
|
228
|
+
|
|
229
|
+
<div class="app">
|
|
230
|
+
<header class="mast">
|
|
231
|
+
<div class="brand"><h1>Glass<span>box</span></h1><span class="tag">flight recorder for Claude Code sessions</span></div>
|
|
232
|
+
<label class="drop" id="drop" tabindex="0" aria-label="Drop a session .jsonl file or folder here, or press Enter to choose files">
|
|
233
|
+
<input type="file" id="file" multiple accept=".jsonl,.json,.txt" class="sr-only">
|
|
234
|
+
<span class="hint"><b>Drop a session .jsonl</b> (or the whole session folder) — parsed in your browser, never uploaded.</span>
|
|
235
|
+
<span class="btn" aria-hidden="true">Choose files</span>
|
|
236
|
+
</label>
|
|
237
|
+
<div class="controls">
|
|
238
|
+
<button class="btn" id="open-folder" hidden title="Browse ~/.claude/projects and pick a session">Open folder…</button>
|
|
239
|
+
<button class="btn" id="recent" hidden title="Sessions from the folder you opened before">Recent</button>
|
|
240
|
+
<button class="btn" id="demo">Load demo</button>
|
|
241
|
+
<button class="btn" id="share" aria-pressed="false" title="Blank every prompt, input and result so the trace can be shared">Share mode</button>
|
|
242
|
+
<button class="btn" id="export" disabled title="Download a structure-only copy of the transcript">Export redacted</button>
|
|
243
|
+
<button class="btn" id="rates" title="Edit the per-model rate card">Rates</button>
|
|
244
|
+
<button class="btn" id="theme" title="Theme: system / light / dark" aria-label="Theme: system">◐</button>
|
|
245
|
+
<button class="btn" id="density" title="Text density" aria-label="Density: normal">Aa</button>
|
|
246
|
+
<button class="btn" id="help" aria-label="Help and keyboard shortcuts">?</button>
|
|
247
|
+
</div>
|
|
248
|
+
</header>
|
|
249
|
+
<div id="status" class="sr-only" aria-live="polite"></div>
|
|
250
|
+
|
|
251
|
+
<main id="main" hidden>
|
|
252
|
+
<div class="banner" id="banner" hidden><p><b>This is the demo session</b> — the trace of the session that built Glassbox. Drop your own <code>.jsonl</code> above to replace it. Find yours with <code>ls -t ~/.claude/projects/*/*.jsonl | head</code> (Windows: <code>%USERPROFILE%\.claude\projects\</code>), or use <b>Open folder</b> in Chrome or Edge.</p><button class="x" id="banner-x" aria-label="Dismiss">×</button></div>
|
|
253
|
+
<div class="session" id="session"></div>
|
|
254
|
+
<div id="problems" class="problems" hidden role="alert"></div>
|
|
255
|
+
<dl class="strip" id="strip"></dl>
|
|
256
|
+
|
|
257
|
+
<section class="sec" aria-labelledby="h-tl">
|
|
258
|
+
<div class="sec-h"><h2 id="h-tl">Timeline</h2>
|
|
259
|
+
<div class="tools">
|
|
260
|
+
<div class="search"><label class="sr-only" for="q">Search prompts, tool inputs and results</label><input id="q" type="search" placeholder="Search…"><span class="n" id="q-n" aria-live="polite"></span></div>
|
|
261
|
+
<label class="sr-only" for="fit">Fit to turn</label><select id="fit" class="btn"></select>
|
|
262
|
+
<button class="btn" id="z-out" aria-label="Zoom out">−</button><button class="btn" id="z-in" aria-label="Zoom in">+</button><button class="btn" id="z-reset" aria-label="Reset zoom">⟲</button>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
<div class="panel tl-wrap" id="tl"></div>
|
|
266
|
+
</section>
|
|
267
|
+
|
|
268
|
+
<div class="two sec">
|
|
269
|
+
<section aria-labelledby="h-burn">
|
|
270
|
+
<div class="sec-h"><h2 id="h-burn">Context & cost per request</h2><span class="aux" id="burn-aux"></span></div>
|
|
271
|
+
<div class="panel burn" id="burn"></div>
|
|
272
|
+
</section>
|
|
273
|
+
<section aria-labelledby="h-find">
|
|
274
|
+
<div class="sec-h"><h2 id="h-find">Findings</h2><div class="tools"><span class="aux" id="find-aux"></span><button class="btn sm" id="copy-all">Copy all</button><button class="btn sm" id="export-md">Export report</button></div></div>
|
|
275
|
+
<div class="panel findings" id="findings"></div>
|
|
276
|
+
</section>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<section class="sec" aria-labelledby="h-tools">
|
|
280
|
+
<div class="sec-h"><h2 id="h-tools">Tools</h2><span class="aux">select a row to filter the timeline</span></div>
|
|
281
|
+
<div class="panel tbl-wrap" tabindex="0" aria-label="Tools table, scrollable"><table id="tools"></table></div>
|
|
282
|
+
</section>
|
|
283
|
+
|
|
284
|
+
<section class="sec" aria-labelledby="h-turns">
|
|
285
|
+
<div class="sec-h"><h2 id="h-turns">Turns</h2><span class="aux" id="turns-aux"></span></div>
|
|
286
|
+
<div class="panel" id="turns"></div>
|
|
287
|
+
</section>
|
|
288
|
+
</main>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
<div class="tooltip" id="tip" role="tooltip"></div>
|
|
292
|
+
|
|
293
|
+
<aside class="drawer" id="drawer" role="dialog" aria-labelledby="d-title" hidden>
|
|
294
|
+
<div class="dh"><h3 id="d-title" tabindex="-1">Detail</h3><button class="x" id="d-close" aria-label="Close detail">×</button></div>
|
|
295
|
+
<div class="db" id="d-body"></div>
|
|
296
|
+
</aside>
|
|
297
|
+
|
|
298
|
+
<dialog id="rates-dlg" aria-labelledby="rates-h">
|
|
299
|
+
<div class="dh"><h3 id="rates-h">Rate card · USD per million tokens</h3><button class="x" id="rates-close" aria-label="Close">×</button></div>
|
|
300
|
+
<div class="db">
|
|
301
|
+
<p class="note" style="margin:0 0 10px">Defaults fetched from the Claude pricing page on 2026-09-05. Prices change — edit any cell; changes are kept in this browser. Prefix matching: a row named <code class="mono">claude-sonnet-4</code> covers every dated Sonnet 4.x id.</p>
|
|
302
|
+
<div class="tbl-wrap"><table class="rates" id="rates-tbl"></table></div>
|
|
303
|
+
<p class="note" style="margin:10px 0 0"><label for="rate-new">Add a model:</label> <input id="rate-new" placeholder="model id prefix" style="padding:3px 6px;border:1px solid var(--line);border-radius:4px;background:var(--panel-2);width:220px"> <button class="btn" id="rate-add">Add row</button></p>
|
|
304
|
+
</div>
|
|
305
|
+
<div class="df"><button class="btn" id="rates-reset">Reset to defaults</button><button class="btn primary" id="rates-save">Apply</button></div>
|
|
306
|
+
</dialog>
|
|
307
|
+
|
|
308
|
+
<dialog id="sessions-dlg" aria-labelledby="sessions-h">
|
|
309
|
+
<div class="dh"><h3 id="sessions-h">Sessions</h3><button class="x" id="sessions-close" aria-label="Close">×</button></div>
|
|
310
|
+
<div class="db" tabindex="0"><p class="note" id="sessions-note" style="margin:0 0 10px"></p><div class="tbl-wrap" style="max-height:60vh"><table class="sessions" id="sessions-tbl"></table></div></div>
|
|
311
|
+
</dialog>
|
|
312
|
+
|
|
313
|
+
<dialog id="copy-dlg" aria-labelledby="copy-h">
|
|
314
|
+
<div class="dh"><h3 id="copy-h">Copy</h3><button class="x" id="copy-close" aria-label="Close">×</button></div>
|
|
315
|
+
<div class="db"><p class="note" style="margin:0 0 8px">The clipboard isn't available here — the text is selected below, press <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>C</kbd>.</p><label class="sr-only" for="copy-text">Text to copy</label><textarea id="copy-text" style="width:100%;height:240px;font:12px/1.45 var(--mono);background:var(--panel-2);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:8px"></textarea></div>
|
|
316
|
+
</dialog>
|
|
317
|
+
|
|
318
|
+
<dialog id="help-dlg" aria-labelledby="help-h">
|
|
319
|
+
<div class="dh"><h3 id="help-h">How to read Glassbox</h3><button class="x" id="help-close" aria-label="Close">×</button></div>
|
|
320
|
+
<div class="db" style="font-size:13.5px;line-height:1.55" tabindex="0">
|
|
321
|
+
<p><b>Timeline.</b> One lane per agent; the main session first, subagents below it. Grey blocks are model time (from the moment the model had its input to its last streamed block). Coloured blocks are tool calls, by category. Hatched grey between turns is human idle time. Red edge = the tool returned an error. The strip underneath is a minimap of the whole session; click it to jump.</p>
|
|
322
|
+
<p><b>Context & cost.</b> One bar per API request: the size of the prompt the model saw, split into cached reads (light), cache writes (mid) and uncached input (dark). The line is cumulative estimated cost.</p>
|
|
323
|
+
<p><b>Findings.</b> Mechanical rules, no AI: identical calls repeated in a turn, tool error rates, results over 20k characters, context over 120k tokens, mid-session cache invalidation, responses that took over a minute and produced little, truncated outputs, hook and API errors, compaction events. Select one to highlight its evidence on the timeline; copy one or all as Markdown to paste into an issue or back into the agent.</p>
|
|
324
|
+
<p><b>Tokens.</b> Assistant messages are stored one record per content block with the usage repeated on each; Glassbox counts usage once per API request. Thinking tokens are part of output tokens, shown as a share, never double counted. Cost is an estimate from the rate card unless the file carries a reported <code class="mono">total_cost_usd</code>.</p>
|
|
325
|
+
<p><b>Share mode.</b> Blanks every prompt, tool input, result and assistant text in the view; <b>Export redacted</b> downloads a structure-only <code class="mono">.jsonl</code> that keeps timestamps, usage and tool names — safe to post publicly.</p>
|
|
326
|
+
<p><b>Keyboard.</b></p>
|
|
327
|
+
<div class="keys">
|
|
328
|
+
<span><kbd>Tab</kbd></span><span>into the timeline, then <kbd>←</kbd> <kbd>→</kbd> previous / next call, <kbd>↑</kbd> <kbd>↓</kbd> change lane, <kbd>Enter</kbd> open detail</span>
|
|
329
|
+
<span><kbd>+</kbd> <kbd>−</kbd> <kbd>0</kbd></span><span>zoom in, out, reset · <kbd>Shift</kbd>+<kbd>←</kbd><kbd>→</kbd> pan</span>
|
|
330
|
+
<span><kbd>Esc</kbd></span><span>close the detail panel</span>
|
|
331
|
+
<span><kbd>/</kbd></span><span>jump to search</span>
|
|
332
|
+
<span><kbd>?</kbd></span><span>this help</span>
|
|
333
|
+
</div>
|
|
334
|
+
<p class="note" style="margin-top:12px">Glassbox is a single HTML file that makes no network requests apart from the optional font stylesheet. Source, tests and the CLI (<code class="mono">npx glassbox-trace</code>) are in the README.</p>
|
|
335
|
+
</div>
|
|
336
|
+
</dialog>
|
|
337
|
+
|
|
338
|
+
<script>/*__TRACE_CORE__*/</script>
|
|
339
|
+
<script>
|
|
340
|
+
(function () {
|
|
341
|
+
'use strict';
|
|
342
|
+
const C = window.TraceCore;
|
|
343
|
+
const $ = (s, el) => (el || document).querySelector(s);
|
|
344
|
+
const $$ = (s, el) => Array.from((el || document).querySelectorAll(s));
|
|
345
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
346
|
+
const CAT_COLOR = { read: 'var(--c-read)', write: 'var(--c-write)', exec: 'var(--c-exec)', agent: 'var(--c-agent)', user: 'var(--c-user)', mcp: 'var(--c-mcp)', other: 'var(--c-other)' };
|
|
347
|
+
const CAT_LABEL = { read: 'var(--l-read)', write: 'var(--l-write)', exec: 'var(--l-exec)', agent: 'var(--l-agent)', user: 'var(--l-user)', mcp: 'var(--l-mcp)', other: 'var(--l-other)' };
|
|
348
|
+
const fmtDur = C.fmtDur, fmtInt = C.fmtInt;
|
|
349
|
+
const fmtTok = (n) => n == null ? '—' : n >= 1e6 ? (n / 1e6).toFixed(2) + 'M' : n >= 10000 ? Math.round(n / 1000) + 'k' : n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n);
|
|
350
|
+
const fmtUSD = (v) => v == null ? '—' : v < 0.01 ? '$' + v.toFixed(4) : v < 1 ? '$' + v.toFixed(3) : '$' + v.toFixed(2);
|
|
351
|
+
const pct = (x) => x == null ? '—' : Math.round(x * 100) + '%';
|
|
352
|
+
const clock = (ms) => ms == null ? '—' : new Date(ms).toISOString().slice(11, 19);
|
|
353
|
+
const store = { get(k) { try { return localStorage.getItem('glassbox.' + k); } catch (e) { return null; } }, set(k, v) { try { localStorage.setItem('glassbox.' + k, v); } catch (e) { } } };
|
|
354
|
+
const say = (msg) => { const s = $('#status'); s.textContent = ''; setTimeout(() => { s.textContent = msg; }, 30); };
|
|
355
|
+
|
|
356
|
+
// ------------------------------------------------------------ state
|
|
357
|
+
const S = { files: [], records: [], trace: null, findings: [], cost: null, rates: loadRates(), share: false, sel: null, hl: new Set(), toolFilter: null, view: null, query: '', matches: new Set(), focusId: null, lastFocus: null, isDemo: false };
|
|
358
|
+
function loadRates() { try { const r = JSON.parse(store.get('rates') || 'null'); if (r && typeof r === 'object') return r; } catch (e) { } return JSON.parse(JSON.stringify(C.RATES)); }
|
|
359
|
+
const R = (s) => S.share ? '«' + String(s == null ? '' : s).length + ' chars»' : s;
|
|
360
|
+
|
|
361
|
+
// ------------------------------------------------------------ theme + density
|
|
362
|
+
const THEMES = ['', 'light', 'dark'];
|
|
363
|
+
function applyTheme(t) { if (t) document.documentElement.setAttribute('data-theme', t); else document.documentElement.removeAttribute('data-theme'); const b = $('#theme'); b.textContent = t === 'light' ? '☀' : t === 'dark' ? '☾' : '◐'; b.setAttribute('aria-label', 'Theme: ' + (t || 'system')); b.title = 'Theme: ' + (t || 'system') + ' — click to change'; }
|
|
364
|
+
applyTheme(store.get('theme') || '');
|
|
365
|
+
$('#theme').onclick = () => { const cur = document.documentElement.getAttribute('data-theme') || ''; const next = THEMES[(THEMES.indexOf(cur) + 1) % THEMES.length]; store.set('theme', next); applyTheme(next); };
|
|
366
|
+
const DENS = ['', 'compact', 'comfortable'];
|
|
367
|
+
function applyDensity(d) { if (d) document.documentElement.setAttribute('data-density', d); else document.documentElement.removeAttribute('data-density'); $('#density').setAttribute('aria-label', 'Density: ' + (d || 'normal')); $('#density').title = 'Density: ' + (d || 'normal') + ' — click to change'; if (S.trace) { renderTimeline(); renderBurn(); } }
|
|
368
|
+
applyDensity(store.get('density') || '');
|
|
369
|
+
$('#density').onclick = () => { const cur = document.documentElement.getAttribute('data-density') || ''; const next = DENS[(DENS.indexOf(cur) + 1) % DENS.length]; store.set('density', next); applyDensity(next); };
|
|
370
|
+
|
|
371
|
+
// ------------------------------------------------------------ loading
|
|
372
|
+
async function readEntry(entry, out, prefix) {
|
|
373
|
+
if (entry.isFile) { const f = await new Promise((res, rej) => entry.file(res, rej)); if (/\.(jsonl|json|txt)$/i.test(f.name)) out.push({ name: prefix + f.name, file: f }); }
|
|
374
|
+
else if (entry.isDirectory) { const reader = entry.createReader(); let batch; do { batch = await new Promise((res, rej) => reader.readEntries(res, rej)); for (const e of batch) await readEntry(e, out, prefix + entry.name + '/'); } while (batch.length); }
|
|
375
|
+
}
|
|
376
|
+
async function handleDataTransfer(dt) {
|
|
377
|
+
const out = []; const items = dt.items ? Array.from(dt.items) : []; let usedEntries = false;
|
|
378
|
+
for (const it of items) { const e = it.webkitGetAsEntry && it.webkitGetAsEntry(); if (e) { usedEntries = true; await readEntry(e, out, ''); } }
|
|
379
|
+
if (!usedEntries) for (const f of Array.from(dt.files || [])) out.push({ name: f.name, file: f });
|
|
380
|
+
await loadFiles(out);
|
|
381
|
+
}
|
|
382
|
+
async function loadFiles(list) { const files = []; for (const { name, file } of list) files.push({ name: (file.webkitRelativePath || name), text: await file.text() }); if (files.length) load(files, false); else say('No .jsonl files found in what was dropped.'); }
|
|
383
|
+
function load(files, isDemo) {
|
|
384
|
+
S.files = files; S.isDemo = !!isDemo;
|
|
385
|
+
S.records = files.filter((f) => !/\.meta\.json$/.test(f.name)).flatMap((f) => C.parseLines(f.text, f.name).records);
|
|
386
|
+
S.trace = C.parseTrace(files);
|
|
387
|
+
S.findings = C.diagnose(S.trace);
|
|
388
|
+
S.cost = C.estimateCost(S.trace, S.rates);
|
|
389
|
+
S.sel = null; S.hl = new Set(); S.toolFilter = null; S.view = null; S.query = ''; S.matches = new Set(); S.focusId = null; $('#q').value = ''; $('#q-n').textContent = '';
|
|
390
|
+
$('#main').hidden = false; $('#banner').hidden = !isDemo;
|
|
391
|
+
renderAll();
|
|
392
|
+
const T = S.trace.totals;
|
|
393
|
+
if (!S.trace.requests.length && !S.trace.toolCalls.length) { const p = $('#problems'); p.hidden = false; p.textContent = 'No API requests or tool calls were found — this does not look like a Claude Code / Agent SDK transcript. Expected a .jsonl with "type":"assistant" and "type":"user" records.'; }
|
|
394
|
+
say(`Loaded ${isDemo ? 'the demo session' : files.length + ' file' + (files.length > 1 ? 's' : '')}: ${T.turns} turns, ${T.toolCalls} tool calls, ${S.findings.length} findings${S.trace.problems.length ? ', ' + S.trace.problems.length + ' unreadable lines skipped' : ''}.`);
|
|
395
|
+
if (!isDemo) window.scrollTo({ top: 0 });
|
|
396
|
+
applyHash();
|
|
397
|
+
}
|
|
398
|
+
function recost() { if (!S.trace) return; S.cost = C.estimateCost(S.trace, S.rates); renderStrip(); renderBurn(); renderTurns(); }
|
|
399
|
+
|
|
400
|
+
// ------------------------------------------------------------ render: all
|
|
401
|
+
function renderAll() { closeDrawer(true); renderSession(); renderStrip(); renderFit(); renderTimeline(); renderBurn(); renderFindings(); renderTools(); renderTurns(); }
|
|
402
|
+
|
|
403
|
+
function renderSession() {
|
|
404
|
+
const m = S.trace.meta, t = S.trace;
|
|
405
|
+
const title = m.title || (t.turns.find((x) => x.promptKind === 'human' && x.agent === 'main' && x.promptText && !/^[«<]/.test(x.promptText.trim())) || {}).promptText || 'Session';
|
|
406
|
+
const parts = [
|
|
407
|
+
`<span class="title">${esc(R(String(title).slice(0, 110)))}</span>`,
|
|
408
|
+
m.models.length ? `<span><span class="k">model</span> <span class="mono">${esc(m.models.join(', '))}</span></span>` : '',
|
|
409
|
+
m.version ? `<span><span class="k">claude code</span> <span class="mono">${esc(m.version)}</span></span>` : '',
|
|
410
|
+
m.format !== 'claude-code' ? `<span><span class="k">format</span> <span class="mono">${esc(m.format)}</span></span>` : '',
|
|
411
|
+
m.cwd ? `<span><span class="k">cwd</span> <span class="mono">${esc(R(m.cwd))}</span></span>` : '',
|
|
412
|
+
m.start ? `<span><span class="k">started</span> <span class="mono">${esc(new Date(m.start).toLocaleString())}</span></span>` : '',
|
|
413
|
+
`<span><span class="k">files</span> <span class="mono">${m.files.length}</span></span>`,
|
|
414
|
+
m.sessionId ? `<span><span class="k">session</span> <span class="mono">${esc(m.sessionId.slice(0, 8))}…</span></span>` : '',
|
|
415
|
+
].filter(Boolean);
|
|
416
|
+
$('#session').innerHTML = parts.join('');
|
|
417
|
+
const p = $('#problems');
|
|
418
|
+
if (t.problems.length) { p.hidden = false; p.textContent = `${t.problems.length} line${t.problems.length > 1 ? 's' : ''} could not be parsed and were skipped (e.g. ${t.problems[0].file}:${t.problems[0].line} — ${t.problems[0].reason}).`; } else p.hidden = true;
|
|
419
|
+
$('#share').setAttribute('aria-pressed', String(S.share)); $('#export').disabled = !S.share;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function renderStrip() {
|
|
423
|
+
const t = S.trace, T = t.totals, u = T.usage, cost = S.cost;
|
|
424
|
+
const ctx = u.input + u.cacheRead + u.cacheWrite;
|
|
425
|
+
const errs = S.findings.filter((f) => f.severity === 'error').length, warns = S.findings.filter((f) => f.severity === 'warn').length, infos = S.findings.filter((f) => f.severity === 'info').length;
|
|
426
|
+
const costV = cost.reported != null ? fmtUSD(cost.reported) : cost.total != null ? fmtUSD(cost.total) : '—';
|
|
427
|
+
const costS = cost.reported != null ? 'reported by the run' : cost.total != null ? (cost.complete ? 'estimated from rate card' : 'partial — unknown model') : (cost.unknownModels.length ? 'unknown model: set a rate' : 'no usage data');
|
|
428
|
+
const tiles = [
|
|
429
|
+
['Wall time', fmtDur(T.wallMs), T.humanIdleMs ? `${fmtDur(T.activeMs)} active · ${fmtDur(T.humanIdleMs)} waiting on you` : (t.meta.hasTimestamps ? 'no human idle' : 'no timestamps in file')],
|
|
430
|
+
['Turns', fmtInt(T.turns), `${fmtInt(T.requests)} API requests`],
|
|
431
|
+
['Tool calls', fmtInt(T.toolCalls), `${T.toolErrors ? `<span class="errpill">${T.toolErrors} failed</span>` : 'none failed'}${T.orphans ? ` · ${T.orphans} unanswered` : ''} · ${fmtDur(T.toolTimeMs)} in tools`],
|
|
432
|
+
['Context served', fmtTok(ctx), `${pct(T.cacheHitRatio)} from cache · peak ${fmtTok(Math.max(0, ...t.requests.map((r) => r.contextTokens)))}`],
|
|
433
|
+
['Output', fmtTok(u.output), `${u.thinking ? pct(u.thinking / u.output) + ' thinking · ' : ''}${fmtDur(T.modelTimeMs)} generating`],
|
|
434
|
+
['Est. cost', costV, costS],
|
|
435
|
+
['Findings', `<span class="sev error">${errs}<span class="sr-only"> errors</span></span><span class="sev warn">${warns}<span class="sr-only"> warnings</span></span><span class="sev info">${infos}<span class="sr-only"> info</span></span>`, S.findings.length ? esc(S.findings[0].title) : 'nothing flagged', 'f'],
|
|
436
|
+
];
|
|
437
|
+
if (t.agents.length > 1) tiles.splice(2, 0, ['Subagents', fmtInt(t.agents.length - 1), `${fmtTok(t.agents.filter((a) => a.id !== 'main').reduce((s, a) => s + a.usage.output, 0))} output tokens`]);
|
|
438
|
+
$('#strip').innerHTML = tiles.map(([l, v, s, cls]) => `<div class="stat ${cls || ''}"><dt>${l}</dt><dd class="v">${v}</dd><dd class="s">${s}</dd></div>`).join('');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function renderFit() {
|
|
442
|
+
const sel = $('#fit'); const turns = S.trace.turns.filter((x) => x.agent === 'main' && x.promptKind === 'human');
|
|
443
|
+
sel.innerHTML = '<option value="">Fit: whole session</option>' + turns.map((tn, i) => `<option value="${tn.index}">Fit: turn ${i + 1}</option>`).join('');
|
|
444
|
+
sel.onchange = () => { const v = sel.value; if (v === '') { S.view = null; } else { const tn = S.trace.turns[+v]; if (tn && tn.start != null && tn.end != null) { const a = tn.start - TL.base, b = Math.max(tn.end, tn.start + 1000) - TL.base; const pad = (b - a) * .04; S.view = { t0: Math.max(0, a - pad), t1: Math.min(TL.total, b + pad) }; } } renderTimeline(); };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ------------------------------------------------------------ timeline
|
|
448
|
+
const TL = { W: 0, left: 130, top: 26, laneGap: 10, lanes: [], total: 1, base: 0, drag: null, pinch: null, pointers: new Map(), mmDrag: false };
|
|
449
|
+
const rowH = () => parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--rowh')) || 18;
|
|
450
|
+
function packRows(spans) { const rows = []; for (const s of spans.slice().sort((a, b) => a.s - b.s)) { let placed = false; for (const r of rows) { if (r.end <= s.s) { r.items.push(s); r.end = s.e; placed = true; break; } } if (!placed) rows.push({ items: [s], end: s.e }); } return rows.map((r) => r.items); }
|
|
451
|
+
function buildLayout() {
|
|
452
|
+
const t = S.trace; const m = t.meta; const base = m.hasTimestamps ? m.start : 0;
|
|
453
|
+
let seq = 0; const synth = (o) => { if (o.start == null) { o._s = seq++; o._e = seq; } };
|
|
454
|
+
if (!m.hasTimestamps) { for (const r of t.requests) synth(r); for (const c of t.toolCalls) synth(c); }
|
|
455
|
+
const end = m.hasTimestamps ? m.end : seq;
|
|
456
|
+
const lanes = [];
|
|
457
|
+
for (const ag of t.agents) {
|
|
458
|
+
const reqs = t.requests.filter((r) => r.agent === ag.id);
|
|
459
|
+
const calls = t.toolCalls.filter((c) => c.agent === ag.id && !c.unmatched);
|
|
460
|
+
const model = reqs.map((r) => ({ kind: 'req', ref: r, s: (m.hasTimestamps ? (r.responseFrom != null ? r.responseFrom : r.start) : r._s) - base, e: (m.hasTimestamps ? r.end : r._e) - base }));
|
|
461
|
+
const tools = calls.map((c) => ({ kind: 'tool', ref: c, s: (m.hasTimestamps ? c.start : c._s) - base, e: (m.hasTimestamps ? (c.end != null ? c.end : m.end) : c._e) - base }));
|
|
462
|
+
const idle = ag.id === 'main' ? t.turns.filter((x) => x.agent === 'main' && x.idleBeforeMs > 0).map((x) => ({ kind: 'idle', ref: x, s: x.start - x.idleBeforeMs - base, e: x.start - base })) : [];
|
|
463
|
+
lanes.push({ agent: ag, rows: [model, ...packRows(tools)], idle });
|
|
464
|
+
}
|
|
465
|
+
TL.lanes = lanes; TL.total = Math.max(1, end - base); TL.base = base;
|
|
466
|
+
if (!S.view) S.view = { t0: 0, t1: TL.total };
|
|
467
|
+
}
|
|
468
|
+
const spanLabel = (sp) => {
|
|
469
|
+
if (sp.kind === 'tool') { const c = sp.ref; return esc(`${c.name}, ${c.status}${c.durationMs != null ? ', ' + fmtDur(c.durationMs) : ''}${c.isError ? ', error' : ''}, ${R(inputSummary(c)).slice(0, 80)}`); }
|
|
470
|
+
if (sp.kind === 'req') { const r = sp.ref; return `Request ${S.trace.requests.indexOf(r) + 1}, ${fmtDur(r.responseMs)}, ${fmtTok(r.usage.output)} output tokens, context ${fmtTok(r.contextTokens)}`; }
|
|
471
|
+
return `Waiting on you, ${fmtDur(sp.ref.idleBeforeMs)}`;
|
|
472
|
+
};
|
|
473
|
+
function renderTimeline() {
|
|
474
|
+
buildLayout();
|
|
475
|
+
const box = $('#tl'); const width = box.clientWidth || 1000; TL.W = width; const RH = rowH();
|
|
476
|
+
TL.left = width < 600 ? 70 : 130;
|
|
477
|
+
let y = TL.top; const laneY = [];
|
|
478
|
+
for (const ln of TL.lanes) { laneY.push(y); y += ln.rows.length * RH + TL.laneGap; }
|
|
479
|
+
const H = y + 8; const { t0, t1 } = S.view; const span = t1 - t0;
|
|
480
|
+
const X = (tm) => TL.left + (tm - t0) / span * (width - TL.left - 10);
|
|
481
|
+
const svg = [];
|
|
482
|
+
const first = S.focusId || (TL.lanes[0] && TL.lanes[0].rows[1] && TL.lanes[0].rows[1][0] ? TL.lanes[0].rows[1][0].ref.id : (TL.lanes[0] && TL.lanes[0].rows[0][0] ? TL.lanes[0].rows[0][0].ref.id : null));
|
|
483
|
+
svg.push(`<svg viewBox="0 0 ${width} ${H}" width="${width}" height="${H}" id="tl-svg" role="group" aria-label="Session timeline: ${TL.lanes.length} lane${TL.lanes.length > 1 ? 's' : ''}, ${S.trace.toolCalls.length} tool calls over ${fmtDur(TL.total)}. Use arrow keys to move between calls.">`);
|
|
484
|
+
svg.push(`<defs><pattern id="hatch" patternUnits="userSpaceOnUse" width="6" height="6" patternTransform="rotate(45)"><rect width="6" height="6" fill="var(--c-idle)"/><line x1="0" y1="0" x2="0" y2="6" stroke="var(--faint)" stroke-width="1" opacity=".8"/></pattern><clipPath id="clip"><rect x="${TL.left}" y="0" width="${width - TL.left}" height="${H}"/></clipPath></defs>`);
|
|
485
|
+
svg.push(`<rect x="0" y="0" width="${width}" height="${H}" fill="var(--panel)"/>`);
|
|
486
|
+
const ticks = niceTicks(t0, t1, Math.max(3, Math.floor((width - TL.left) / 110)), S.trace.meta.hasTimestamps);
|
|
487
|
+
for (const tk of ticks) { const x = X(tk); svg.push(`<line x1="${x}" y1="${TL.top - 4}" x2="${x}" y2="${H}" stroke="var(--line-2)" pointer-events="none"/><text class="tick" pointer-events="none" x="${x + 3}" y="${TL.top - 8}">${S.trace.meta.hasTimestamps ? '+' + fmtTick(tk) : '#' + tk}</text>`); }
|
|
488
|
+
svg.push('<g clip-path="url(#clip)">');
|
|
489
|
+
for (const tn of S.trace.turns) { if (tn.agent !== 'main' || tn.promptKind !== 'human' || tn.start == null) continue; const x = X(tn.start - TL.base); if (x < TL.left || x > width) continue; svg.push(`<line x1="${x}" y1="${TL.top - 2}" x2="${x}" y2="${H}" stroke="var(--muted)" stroke-dasharray="2 3" pointer-events="none"/><text class="turn-lbl" pointer-events="none" x="${x + 3}" y="${H - 4}">T${mainTurnNo(tn)}</text>`); }
|
|
490
|
+
for (const ev of S.trace.events) if (ev.kind === 'compact' && ev.at != null) { const x = X(ev.at - TL.base); svg.push(`<line x1="${x}" y1="${TL.top}" x2="${x}" y2="${H}" stroke="var(--accent)" stroke-width="1.5" pointer-events="none"/><text class="turn-lbl" x="${x + 3}" y="${H - 4}" fill="var(--accent)" pointer-events="none">compact</text>`); }
|
|
491
|
+
TL.lanes.forEach((ln, i) => {
|
|
492
|
+
const ly = laneY[i];
|
|
493
|
+
for (const id of ln.idle) { const x0 = Math.max(TL.left, X(id.s)), x1 = Math.min(width, X(id.e)); if (x1 > x0) svg.push(`<rect x="${x0}" y="${ly}" width="${x1 - x0}" height="${ln.rows.length * RH - 2}" fill="url(#hatch)" data-kind="idle" data-ref="${id.ref.index}" role="img" aria-label="${spanLabel(id)}"><title>${spanLabel(id)}</title></rect>`); }
|
|
494
|
+
ln.rows.forEach((row, ri) => {
|
|
495
|
+
const ry = ly + ri * RH;
|
|
496
|
+
for (const sp of row) {
|
|
497
|
+
let x0 = X(sp.s), x1 = X(sp.e); if (x1 < TL.left || x0 > width) continue;
|
|
498
|
+
x0 = Math.max(TL.left, x0); x1 = Math.min(width, x1); const w = Math.max(2, x1 - x0);
|
|
499
|
+
const id = sp.ref.id; const tab = id === first ? 0 : -1; const label = spanLabel(sp);
|
|
500
|
+
if (sp.kind === 'req') {
|
|
501
|
+
const r = sp.ref; const thinkOnly = r.blocks.length && r.blocks.every((b) => b.type === 'thinking');
|
|
502
|
+
const cls = 'span' + (S.sel && S.sel.kind === 'req' && S.sel.ref === r ? ' sel' : '') + (S.hl.has(r.id) || S.matches.has(r.id) ? ' hl' : '');
|
|
503
|
+
svg.push(`<rect class="${cls}" x="${x0}" y="${ry + 2}" width="${w}" height="${RH - 5}" rx="2" fill="var(--c-model)" opacity="${thinkOnly ? .55 : .9}" data-kind="req" data-ref="${esc(r.id)}" tabindex="${tab}" role="button" aria-label="${label}"/>`);
|
|
504
|
+
} else {
|
|
505
|
+
const c = sp.ref; const dim = (S.toolFilter && S.toolFilter !== c.name) || (S.query && !S.matches.has(c.id));
|
|
506
|
+
const cls = 'span' + (c.isError ? ' err' : '') + (S.sel && S.sel.kind === 'tool' && S.sel.ref === c ? ' sel' : '') + (S.hl.has(c.id) || S.matches.has(c.id) ? ' hl' : '');
|
|
507
|
+
svg.push(`<rect class="${cls}" x="${x0}" y="${ry + 2}" width="${w}" height="${RH - 5}" rx="2" fill="${CAT_COLOR[c.category]}" opacity="${dim ? .18 : (c.status === 'orphan' ? .5 : 1)}" data-kind="tool" data-ref="${esc(c.id)}" tabindex="${tab}" role="button" aria-label="${label}"/>`);
|
|
508
|
+
if (w > 40 && !dim) svg.push(`<text x="${x0 + 4}" y="${ry + RH - 6}" font-size="10" font-family="var(--mono)" fill="${CAT_LABEL[c.category]}" pointer-events="none" aria-hidden="true">${esc(shortName(c.name)).slice(0, Math.floor(w / 6.5))}</text>`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
svg.push('</g>');
|
|
514
|
+
TL.lanes.forEach((ln, i) => {
|
|
515
|
+
const ly = laneY[i]; const ag = ln.agent;
|
|
516
|
+
svg.push(`<rect x="0" y="${ly - 2}" width="${TL.left - 6}" height="${ln.rows.length * RH}" fill="var(--panel)"/>`);
|
|
517
|
+
svg.push(`<text class="lane-label" x="10" y="${ly + 12}">${ag.id === 'main' ? 'main' : '↳ ' + esc((ag.agentType || 'subagent').slice(0, width < 600 ? 7 : 16))}</text>`);
|
|
518
|
+
const sub = ag.id === 'main' ? `${ag.requestCount} req · ${ag.toolCount} tools` : esc((ag.description || ag.id).slice(0, 22));
|
|
519
|
+
svg.push(`<text class="lane-sub" x="10" y="${ly + 25}">${sub}</text>`);
|
|
520
|
+
});
|
|
521
|
+
svg.push(`<g id="cursor" hidden pointer-events="none"><line id="cursor-line" x1="0" y1="${TL.top - 2}" x2="0" y2="${H}" stroke="var(--ink)" opacity=".5"/><text id="cursor-txt" class="cursor-lbl" x="0" y="${TL.top - 8}"></text></g>`);
|
|
522
|
+
svg.push('</svg>');
|
|
523
|
+
const mm = []; const MW = width, MH = 44; const mx = (tm) => TL.left + tm / TL.total * (MW - TL.left - 10);
|
|
524
|
+
mm.push(`<svg class="minimap" id="minimap" viewBox="0 0 ${MW} ${MH}" width="${MW}" height="${MH}" role="img" aria-label="Minimap of the whole session; click to move the view"><rect x="0" y="0" width="${MW}" height="${MH}" fill="var(--panel)"/>`);
|
|
525
|
+
TL.lanes.forEach((ln, i) => { const lh = Math.max(4, (MH - 10) / TL.lanes.length); const ly = 5 + i * lh; for (const row of ln.rows.slice(1)) for (const sp of row) { const a = mx(sp.s), b = Math.max(a + 1, mx(sp.e)); mm.push(`<rect x="${a}" y="${ly}" width="${b - a}" height="${lh - 2}" fill="${CAT_COLOR[sp.ref.category]}" opacity=".7"/>`); } for (const sp of ln.rows[0]) { const a = mx(sp.s), b = Math.max(a + 1, mx(sp.e)); mm.push(`<rect x="${a}" y="${ly}" width="${b - a}" height="${lh - 2}" fill="var(--c-model)" opacity=".35"/>`); } });
|
|
526
|
+
mm.push(`<rect x="${mx(t0)}" y="1" width="${Math.max(3, mx(t1) - mx(t0))}" height="${MH - 2}" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1.5" rx="2"/><text class="lane-label" x="10" y="${MH / 2 + 4}">all</text></svg>`);
|
|
527
|
+
const legend = `<div class="tl-legend"><span><i style="background:var(--c-model)"></i>model</span>${Object.entries(CAT_COLOR).map(([k, v]) => `<span><i style="background:${v}"></i>${k}</span>`).join('')}<span><i style="background:url(#hatch);border:1px solid var(--faint)"></i>human idle</span><span><i style="border:1.5px solid var(--err);background:none"></i>error</span>${S.toolFilter ? `<span style="margin-left:auto">filtered: <b>${esc(S.toolFilter)}</b> <button class="btn sm" id="unfilter">clear</button></span>` : ''}</div>`;
|
|
528
|
+
box.innerHTML = svg.join('') + mm.join('') + legend;
|
|
529
|
+
bindTimeline();
|
|
530
|
+
if (S.focusId && document.activeElement && document.activeElement.closest && document.activeElement.closest('#tl')) { const el = box.querySelector(`[data-ref="${cssEsc(S.focusId)}"]`); if (el) el.focus({ preventScroll: true }); }
|
|
531
|
+
}
|
|
532
|
+
const cssEsc = (s) => (window.CSS && CSS.escape) ? CSS.escape(s) : String(s).replace(/["\\]/g, '\\$&');
|
|
533
|
+
function mainTurnNo(tn) { let n = 0; for (const x of S.trace.turns) { if (x.agent === 'main' && x.promptKind === 'human') { n++; if (x === tn) return n; } } return '?'; }
|
|
534
|
+
function shortName(n) { return n.startsWith('mcp__') ? n.split('__').slice(1).join(':') : n; }
|
|
535
|
+
function fmtTick(ms) { const s = Math.round(ms / 1000); const m = Math.floor(s / 60), r = s % 60; const h = Math.floor(m / 60); return h ? `${h}:${String(m % 60).padStart(2, '0')}:${String(r).padStart(2, '0')}` : `${m}:${String(r).padStart(2, '0')}`; }
|
|
536
|
+
function niceTicks(a, b, n, isTime) { const raw = (b - a) / n; const steps = isTime ? [1000, 2000, 5000, 10000, 15000, 30000, 60000, 120000, 300000, 600000, 900000, 1800000, 3600000, 7200000] : [1, 2, 5, 10, 20, 50, 100, 200, 500]; const step = steps.find((s) => s >= raw) || steps[steps.length - 1]; const out = []; for (let v = Math.ceil(a / step) * step; v <= b; v += step) out.push(v); return out; }
|
|
537
|
+
function zoomBy(k, frac) {
|
|
538
|
+
frac = frac == null ? 0.5 : frac; const { t0, t1 } = S.view; const span = t1 - t0; const cursor = t0 + frac * span;
|
|
539
|
+
let ns = Math.min(TL.total, Math.max(TL.total / 2000, span * k)); let n0 = cursor - frac * ns; n0 = Math.max(0, Math.min(TL.total - ns, n0));
|
|
540
|
+
S.view = { t0: n0, t1: n0 + ns }; scheduleTL();
|
|
541
|
+
}
|
|
542
|
+
function panBy(fracOfView) { const { t0, t1 } = S.view; const span = t1 - t0; let n0 = Math.max(0, Math.min(TL.total - span, t0 + span * fracOfView)); S.view = { t0: n0, t1: n0 + span }; scheduleTL(); }
|
|
543
|
+
function fracAt(svg, clientX) { const rect = svg.getBoundingClientRect(); const px = (clientX - rect.left) * (TL.W / rect.width); return Math.min(1, Math.max(0, (px - TL.left) / (TL.W - TL.left - 10))); }
|
|
544
|
+
function bindTimeline() {
|
|
545
|
+
const svg = $('#tl-svg'); if (!svg) return;
|
|
546
|
+
const un = $('#unfilter'); if (un) un.onclick = () => { S.toolFilter = null; renderTimeline(); renderTools(); };
|
|
547
|
+
svg.addEventListener('wheel', (e) => { e.preventDefault(); zoomBy(Math.exp(e.deltaY * 0.0015), fracAt(svg, e.clientX)); }, { passive: false });
|
|
548
|
+
svg.addEventListener('pointerdown', (e) => { TL.pointers.set(e.pointerId, { x: e.clientX }); if (TL.pointers.size === 1) { TL.drag = { x: e.clientX, t0: S.view.t0, t1: S.view.t1, moved: false }; svg.classList.add('drag'); } else if (TL.pointers.size === 2) { const [a, b] = Array.from(TL.pointers.values()); TL.pinch = { d: Math.max(10, Math.abs(a.x - b.x)), t0: S.view.t0, t1: S.view.t1, mid: fracAt(svg, (a.x + b.x) / 2) }; TL.drag = null; } try { svg.setPointerCapture(e.pointerId); } catch (x) { } });
|
|
549
|
+
svg.addEventListener('pointermove', (e) => {
|
|
550
|
+
if (TL.pointers.has(e.pointerId)) TL.pointers.set(e.pointerId, { x: e.clientX });
|
|
551
|
+
if (TL.pinch && TL.pointers.size === 2) { const [a, b] = Array.from(TL.pointers.values()); const d = Math.max(10, Math.abs(a.x - b.x)); const span0 = TL.pinch.t1 - TL.pinch.t0; let ns = Math.min(TL.total, Math.max(TL.total / 2000, span0 * TL.pinch.d / d)); const cursor = TL.pinch.t0 + TL.pinch.mid * span0; let n0 = Math.max(0, Math.min(TL.total - ns, cursor - TL.pinch.mid * ns)); S.view = { t0: n0, t1: n0 + ns }; scheduleTL(); return; }
|
|
552
|
+
if (TL.drag) { const dx = e.clientX - TL.drag.x; if (Math.abs(dx) > 3) TL.drag.moved = true; const rect = svg.getBoundingClientRect(); const span = TL.drag.t1 - TL.drag.t0; const dt = -dx / (rect.width * (TL.W - TL.left - 10) / TL.W) * span; let n0 = Math.max(0, Math.min(TL.total - span, TL.drag.t0 + dt)); S.view = { t0: n0, t1: n0 + span }; scheduleTL(); return; }
|
|
553
|
+
const el = e.target.closest('[data-kind]'); if (el) showTip(e, el); else hideTip();
|
|
554
|
+
moveCursor(svg, e.clientX);
|
|
555
|
+
});
|
|
556
|
+
const endPointer = (e) => { TL.pointers.delete(e.pointerId); if (TL.pointers.size < 2) TL.pinch = null; if (TL.drag) { const moved = TL.drag.moved; svg.classList.remove('drag'); setTimeout(() => { TL.drag = null; }, moved ? 50 : 0); } };
|
|
557
|
+
svg.addEventListener('pointerup', endPointer); svg.addEventListener('pointercancel', endPointer);
|
|
558
|
+
svg.addEventListener('mouseleave', () => { hideTip(); const c = $('#cursor'); if (c) c.setAttribute('hidden', ''); });
|
|
559
|
+
svg.addEventListener('click', (e) => { if (TL.drag && TL.drag.moved) return; let el = e.target.closest('[data-kind]'); if (!el) { const at = document.elementFromPoint(e.clientX, e.clientY); el = at && at.closest ? at.closest('[data-kind]') : null; } if (!el) return; selectEl(el); });
|
|
560
|
+
svg.addEventListener('focusin', (e) => { const el = e.target.closest && e.target.closest('[data-kind]'); if (el) { S.focusId = el.dataset.ref; showTip(null, el); } });
|
|
561
|
+
svg.addEventListener('focusout', () => hideTip());
|
|
562
|
+
svg.addEventListener('keydown', (e) => {
|
|
563
|
+
const el = e.target.closest && e.target.closest('[data-kind]'); if (!el) return;
|
|
564
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectEl(el); return; }
|
|
565
|
+
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) { e.preventDefault(); if (e.shiftKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { panBy(e.key === 'ArrowLeft' ? -0.25 : 0.25); return; } moveFocus(el, e.key); }
|
|
566
|
+
});
|
|
567
|
+
const mmEl = $('#minimap');
|
|
568
|
+
if (mmEl) { const go = (clientX) => { const rect = mmEl.getBoundingClientRect(); const px = (clientX - rect.left) * (TL.W / rect.width); const tm = (px - TL.left) / (TL.W - TL.left - 10) * TL.total; const span = S.view.t1 - S.view.t0; let n0 = Math.max(0, Math.min(TL.total - span, tm - span / 2)); S.view = { t0: n0, t1: n0 + span }; scheduleTL(); }; mmEl.addEventListener('pointerdown', (e) => { go(e.clientX); TL.mmDrag = true; }); mmEl.addEventListener('pointermove', (e) => { if (TL.mmDrag) go(e.clientX); }); window.addEventListener('pointerup', () => { TL.mmDrag = false; }); }
|
|
569
|
+
}
|
|
570
|
+
function moveCursor(svg, clientX) { const c = $('#cursor'); if (!c) return; const frac = fracAt(svg, clientX); const tm = S.view.t0 + frac * (S.view.t1 - S.view.t0); const x = TL.left + frac * (TL.W - TL.left - 10); c.removeAttribute('hidden'); $('#cursor-line').setAttribute('x1', x); $('#cursor-line').setAttribute('x2', x); const t = $('#cursor-txt'); t.setAttribute('x', x + 4); t.textContent = S.trace.meta.hasTimestamps ? '+' + fmtTick(tm) + ' ' + clock(TL.base + tm) : '#' + Math.round(tm); }
|
|
571
|
+
function spanInfo(el) { const ref = el.dataset.ref; for (let li = 0; li < TL.lanes.length; li++) { const ln = TL.lanes[li]; for (let ri = 0; ri < ln.rows.length; ri++) { const idx = ln.rows[ri].findIndex((s) => s.ref.id === ref); if (idx >= 0) return { li, ri, idx, sp: ln.rows[ri][idx] }; } } return null; }
|
|
572
|
+
function focusSpan(sp) { if (!sp) return; S.focusId = sp.ref.id; const v = S.view; const mid = (sp.s + sp.e) / 2; if (mid < v.t0 || mid > v.t1) { const span = v.t1 - v.t0; let n0 = Math.max(0, Math.min(TL.total - span, mid - span / 2)); S.view = { t0: n0, t1: n0 + span }; } renderTimeline(); const el = $('#tl').querySelector(`[data-ref="${cssEsc(sp.ref.id)}"]`); if (el) { $$('#tl-svg [tabindex="0"]').forEach((x) => x.setAttribute('tabindex', '-1')); el.setAttribute('tabindex', '0'); el.focus({ preventScroll: true }); } }
|
|
573
|
+
function moveFocus(el, key) {
|
|
574
|
+
const info = spanInfo(el); if (!info) return; const { li, ri, sp } = info; const lane = TL.lanes[li];
|
|
575
|
+
if (key === 'ArrowRight' || key === 'ArrowLeft') { const all = lane.rows.flat().sort((a, b) => a.s - b.s); const i = all.indexOf(sp); const nxt = all[i + (key === 'ArrowRight' ? 1 : -1)]; if (nxt) focusSpan(nxt); return; }
|
|
576
|
+
const targetLane = TL.lanes[li + (key === 'ArrowDown' ? 1 : -1)];
|
|
577
|
+
const pool = targetLane ? targetLane.rows.flat() : (key === 'ArrowDown' && ri < lane.rows.length - 1 ? lane.rows[ri + 1] : key === 'ArrowUp' && ri > 0 ? lane.rows[ri - 1] : null);
|
|
578
|
+
if (!pool || !pool.length) return;
|
|
579
|
+
const mid = (sp.s + sp.e) / 2; const best = pool.reduce((b, x) => Math.abs((x.s + x.e) / 2 - mid) < Math.abs((b.s + b.e) / 2 - mid) ? x : b);
|
|
580
|
+
focusSpan(best);
|
|
581
|
+
}
|
|
582
|
+
function selectEl(el) { const k = el.dataset.kind, ref = el.dataset.ref; if (k === 'tool') select({ kind: 'tool', ref: S.trace.toolCalls.find((c) => c.id === ref) }); else if (k === 'req') select({ kind: 'req', ref: S.trace.requests.find((r) => r.id === ref) }); }
|
|
583
|
+
let tlRaf = null; function scheduleTL() { if (tlRaf) return; tlRaf = requestAnimationFrame(() => { tlRaf = null; renderTimeline(); }); }
|
|
584
|
+
$('#z-in').onclick = () => zoomBy(0.6); $('#z-out').onclick = () => zoomBy(1 / 0.6); $('#z-reset').onclick = () => { S.view = null; $('#fit').value = ''; renderTimeline(); };
|
|
585
|
+
document.addEventListener('keydown', (e) => {
|
|
586
|
+
if (/input|textarea|select/i.test(e.target.tagName)) { if (e.key === 'Escape') e.target.blur(); return; }
|
|
587
|
+
if (!S.trace) return;
|
|
588
|
+
if (e.key === '0') { S.view = null; renderTimeline(); }
|
|
589
|
+
else if (e.key === '+' || e.key === '=') zoomBy(0.6);
|
|
590
|
+
else if (e.key === '-') zoomBy(1 / 0.6);
|
|
591
|
+
else if (e.key === '/') { e.preventDefault(); $('#q').focus(); }
|
|
592
|
+
else if (e.key === '?') { $('#help-dlg').showModal(); }
|
|
593
|
+
else if (e.key === 'Escape') closeDrawer();
|
|
594
|
+
});
|
|
595
|
+
window.addEventListener('resize', () => { if (S.trace) { renderTimeline(); renderBurn(); } });
|
|
596
|
+
|
|
597
|
+
const tip = $('#tip');
|
|
598
|
+
function showTip(e, el) {
|
|
599
|
+
const k = el.dataset.kind, ref = el.dataset.ref; let html = '';
|
|
600
|
+
if (k === 'tool') { const c = S.trace.toolCalls.find((x) => x.id === ref); if (!c) return; html = `<b>${esc(c.name)}</b> <span class="m">${c.status}${c.durationMs != null ? ' · ' + fmtDur(c.durationMs) : ''}</span><br><span class="m">${esc(R(inputSummary(c)).slice(0, 160))}</span>${c.resultChars ? `<br><span class="m">→ ${fmtInt(c.resultChars)} chars</span>` : ''}`; }
|
|
601
|
+
else if (k === 'req') { const r = S.trace.requests.find((x) => x.id === ref); if (!r) return; const i = S.trace.requests.indexOf(r) + 1; html = `<b>Request #${i}</b> <span class="m">${esc(r.blocksSummary)}</span><br><span class="m">${fmtDur(r.responseMs)} · ${fmtTok(r.usage.output)} out (${fmtTok(r.usage.thinking)} thinking) · ctx ${fmtTok(r.contextTokens)}${r.tokensPerSec ? ' · ' + r.tokensPerSec.toFixed(0) + ' tok/s' : ''}</span>${S.cost.byRequest[i - 1] != null ? `<br><span class="m">${fmtUSD(S.cost.byRequest[i - 1])}</span>` : ''}`; }
|
|
602
|
+
else if (k === 'idle') { const tn = S.trace.turns[+ref]; html = `<b>Waiting on you</b> <span class="m">${fmtDur(tn.idleBeforeMs)}</span>`; }
|
|
603
|
+
else if (k === 'bar') { const r = S.trace.requests[+ref]; const i = +ref + 1; html = `<b>Request #${i}</b> <span class="m">${esc(r.blocksSummary)}</span><br><span class="m">cache read ${fmtTok(r.usage.cacheRead)} · cache write ${fmtTok(r.usage.cacheWrite)} · input ${fmtTok(r.usage.input)}</span><br><span class="m">out ${fmtTok(r.usage.output)} · ${fmtUSD(S.cost.byRequest[+ref])} · cumulative ${fmtUSD(cum(+ref))}</span>`; }
|
|
604
|
+
tip.innerHTML = html; tip.style.display = 'block';
|
|
605
|
+
if (e) moveTip(e.clientX, e.clientY); else { const b = el.getBoundingClientRect(); moveTip(b.left + Math.min(b.width, 40), b.bottom + 4); }
|
|
606
|
+
}
|
|
607
|
+
function moveTip(cx, cy) { const w = tip.offsetWidth, h = tip.offsetHeight; let x = cx + 14, y = cy + 14; if (x + w > innerWidth - 8) x = cx - w - 10; if (y + h > innerHeight - 8) y = cy - h - 10; tip.style.left = Math.max(4, x) + 'px'; tip.style.top = Math.max(4, y) + 'px'; }
|
|
608
|
+
function hideTip() { tip.style.display = 'none'; }
|
|
609
|
+
document.addEventListener('mousemove', (e) => { if (tip.style.display === 'block' && !(document.activeElement && document.activeElement.dataset && document.activeElement.dataset.kind)) moveTip(e.clientX, e.clientY); });
|
|
610
|
+
function cum(i) { let s = 0; for (let k = 0; k <= i; k++) s += S.cost.byRequest[k] || 0; return s; }
|
|
611
|
+
function inputSummary(c) { const i = c.input || {}; if (i.command) return i.command; if (i.file_path) return i.file_path + (i.pattern ? ' ' + i.pattern : ''); if (i.pattern) return i.pattern; if (i.query) return i.query; if (i.description) return i.description; if (i.url) return i.url; if (i.prompt) return i.prompt; if (i.path) return i.path; const s = JSON.stringify(i); return s === '{}' ? '' : s; }
|
|
612
|
+
|
|
613
|
+
// ------------------------------------------------------------ burn chart
|
|
614
|
+
function renderBurn() {
|
|
615
|
+
const box = $('#burn'); const reqs = S.trace.requests; const width = box.clientWidth || 800; const H = 200, L = 46, Rm = 52, T = 14, B = 22;
|
|
616
|
+
if (!reqs.length) { box.innerHTML = '<div class="empty">No API requests found.</div>'; return; }
|
|
617
|
+
const maxCtx = Math.max(1, ...reqs.map((r) => r.contextTokens)); const costs = S.cost.byRequest; let acc = 0; const cumv = costs.map((c) => (acc += c || 0)); const maxCost = Math.max(1e-9, ...cumv);
|
|
618
|
+
const plotW = width - L - Rm, bw = Math.max(1, plotW / reqs.length - 1);
|
|
619
|
+
const y = (v) => T + (H - T - B) * (1 - v / maxCtx), yc = (v) => T + (H - T - B) * (1 - v / maxCost);
|
|
620
|
+
const s = [`<svg viewBox="0 0 ${width} ${H}" width="${width}" height="${H}" role="group" aria-label="Context size per request, ${reqs.length} requests, peak ${fmtTok(maxCtx)} tokens${S.cost.total != null ? ', cumulative cost ' + fmtUSD(S.cost.total) : ''}. Use arrow keys to move between requests.">`];
|
|
621
|
+
for (const f of [0, .25, .5, .75, 1]) { const yy = y(maxCtx * f); s.push(`<line x1="${L}" x2="${width - Rm}" y1="${yy}" y2="${yy}" stroke="var(--line-2)"/><text class="axis" x="${L - 4}" y="${yy + 3}" text-anchor="end">${fmtTok(Math.round(maxCtx * f))}</text>`); }
|
|
622
|
+
if (S.cost.total != null) for (const f of [.5, 1]) { const yy = yc(maxCost * f); s.push(`<text class="axis" x="${width - Rm + 4}" y="${yy + 3}" fill="var(--accent)">${fmtUSD(maxCost * f)}</text>`); }
|
|
623
|
+
const focusIdx = S.sel && S.sel.kind === 'req' ? reqs.indexOf(S.sel.ref) : 0;
|
|
624
|
+
reqs.forEach((r, i) => {
|
|
625
|
+
const x = L + i * (plotW / reqs.length); const u = r.usage; let top = H - B; const isSub = r.agent !== 'main'; const col = isSub ? 'var(--c-agent)' : 'var(--c-read)';
|
|
626
|
+
const label = `Request ${i + 1}: context ${fmtTok(r.contextTokens)} tokens (${fmtTok(u.cacheRead)} cached), ${fmtTok(u.output)} output, ${fmtUSD(costs[i])}`;
|
|
627
|
+
s.push(`<g class="bar" data-kind="bar" data-ref="${i}" tabindex="${i === focusIdx ? 0 : -1}" role="button" aria-label="${label}">`);
|
|
628
|
+
const seg = (v, op) => { if (!v) return; const h = (H - T - B) * v / maxCtx; top -= h; s.push(`<rect x="${x}" y="${top}" width="${bw}" height="${h}" fill="${col}" opacity="${op}"/>`); };
|
|
629
|
+
seg(u.cacheRead, .35); seg(u.cacheWrite, .7); seg(u.input, 1);
|
|
630
|
+
s.push(`<rect x="${x}" y="${T}" width="${bw}" height="${H - T - B}" fill="transparent"/></g>`);
|
|
631
|
+
if (S.sel && S.sel.kind === 'req' && S.sel.ref === r) s.push(`<rect x="${x - 1}" y="${T}" width="${bw + 2}" height="${H - T - B}" fill="none" stroke="var(--accent)" stroke-width="1.5" pointer-events="none"/>`);
|
|
632
|
+
});
|
|
633
|
+
for (const ev of S.trace.events) if (ev.kind === 'compact' && ev.at != null) { const idx = reqs.findIndex((r) => r.start != null && r.start >= ev.at); if (idx >= 0) { const x = L + idx * (plotW / reqs.length); s.push(`<line x1="${x}" x2="${x}" y1="${T}" y2="${H - B}" stroke="var(--accent)" stroke-dasharray="3 2"/>`); } }
|
|
634
|
+
if (S.cost.total != null) { const pts = cumv.map((v, i) => `${(L + i * (plotW / reqs.length) + bw / 2).toFixed(1)},${yc(v).toFixed(1)}`); s.push(`<polyline points="${pts.join(' ')}" fill="none" stroke="var(--accent)" stroke-width="1.8" stroke-linejoin="round" pointer-events="none"/>`); const lx = L + (reqs.length - 1) * (plotW / reqs.length) + bw / 2; s.push(`<circle cx="${lx}" cy="${yc(cumv[cumv.length - 1])}" r="3" fill="var(--accent)"/>`); }
|
|
635
|
+
const step = Math.max(1, Math.ceil(reqs.length / Math.max(2, Math.floor(plotW / 60))));
|
|
636
|
+
for (let i = 0; i < reqs.length; i += step) s.push(`<text class="axis" x="${L + i * (plotW / reqs.length)}" y="${H - 6}">#${i + 1}</text>`);
|
|
637
|
+
s.push('</svg>');
|
|
638
|
+
box.innerHTML = s.join('');
|
|
639
|
+
$('#burn-aux').innerHTML = `bars: prompt size per request (light = cached read, mid = cache write, dark = uncached${S.trace.agents.length > 1 ? ', violet = subagent' : ''}) · <span style="color:var(--accent)">line</span>: cumulative est. cost`;
|
|
640
|
+
const svg = box.querySelector('svg');
|
|
641
|
+
svg.addEventListener('mousemove', (e) => { const el = e.target.closest('[data-kind]'); if (el) showTip(e, el); else hideTip(); });
|
|
642
|
+
svg.addEventListener('mouseleave', hideTip);
|
|
643
|
+
svg.addEventListener('click', (e) => { const el = e.target.closest('[data-kind=bar]'); if (el) select({ kind: 'req', ref: S.trace.requests[+el.dataset.ref] }); });
|
|
644
|
+
svg.addEventListener('focusin', (e) => { const el = e.target.closest('[data-kind=bar]'); if (el) showTip(null, el); });
|
|
645
|
+
svg.addEventListener('focusout', hideTip);
|
|
646
|
+
svg.addEventListener('keydown', (e) => { const el = e.target.closest('[data-kind=bar]'); if (!el) return; const i = +el.dataset.ref; if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); select({ kind: 'req', ref: reqs[i] }); } else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { e.preventDefault(); const n = svg.querySelector(`[data-ref="${i + (e.key === 'ArrowRight' ? 1 : -1)}"]`); if (n) { el.setAttribute('tabindex', '-1'); n.setAttribute('tabindex', '0'); n.focus(); } } });
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ------------------------------------------------------------ findings
|
|
650
|
+
function findingMd(f) { return `- **${f.severity.toUpperCase()}** \`${f.id}\` — ${f.title}\n ${f.detail}`; }
|
|
651
|
+
function reportMd() {
|
|
652
|
+
const t = S.trace, T = t.totals, m = t.meta;
|
|
653
|
+
const head = `# Glassbox report — ${R(m.title || 'session')}\n\n` + `- session: ${m.sessionId || '—'} · model: ${m.models.join(', ') || '—'} · started: ${m.start ? new Date(m.start).toISOString() : '—'}\n` + `- wall ${fmtDur(T.wallMs)} (active ${fmtDur(T.activeMs)}) · ${T.turns} turns · ${T.requests} requests · ${T.toolCalls} tool calls (${T.toolErrors} failed)\n` + `- tokens: ${fmtInt(T.usage.input + T.usage.cacheRead + T.usage.cacheWrite)} context served (${pct(T.cacheHitRatio)} cached), ${fmtInt(T.usage.output)} output (${fmtInt(T.usage.thinking)} thinking) · est. cost ${S.cost.total != null ? fmtUSD(S.cost.total) : '—'}\n\n`;
|
|
654
|
+
const fs = S.findings.length ? `## Findings (${S.findings.length})\n\n` + S.findings.map(findingMd).join('\n') + '\n' : '## Findings\n\nNothing flagged.\n';
|
|
655
|
+
const tools = '\n## Tools\n\n| tool | calls | errors | total time |\n|---|---|---|---|\n' + toolRows().map((r) => `| ${r.name} | ${r.calls} | ${r.errors} | ${fmtDur(r.time)} |`).join('\n') + '\n';
|
|
656
|
+
return head + fs + tools + '\n_Generated by Glassbox._\n';
|
|
657
|
+
}
|
|
658
|
+
async function copyText(text, what) { try { await navigator.clipboard.writeText(text); say(what + ' copied to clipboard.'); return true; } catch (e) { const dlg = $('#copy-dlg'); $('#copy-text').value = text; dlg.showModal(); $('#copy-text').select(); say('Clipboard blocked — text shown for manual copy.'); return false; } }
|
|
659
|
+
$('#copy-close').onclick = () => $('#copy-dlg').close();
|
|
660
|
+
function renderFindings() {
|
|
661
|
+
const box = $('#findings'); const fs = S.findings;
|
|
662
|
+
$('#find-aux').textContent = fs.length ? `${fs.length} · mechanical rules, no AI` : '';
|
|
663
|
+
$('#copy-all').disabled = !fs.length;
|
|
664
|
+
if (!fs.length) { box.innerHTML = '<div class="empty">Nothing flagged. Either a clean session or a very short one.</div>'; return; }
|
|
665
|
+
box.innerHTML = fs.map((f, i) => `<div class="finding ${f.severity}" data-i="${i}"><div class="bar" aria-hidden="true"></div><div><button type="button" class="t" data-go="${i}" aria-label="${esc(f.severity)}: ${esc(f.title)}. Show on timeline.">${esc(f.title)}</button><div class="d">${esc(S.share ? f.detail.replace(/«[^»]*»|\/[^\s]+/g, '…') : f.detail)}</div></div><div class="side"><span class="id">${f.id}</span><button type="button" class="btn sm" data-copy="${i}" aria-label="Copy finding ${i + 1} as Markdown">Copy</button></div></div>`).join('');
|
|
666
|
+
$$('.finding', box).forEach((el) => { el.onclick = (e) => { if (e.target.closest('[data-copy]')) return; focusFinding(+el.dataset.i); }; });
|
|
667
|
+
$$('[data-copy]', box).forEach((b) => b.onclick = (e) => { e.stopPropagation(); copyText(findingMd(S.findings[+b.dataset.copy]), 'Finding'); });
|
|
668
|
+
}
|
|
669
|
+
$('#copy-all').onclick = () => copyText(S.findings.map(findingMd).join('\n'), 'All findings');
|
|
670
|
+
$('#export-md').onclick = async () => { const md = reportMd(); await copyText(md, 'Report (Markdown)'); download((S.trace.meta.sessionId || 'session').slice(0, 8) + '-glassbox-report.md', md, 'text/markdown'); };
|
|
671
|
+
function download(name, text, type) { try { const blob = new Blob([text], { type }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 2000); } catch (e) { } }
|
|
672
|
+
function focusFinding(i) {
|
|
673
|
+
const f = S.findings[i]; const ev = f.evidence || {};
|
|
674
|
+
$$('.finding').forEach((el, k) => el.classList.toggle('active', k === i));
|
|
675
|
+
S.hl = new Set([...(ev.toolCallIds || []), ...(ev.requestIds || [])]);
|
|
676
|
+
const items = [...(ev.toolCallIds || []).map((id) => S.trace.toolCalls.find((c) => c.id === id)), ...(ev.requestIds || []).map((id) => S.trace.requests.find((r) => r.id === id))].filter(Boolean);
|
|
677
|
+
if (items.length && S.trace.meta.hasTimestamps) { let a = Infinity, b = -Infinity; for (const it of items) { const s = (it.responseFrom != null ? it.responseFrom : it.start), e = it.end != null ? it.end : S.trace.meta.end; if (s != null) a = Math.min(a, s); if (e != null) b = Math.max(b, e); } if (Number.isFinite(a) && Number.isFinite(b)) { const pad = Math.max((b - a) * .6, 15000); S.view = { t0: Math.max(0, a - TL.base - pad), t1: Math.min(TL.total, b - TL.base + pad) }; } }
|
|
678
|
+
else if (ev.at != null && S.trace.meta.hasTimestamps) { const c = ev.at - TL.base; S.view = { t0: Math.max(0, c - 60000), t1: Math.min(TL.total, c + 60000) }; }
|
|
679
|
+
renderTimeline();
|
|
680
|
+
if (items.length === 1) select({ kind: items[0].name ? 'tool' : 'req', ref: items[0] }, true); else closeDrawer();
|
|
681
|
+
setHash('find=' + (i + 1));
|
|
682
|
+
say(`${f.title}. ${items.length} item${items.length === 1 ? '' : 's'} highlighted on the timeline.`);
|
|
683
|
+
$('#tl').scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ------------------------------------------------------------ tools table
|
|
687
|
+
const TT = { sort: 'time', dir: -1 };
|
|
688
|
+
function toolRows() {
|
|
689
|
+
const rows = new Map();
|
|
690
|
+
for (const c of S.trace.toolCalls) { if (c.unmatched) continue; if (!rows.has(c.name)) rows.set(c.name, { name: c.name, category: c.category, calls: 0, errors: 0, time: 0, max: 0, chars: 0, n: 0 }); const r = rows.get(c.name); r.calls++; if (c.isError) r.errors++; if (c.durationMs != null) { r.time += c.durationMs; r.n++; r.max = Math.max(r.max, c.durationMs); } r.chars += c.resultChars || 0; }
|
|
691
|
+
const list = Array.from(rows.values()).map((r) => Object.assign(r, { avg: r.n ? r.time / r.n : 0, rate: r.calls ? r.errors / r.calls : 0 }));
|
|
692
|
+
list.sort((a, b) => (typeof a[TT.sort] === 'string' ? a[TT.sort].localeCompare(b[TT.sort]) : a[TT.sort] - b[TT.sort]) * TT.dir);
|
|
693
|
+
return list;
|
|
694
|
+
}
|
|
695
|
+
function renderTools() {
|
|
696
|
+
const list = toolRows();
|
|
697
|
+
const cols = [['name', 'Tool', ''], ['calls', 'Calls', 'n'], ['errors', 'Errors', 'n'], ['time', 'Total time', 'n'], ['avg', 'Avg', 'n'], ['max', 'Max', 'n'], ['chars', 'Result chars', 'n']];
|
|
698
|
+
const th = cols.map(([k, l, c]) => `<th class="${c}" scope="col" aria-sort="${TT.sort === k ? (TT.dir < 0 ? 'descending' : 'ascending') : 'none'}"><button type="button" data-k="${k}">${l}${TT.sort === k ? (TT.dir < 0 ? ' ↓' : ' ↑') : ''}</button></th>`).join('');
|
|
699
|
+
const tr = list.map((r) => `<tr class="row" data-name="${esc(r.name)}" tabindex="0" role="button" aria-pressed="${S.toolFilter === r.name}" aria-label="${esc(r.name)}, ${r.calls} calls${r.errors ? ', ' + r.errors + ' errors' : ''}, ${fmtDur(r.time)} total. Filter the timeline to this tool."><td><span class="cat" style="background:${CAT_COLOR[r.category]}" aria-hidden="true"></span>${esc(r.name)}<span class="sr-only"> (${r.category})</span></td><td class="n">${r.calls}</td><td class="n">${r.errors ? `<span class="errpill">${r.errors} (${Math.round(r.rate * 100)}%)</span>` : '—'}</td><td class="n">${fmtDur(r.time)}</td><td class="n">${fmtDur(r.avg)}</td><td class="n">${fmtDur(r.max)}</td><td class="n">${fmtInt(r.chars)}</td></tr>`).join('');
|
|
700
|
+
$('#tools').innerHTML = `<caption class="sr-only">Tool calls grouped by tool</caption><thead><tr>${th}</tr></thead><tbody>${tr || '<tr><td colspan="7" class="empty">No tool calls.</td></tr>'}</tbody>`;
|
|
701
|
+
$$('#tools th button').forEach((el) => el.onclick = () => { const k = el.dataset.k; if (TT.sort === k) TT.dir *= -1; else { TT.sort = k; TT.dir = k === 'name' ? 1 : -1; } renderTools(); });
|
|
702
|
+
const toggle = (el) => { S.toolFilter = S.toolFilter === el.dataset.name ? null : el.dataset.name; renderTools(); renderTimeline(); say(S.toolFilter ? 'Timeline filtered to ' + S.toolFilter : 'Filter cleared'); };
|
|
703
|
+
$$('#tools tr.row').forEach((el) => { el.onclick = () => toggle(el); el.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(el); } }; });
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ------------------------------------------------------------ turns + search
|
|
707
|
+
function matchText(s) { return !!S.query && s != null && String(s).toLowerCase().includes(S.query); }
|
|
708
|
+
function markText(s) { s = String(s == null ? '' : s); if (!S.query) return esc(s); const q = S.query; const out = []; let i = 0; const low = s.toLowerCase(); let j; while ((j = low.indexOf(q, i)) >= 0) { out.push(esc(s.slice(i, j)) + '<mark>' + esc(s.slice(j, j + q.length)) + '</mark>'); i = j + q.length; } out.push(esc(s.slice(i))); return out.join(''); }
|
|
709
|
+
function runSearch() {
|
|
710
|
+
const q = $('#q').value.trim().toLowerCase(); S.query = q; S.matches = new Set();
|
|
711
|
+
if (q) { for (const c of S.trace.toolCalls) if (matchText(c.name) || matchText(inputSummary(c)) || matchText(JSON.stringify(c.input)) || matchText(c.resultText)) S.matches.add(c.id); for (const r of S.trace.requests) if (r.blocks.some((b) => b.type === 'text' && matchText(b.text))) S.matches.add(r.id); }
|
|
712
|
+
$('#q-n').textContent = q ? `${S.matches.size} hit${S.matches.size === 1 ? '' : 's'}` : '';
|
|
713
|
+
renderTimeline(); renderTurns();
|
|
714
|
+
}
|
|
715
|
+
let qTimer = null; $('#q').addEventListener('input', () => { clearTimeout(qTimer); qTimer = setTimeout(runSearch, 150); });
|
|
716
|
+
function renderTurns() {
|
|
717
|
+
const t = S.trace; const box = $('#turns');
|
|
718
|
+
const turns = t.turns.filter((tn) => tn.agent === 'main');
|
|
719
|
+
$('#turns-aux').textContent = `${turns.filter((x) => x.promptKind === 'human').length} prompts${t.agents.length > 1 ? ' · subagent work is inside its Agent call' : ''}`;
|
|
720
|
+
let n = 0;
|
|
721
|
+
box.innerHTML = turns.map((tn) => {
|
|
722
|
+
const human = tn.promptKind === 'human'; if (human) n++;
|
|
723
|
+
const reqs = tn.requestIds.map((id) => t.requests.find((r) => r.id === id)).filter(Boolean);
|
|
724
|
+
const cost = reqs.reduce((s, r) => s + (S.cost.byRequest[t.requests.indexOf(r)] || 0), 0);
|
|
725
|
+
const dur = tn.start != null && tn.end != null ? tn.end - tn.start : null;
|
|
726
|
+
const label = human ? `T${n}` : tn.promptKind;
|
|
727
|
+
const prompt = tn.promptText ? R(tn.promptText).replace(/\s+/g, ' ').slice(0, 140) : '(no prompt text)';
|
|
728
|
+
const steps = [];
|
|
729
|
+
for (const r of reqs) {
|
|
730
|
+
const txt = r.blocks.filter((b) => b.type === 'text').map((b) => b.text).join(' ').trim();
|
|
731
|
+
const hit = S.matches.has(r.id);
|
|
732
|
+
if (txt) steps.push({ t: r.end, kind: 'model', hit, html: `<span class="what"><span class="n">says</span> <span class="a">${markText(R(txt).replace(/\s+/g, ' ').slice(0, 120))}</span></span>`, r: `${fmtTok(r.usage.output)} out`, sel: { kind: 'req', ref: r }, color: 'var(--c-model)', label: `Model reply, ${fmtTok(r.usage.output)} output tokens` });
|
|
733
|
+
else if (!r.blocks.some((b) => b.type === 'tool_use')) steps.push({ t: r.end, kind: 'model', hit, html: `<span class="what"><span class="a">${esc(r.blocksSummary || 'response')}</span></span>`, r: `${fmtTok(r.usage.output)} out`, sel: { kind: 'req', ref: r }, color: 'var(--c-model)', label: `Model response, ${fmtTok(r.usage.output)} output tokens` });
|
|
734
|
+
for (const b of r.blocks) if (b.type === 'tool_use') { const c = t.toolCalls.find((x) => x.id === b.id); if (!c) continue; steps.push({ t: c.start, kind: 'tool', hit: S.matches.has(c.id), html: `<span class="what"><span class="n">${esc(shortName(c.name))}</span> <span class="a">${markText(R(inputSummary(c)).slice(0, 110))}</span></span>`, r: `${c.status === 'error' ? '<span class="errpill">error</span> · ' : c.status === 'orphan' ? 'no result · ' : ''}${fmtDur(c.durationMs)}${c.resultChars ? ' · ' + fmtTok(c.resultChars) + ' ch' : ''}`, sel: { kind: 'tool', ref: c }, color: CAT_COLOR[c.category], label: `${c.name}, ${c.status}, ${fmtDur(c.durationMs)}` }); }
|
|
735
|
+
}
|
|
736
|
+
const anyHit = !S.query || steps.some((s) => s.hit) || matchText(tn.promptText);
|
|
737
|
+
const stepsHtml = steps.map((s, k) => `<div class="step ${s.kind}" data-turn="${tn.index}" data-step="${k}" tabindex="0" role="button" aria-label="${esc(s.label)}. Open detail." ${S.query && !s.hit ? 'hidden' : ''}><span class="t">${tn.start != null && s.t != null ? '+' + fmtTick(s.t - tn.start) : ''}</span><span class="dot" style="background:${s.color}" aria-hidden="true"></span>${s.html}<span class="r">${s.r}</span></div>`).join('');
|
|
738
|
+
tn._steps = steps;
|
|
739
|
+
return `<details class="turn" ${S.query && anyHit ? 'open' : ''} ${anyHit ? '' : 'hidden'}><summary><span class="no">${label}</span><span class="p ${human ? '' : 'meta'}">${markText(prompt)}</span><span class="stats">${tn.toolCallIds.length} tools · ${fmtTok(tn.usage.output)} out · ${fmtDur(dur)}${cost ? ' · ' + fmtUSD(cost) : ''}${tn.idleBeforeMs > 60000 ? ' · after ' + fmtDur(tn.idleBeforeMs) + ' idle' : ''}</span></summary><div class="body">${tn.promptText ? `<div class="prompt ${S.share ? 'redacted' : ''}">${markText(R(tn.promptText).slice(0, 4000))}</div>` : ''}${stepsHtml || '<div class="empty">No model activity in this turn.</div>'}</div></details>`;
|
|
740
|
+
}).join('') || '<div class="empty">No turns.</div>';
|
|
741
|
+
$$('.step', box).forEach((el) => { const go = () => { const tn = t.turns[+el.dataset.turn]; const s = tn._steps[+el.dataset.step]; select(s.sel, true); }; el.onclick = go; el.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } }; });
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// ------------------------------------------------------------ selection / drawer
|
|
745
|
+
function kv(pairs) { return '<dl class="kv">' + pairs.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('') + '</dl>'; }
|
|
746
|
+
function select(sel, keepHl) {
|
|
747
|
+
if (!sel || !sel.ref) return;
|
|
748
|
+
S.sel = sel; if (!keepHl) S.hl = new Set();
|
|
749
|
+
if (!$('#drawer').contains(document.activeElement)) S.lastFocus = document.activeElement;
|
|
750
|
+
renderTimeline(); renderBurn();
|
|
751
|
+
const d = $('#drawer'), body = $('#d-body');
|
|
752
|
+
if (sel.kind === 'tool') {
|
|
753
|
+
const c = sel.ref; const i = S.trace.toolCalls.indexOf(c);
|
|
754
|
+
$('#d-title').textContent = c.name;
|
|
755
|
+
const sub = c.subagentId ? S.trace.agents.find((a) => a.id === c.subagentId) : null;
|
|
756
|
+
body.innerHTML = kv([['status', `${c.status}${c.isError ? ' <span class="errpill">error</span>' : ''}`], ['category', c.category], ['started', c.start != null ? clock(c.start) + ' · +' + fmtTick(c.start - S.trace.meta.start) : '—'], ['duration', fmtDur(c.durationMs)], ['agent', esc(c.agent)], ['turn', c.turnIndex + 1], ['input size', fmtInt(c.inputChars) + ' chars'], ['result size', fmtInt(c.resultChars) + ' chars'], ...(sub ? [['spawned', `${esc(sub.agentType || 'subagent')} · ${sub.requestCount} req · ${sub.toolCount} tools · ${fmtTok(sub.usage.output)} out`]] : []), ['call #', `${i + 1} of ${S.trace.toolCalls.length}`]]) +
|
|
757
|
+
`<h4>Input</h4><pre tabindex="0">${esc(S.share ? '«' + c.inputChars + ' chars»' : JSON.stringify(c.input, null, 2))}</pre>
|
|
758
|
+
<h4>Result</h4><pre tabindex="0">${esc(S.share ? '«' + c.resultChars + ' chars»' : (c.resultText || (c.resultStructured ? JSON.stringify(c.resultStructured, null, 2) : '(empty)')).slice(0, 30000))}${!S.share && c.resultText && c.resultText.length > 30000 ? '\n… ' + fmtInt(c.resultText.length - 30000) + ' more chars' : ''}</pre>`;
|
|
759
|
+
setHash('tool=' + c.id);
|
|
760
|
+
} else {
|
|
761
|
+
const r = sel.ref; const i = S.trace.requests.indexOf(r); const u = r.usage;
|
|
762
|
+
$('#d-title').textContent = `Request #${i + 1}`;
|
|
763
|
+
body.innerHTML = kv([['model', esc(r.model || '—')], ['stop reason', esc(r.stopReason || '—')], ['response', `${fmtDur(r.responseMs)}${r.tokensPerSec ? ' · ' + r.tokensPerSec.toFixed(1) + ' tok/s' : ''}`], ['context', fmtInt(r.contextTokens) + ' tokens'], ['cache read', fmtInt(u.cacheRead)], ['cache write', `${fmtInt(u.cacheWrite)}${u.cacheWrite1h ? ' (1h: ' + fmtInt(u.cacheWrite1h) + ')' : ''}`], ['uncached input', fmtInt(u.input)], ['output', `${fmtInt(u.output)} (${fmtInt(u.thinking)} thinking)`], ['cost', fmtUSD(S.cost.byRequest[i])], ['agent', esc(r.agent)], ['blocks', esc(r.blocksSummary)]]) +
|
|
764
|
+
r.blocks.map((b) => b.type === 'text' ? `<h4>Text</h4><pre tabindex="0">${esc(S.share ? '«' + b.text.length + ' chars»' : b.text)}</pre>` : b.type === 'thinking' ? `<h4>Thinking</h4><pre tabindex="0">${esc(S.share || !b.text ? '«' + (b.text || '').length + ' chars»' : b.text.slice(0, 8000))}</pre>` : b.type === 'tool_use' ? `<h4>Tool call</h4><pre>${esc(b.name)}</pre>` : '').join('');
|
|
765
|
+
setHash('req=' + (i + 1));
|
|
766
|
+
}
|
|
767
|
+
d.hidden = false; requestAnimationFrame(() => d.classList.add('open'));
|
|
768
|
+
$('#d-title').focus();
|
|
769
|
+
}
|
|
770
|
+
function closeDrawer(silent) {
|
|
771
|
+
const d = $('#drawer'); if (d.hidden) return; d.classList.remove('open'); d.hidden = true;
|
|
772
|
+
const lf = S.lastFocus; const ref = lf && lf.dataset ? lf.dataset.ref : null;
|
|
773
|
+
if (S.sel) { S.sel = null; if (!silent) { renderTimeline(); renderBurn(); } }
|
|
774
|
+
if (silent) return;
|
|
775
|
+
let target = lf && lf.isConnected ? lf : (ref != null ? document.querySelector(`#tl [data-ref="${cssEsc(ref)}"], #burn [data-ref="${cssEsc(ref)}"]`) : null);
|
|
776
|
+
if (target) { if (target.closest('#tl-svg')) { $$('#tl-svg [tabindex="0"]').forEach((x) => x.setAttribute('tabindex', '-1')); target.setAttribute('tabindex', '0'); } try { target.focus({ preventScroll: true }); } catch (e) { } }
|
|
777
|
+
}
|
|
778
|
+
$('#d-close').onclick = () => closeDrawer();
|
|
779
|
+
|
|
780
|
+
// ------------------------------------------------------------ permalinks
|
|
781
|
+
let hashLock = false;
|
|
782
|
+
function setHash(h) { hashLock = true; try { history.replaceState(null, '', '#' + h); } catch (e) { } setTimeout(() => { hashLock = false; }, 0); }
|
|
783
|
+
function applyHash() {
|
|
784
|
+
const h = location.hash.slice(1); if (!h || !S.trace) return;
|
|
785
|
+
const eq = h.indexOf('='); if (eq < 0) return; const k = h.slice(0, eq), v = decodeURIComponent(h.slice(eq + 1)); if (!v) return;
|
|
786
|
+
if (k === 'req') { const r = S.trace.requests[+v - 1]; if (r) select({ kind: 'req', ref: r }); }
|
|
787
|
+
else if (k === 'tool') { const c = S.trace.toolCalls.find((x) => x.id === v); if (c) select({ kind: 'tool', ref: c }); }
|
|
788
|
+
else if (k === 'find') { if (S.findings[+v - 1]) focusFinding(+v - 1); }
|
|
789
|
+
else if (k === 'turn') { const tn = S.trace.turns.filter((x) => x.agent === 'main' && x.promptKind === 'human')[+v - 1]; if (tn) { $('#fit').value = String(tn.index); $('#fit').onchange(); } }
|
|
790
|
+
}
|
|
791
|
+
window.addEventListener('hashchange', () => { if (!hashLock) applyHash(); });
|
|
792
|
+
|
|
793
|
+
// ------------------------------------------------------------ share / export
|
|
794
|
+
$('#share').onclick = () => { S.share = !S.share; $('#share').setAttribute('aria-pressed', String(S.share)); $('#export').disabled = !S.share; if (S.trace) renderAll(); say(S.share ? 'Share mode on: text is blanked in the view.' : 'Share mode off.'); };
|
|
795
|
+
$('#export').onclick = () => { if (!S.trace) return; download((S.trace.meta.sessionId || 'trace').slice(0, 8) + '-redacted.jsonl', C.toJsonl(C.redact(S.records)), 'application/x-ndjson'); say('Redacted transcript download started.'); };
|
|
796
|
+
|
|
797
|
+
// ------------------------------------------------------------ rates dialog
|
|
798
|
+
const rd = $('#rates-dlg');
|
|
799
|
+
const COLN = { in: 'Input', out: 'Output', w5m: 'Cache write 5m', w1h: 'Cache write 1h', read: 'Cache read' };
|
|
800
|
+
function renderRates() {
|
|
801
|
+
const cols = ['in', 'out', 'w5m', 'w1h', 'read'];
|
|
802
|
+
$('#rates-tbl').innerHTML = `<thead><tr><th scope="col">Model prefix</th>${cols.map((c) => `<th class="n" scope="col">${COLN[c]}</th>`).join('')}<th scope="col"><span class="sr-only">Remove</span></th></tr></thead><tbody>${Object.entries(S.rates).map(([m, r]) => `<tr data-m="${esc(m)}"><td class="mono">${esc(m)}</td>${cols.map((c) => `<td class="n"><input type="number" step="0.01" min="0" data-c="${c}" value="${r[c]}" aria-label="${esc(m)} ${COLN[c]} price"></td>`).join('')}<td><button class="x" data-del="${esc(m)}" aria-label="Remove ${esc(m)}">×</button></td></tr>`).join('')}</tbody>`;
|
|
803
|
+
$$('#rates-tbl [data-del]').forEach((b) => b.onclick = () => { delete S.rates[b.dataset.del]; renderRates(); });
|
|
804
|
+
}
|
|
805
|
+
$('#rates').onclick = () => { renderRates(); rd.showModal(); };
|
|
806
|
+
$('#rates-close').onclick = () => rd.close();
|
|
807
|
+
$('#rate-add').onclick = () => { const m = $('#rate-new').value.trim(); if (!m) return; S.rates[m] = { in: 0, out: 0, w5m: 0, w1h: 0, read: 0 }; $('#rate-new').value = ''; renderRates(); };
|
|
808
|
+
$('#rates-reset').onclick = () => { S.rates = JSON.parse(JSON.stringify(C.RATES)); renderRates(); };
|
|
809
|
+
$('#rates-save').onclick = () => { $$('#rates-tbl tbody tr').forEach((tr) => { const m = tr.dataset.m; if (!S.rates[m]) return; $$('input', tr).forEach((inp) => { S.rates[m][inp.dataset.c] = parseFloat(inp.value) || 0; }); }); store.set('rates', JSON.stringify(S.rates)); rd.close(); recost(); say('Rates applied.'); };
|
|
810
|
+
|
|
811
|
+
// ------------------------------------------------------------ folder picker (File System Access API)
|
|
812
|
+
const FSA = 'showDirectoryPicker' in window;
|
|
813
|
+
const idb = { open() { return new Promise((res, rej) => { const r = indexedDB.open('glassbox', 1); r.onupgradeneeded = () => r.result.createObjectStore('kv'); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); },
|
|
814
|
+
async get(k) { try { const db = await this.open(); return await new Promise((res) => { const t = db.transaction('kv').objectStore('kv').get(k); t.onsuccess = () => res(t.result); t.onerror = () => res(null); }); } catch (e) { return null; } },
|
|
815
|
+
async set(k, v) { try { const db = await this.open(); await new Promise((res) => { const t = db.transaction('kv', 'readwrite').objectStore('kv').put(v, k); t.onsuccess = res; t.onerror = res; }); } catch (e) { } } };
|
|
816
|
+
const decodeProject = (n) => n.replace(/^-/, '/').replace(/-/g, '/');
|
|
817
|
+
async function scanProjects(dir) {
|
|
818
|
+
const sessions = [];
|
|
819
|
+
for await (const [pname, ph] of dir.entries()) {
|
|
820
|
+
if (ph.kind !== 'directory') continue;
|
|
821
|
+
for await (const [fname, fh] of ph.entries()) {
|
|
822
|
+
if (fh.kind !== 'file' || !/\.jsonl$/.test(fname)) continue;
|
|
823
|
+
const f = await fh.getFile();
|
|
824
|
+
sessions.push({ project: decodeProject(pname), pdir: ph, id: fname.replace(/\.jsonl$/, ''), handle: fh, size: f.size, mtime: f.lastModified });
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
sessions.sort((a, b) => b.mtime - a.mtime);
|
|
828
|
+
return sessions;
|
|
829
|
+
}
|
|
830
|
+
async function sessionTitle(s) {
|
|
831
|
+
try { const f = await s.handle.getFile(); const head = await f.slice(0, 262144).text(); let first = null;
|
|
832
|
+
for (const line of head.split('\n')) { if (!line.trim()) continue; let r; try { r = JSON.parse(line); } catch (e) { continue; } if (r.type === 'summary' && r.summary) return r.summary; if (!first && r.type === 'user' && typeof (r.message && r.message.content) === 'string' && !/^\s*</.test(r.message.content)) first = r.message.content.replace(/\s+/g, ' ').slice(0, 90); }
|
|
833
|
+
return first || '(no prompt found)'; } catch (e) { return '(unreadable)'; }
|
|
834
|
+
}
|
|
835
|
+
async function loadSession(s) {
|
|
836
|
+
const files = [{ name: s.id + '.jsonl', text: await (await s.handle.getFile()).text() }];
|
|
837
|
+
try { const sdir = await (await s.pdir.getDirectoryHandle(s.id)).getDirectoryHandle('subagents'); for await (const [n, h] of sdir.entries()) if (h.kind === 'file' && /\.(jsonl|json)$/.test(n)) files.push({ name: s.id + '/subagents/' + n, text: await (await h.getFile()).text() }); } catch (e) { }
|
|
838
|
+
$('#sessions-dlg').close(); load(files, false);
|
|
839
|
+
}
|
|
840
|
+
async function showSessions(dir) {
|
|
841
|
+
const dlg = $('#sessions-dlg'); $('#sessions-note').textContent = 'Scanning ' + dir.name + '…'; $('#sessions-tbl').innerHTML = ''; dlg.showModal();
|
|
842
|
+
const list = (await scanProjects(dir)).slice(0, 60);
|
|
843
|
+
$('#sessions-note').textContent = list.length ? `${list.length} most recent sessions in ${dir.name}. Subagent files are picked up automatically.` : 'No .jsonl sessions found. Pick the "projects" folder inside ~/.claude.';
|
|
844
|
+
$('#sessions-tbl').innerHTML = `<thead><tr><th scope="col">When</th><th scope="col">Project</th><th scope="col">Session</th><th class="n" scope="col">Size</th></tr></thead><tbody>${list.map((s, i) => `<tr class="row" data-i="${i}" tabindex="0" role="button" aria-label="Open session ${s.id.slice(0, 8)} from ${esc(s.project)}"><td class="mono">${new Date(s.mtime).toLocaleString()}</td><td class="mono">${esc(s.project)}</td><td id="st-${i}">…</td><td class="n">${Math.round(s.size / 1024)} KB</td></tr>`).join('')}</tbody>`;
|
|
845
|
+
$$('#sessions-tbl tr.row').forEach((tr) => { const go = () => loadSession(list[+tr.dataset.i]); tr.onclick = go; tr.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } }; });
|
|
846
|
+
for (let i = 0; i < list.length; i++) { const t = await sessionTitle(list[i]); const el = $('#st-' + i); if (el) el.textContent = t; }
|
|
847
|
+
}
|
|
848
|
+
if (FSA) {
|
|
849
|
+
$('#open-folder').hidden = false;
|
|
850
|
+
$('#open-folder').onclick = async () => { try { const dir = await window.showDirectoryPicker({ id: 'claude-projects', mode: 'read' }); await idb.set('projects', dir); $('#recent').hidden = false; await showSessions(dir); } catch (e) { if (e && e.name !== 'AbortError') say('Could not open the folder: ' + e.message); } };
|
|
851
|
+
idb.get('projects').then((h) => { if (h) $('#recent').hidden = false; });
|
|
852
|
+
$('#recent').onclick = async () => { const h = await idb.get('projects'); if (!h) return; try { let p = await h.queryPermission({ mode: 'read' }); if (p !== 'granted') p = await h.requestPermission({ mode: 'read' }); if (p === 'granted') await showSessions(h); else say('Permission to read the folder was not granted.'); } catch (e) { say('Could not reopen the folder: ' + e.message); } };
|
|
853
|
+
}
|
|
854
|
+
$('#sessions-close').onclick = () => $('#sessions-dlg').close();
|
|
855
|
+
|
|
856
|
+
// ------------------------------------------------------------ help + inputs
|
|
857
|
+
$('#help').onclick = () => $('#help-dlg').showModal(); $('#help-close').onclick = () => $('#help-dlg').close();
|
|
858
|
+
$('#banner-x').onclick = () => { $('#banner').hidden = true; };
|
|
859
|
+
const drop = $('#drop');
|
|
860
|
+
drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('over'); });
|
|
861
|
+
drop.addEventListener('dragleave', () => drop.classList.remove('over'));
|
|
862
|
+
drop.addEventListener('drop', (e) => { e.preventDefault(); drop.classList.remove('over'); handleDataTransfer(e.dataTransfer); });
|
|
863
|
+
document.addEventListener('dragover', (e) => { e.preventDefault(); });
|
|
864
|
+
document.addEventListener('drop', (e) => { if (!drop.contains(e.target)) { e.preventDefault(); handleDataTransfer(e.dataTransfer); } });
|
|
865
|
+
$('#file').addEventListener('change', (e) => loadFiles(Array.from(e.target.files).map((f) => ({ name: f.name, file: f }))));
|
|
866
|
+
drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('#file').click(); } });
|
|
867
|
+
|
|
868
|
+
const DEMO = /*__DEMO__*/null;
|
|
869
|
+
const EMBED = /*__EMBED__*/null;
|
|
870
|
+
function loadDemo(e) { if (e) e.preventDefault(); if (!DEMO) { say('No demo embedded in this build.'); return; } load(DEMO.map((f) => ({ name: f.name, text: f.text })), true); }
|
|
871
|
+
$('#demo').onclick = loadDemo;
|
|
872
|
+
if (EMBED && EMBED.length) load(EMBED.map((f) => ({ name: f.name, text: f.text })), false);
|
|
873
|
+
else if (DEMO) loadDemo();
|
|
874
|
+
})();
|
|
875
|
+
</script>
|