agent-tracer 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2812 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Agent Trace</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0d1117;
10
+ --surface: #161b22;
11
+ --surface2: #1c2128;
12
+ --hover: #21262d;
13
+ --selected: #1a2d45;
14
+ --border: #30363d;
15
+ --border-dim: #21262d;
16
+ --text: #e6edf3;
17
+ --muted: #8b949e;
18
+ --dim: #6e7681;
19
+ --blue: #58a6ff;
20
+ --blue-soft: #79c0ff;
21
+ --green: #3fb950;
22
+ --yellow: #d29922;
23
+ --red: #f85149;
24
+ --purple: #c084fc;
25
+ --orange: #fb923c;
26
+ --teal: #2dd4bf;
27
+ --sky: #38bdf8;
28
+ --gold: #fbbf24;
29
+ --pink: #f472b6;
30
+ }
31
+
32
+ * { box-sizing: border-box; margin: 0; padding: 0; }
33
+
34
+ body {
35
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
36
+ background: var(--bg);
37
+ color: var(--text);
38
+ font-size: 13px;
39
+ line-height: 1.5;
40
+ -webkit-font-smoothing: antialiased;
41
+ }
42
+
43
+ code, pre, .mono { font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace; }
44
+
45
+ /* ── Header ── */
46
+ header {
47
+ position: sticky; top: 0; z-index: 10;
48
+ background: var(--surface);
49
+ border-bottom: 1px solid var(--border);
50
+ padding: 0 16px;
51
+ display: flex; align-items: center; gap: 8px;
52
+ height: 48px;
53
+ }
54
+ .brand {
55
+ display: flex; align-items: center; gap: 8px;
56
+ padding-right: 16px;
57
+ border-right: 1px solid var(--border);
58
+ flex-shrink: 0;
59
+ }
60
+ .brand-icon { font-size: 15px; }
61
+ .brand-name { font-size: 13px; font-weight: 600; color: var(--text); letter-spacing: -0.01em; }
62
+
63
+ .stats { display: flex; align-items: center; gap: 2px; flex: 1; }
64
+ .stat-pill {
65
+ display: flex; align-items: baseline; gap: 4px;
66
+ padding: 4px 10px; border-radius: 6px;
67
+ background: transparent;
68
+ transition: background 0.1s;
69
+ }
70
+ .stat-pill:hover { background: var(--hover); }
71
+ .stat-key { font-size: 11px; color: var(--dim); font-weight: 500; }
72
+ .stat-val { font-size: 12px; color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
73
+ .stat-val.cost { color: var(--green); }
74
+
75
+ .header-right { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-shrink: 0; }
76
+ .status-text { font-size: 11px; color: var(--muted); }
77
+ .pulse {
78
+ width: 8px; height: 8px; border-radius: 50%;
79
+ background: var(--dim); flex-shrink: 0;
80
+ }
81
+ .pulse.live {
82
+ background: var(--green);
83
+ box-shadow: 0 0 0 0 rgba(63,185,80,0.5);
84
+ animation: ripple 1.8s ease-out infinite;
85
+ }
86
+ @keyframes ripple {
87
+ 0% { box-shadow: 0 0 0 0 rgba(63,185,80,0.45); }
88
+ 70% { box-shadow: 0 0 0 6px rgba(63,185,80,0); }
89
+ 100% { box-shadow: 0 0 0 0 rgba(63,185,80,0); }
90
+ }
91
+ @keyframes ripple-yellow {
92
+ 0% { box-shadow: 0 0 0 0 rgba(210,153,34,0.6); }
93
+ 70% { box-shadow: 0 0 0 7px rgba(210,153,34,0); }
94
+ 100% { box-shadow: 0 0 0 0 rgba(210,153,34,0); }
95
+ }
96
+
97
+ /* ── Layout ── */
98
+ .layout { display: flex; height: calc(100vh - 48px); overflow: hidden; }
99
+
100
+ /* ── Projects panel ── */
101
+ #projects-panel {
102
+ border-bottom: 1px solid var(--border);
103
+ background: var(--surface);
104
+ flex-shrink: 0;
105
+ }
106
+ .proj-header {
107
+ display: flex; align-items: center; gap: 6px;
108
+ padding: 6px 12px; cursor: pointer; user-select: none;
109
+ font-size: 10px; font-weight: 700; text-transform: uppercase;
110
+ letter-spacing: 0.08em; color: var(--dim);
111
+ }
112
+ .proj-header:hover { color: var(--muted); }
113
+ .proj-chevron { font-size: 14px; transition: transform 0.15s; line-height: 1; }
114
+ .proj-header.collapsed .proj-chevron { transform: rotate(-90deg); }
115
+ #projects-list { overflow-y: auto; max-height: 280px; }
116
+ .proj-group { border-bottom: 1px solid var(--border-dim); }
117
+ .proj-folder-row {
118
+ display: flex; align-items: center; gap: 8px;
119
+ padding: 5px 12px; cursor: pointer; user-select: none;
120
+ font-size: 11px;
121
+ }
122
+ .proj-folder-row:hover { background: var(--hover); }
123
+ .proj-folder-name { font-weight: 600; color: var(--text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
124
+ .proj-folder-count { font-size: 9px; color: var(--dim); flex-shrink: 0; }
125
+ .proj-session-row {
126
+ display: flex; align-items: center; gap: 6px;
127
+ padding: 3px 12px 3px 28px; cursor: pointer; user-select: none;
128
+ font-size: 11px; color: var(--muted);
129
+ border-left: 2px solid transparent;
130
+ }
131
+ .proj-session-row:hover { background: var(--hover); color: var(--text); }
132
+ .proj-session-row.active { background: var(--selected); border-left-color: var(--blue); color: var(--text); }
133
+ .proj-session-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
134
+ .proj-session-cost { font-size: 9px; color: var(--green); flex-shrink: 0; font-variant-numeric: tabular-nums; }
135
+ .proj-session-age { font-size: 9px; color: var(--dim); flex-shrink: 0; font-variant-numeric: tabular-nums; }
136
+ .proj-status-dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
137
+
138
+ /* ── Tree panel ── */
139
+ #tree-panel {
140
+ width: 440px; min-width: 260px; max-width: 600px;
141
+ border-right: 1px solid var(--border);
142
+ overflow: hidden;
143
+ display: flex; flex-direction: column;
144
+ flex-shrink: 0;
145
+ }
146
+ #tree-scroll {
147
+ flex: 1; overflow-y: auto; overflow-x: hidden;
148
+ padding: 6px 0 24px;
149
+ }
150
+ .tree-placeholder {
151
+ padding: 48px 20px; text-align: center;
152
+ color: var(--muted); font-size: 12px; line-height: 2.2;
153
+ }
154
+ .tree-placeholder-icon { font-size: 26px; display: block; margin-bottom: 8px; opacity: 0.3; }
155
+
156
+ /* ── Agent group ── */
157
+ .agent-group { }
158
+ .agent-group.collapsed .tool-rows,
159
+ .agent-group.collapsed .agent-thinking,
160
+ .agent-group.collapsed .children-wrap,
161
+ .agent-group.collapsed .session-info { display: none !important; }
162
+
163
+ .chevron {
164
+ font-size: 13px; color: var(--muted); flex-shrink: 0;
165
+ margin-right: 5px; transition: transform 0.15s; display: inline-block;
166
+ width: 14px; text-align: center; line-height: 1;
167
+ }
168
+ .agent-group.collapsed .chevron { transform: rotate(-90deg); }
169
+
170
+ .session-info {
171
+ padding: 6px 12px 8px;
172
+ background: var(--surface2);
173
+ border-bottom: 1px solid var(--border-dim);
174
+ display: none;
175
+ }
176
+ .session-info.open { display: block; }
177
+ .session-info-grid {
178
+ display: grid; grid-template-columns: 80px 1fr; gap: 3px 10px;
179
+ font-size: 11px;
180
+ }
181
+ .si-key { color: var(--dim); }
182
+ .si-val { color: var(--muted); font-family: 'SF Mono','Fira Code',ui-monospace,monospace; word-break: break-all; }
183
+
184
+ .agent-row {
185
+ display: flex; align-items: center; gap: 0;
186
+ padding: 4px 10px 4px 0;
187
+ cursor: pointer; user-select: none;
188
+ border-left: 2px solid transparent;
189
+ min-height: 30px;
190
+ transition: background 0.1s;
191
+ position: relative;
192
+ }
193
+ .agent-row:hover { background: var(--hover); }
194
+ .agent-row.selected { background: var(--selected); border-left-color: var(--blue); }
195
+
196
+ /* indent spacer with guide lines */
197
+ .indent { display: flex; align-items: stretch; flex-shrink: 0; }
198
+ .guide-line {
199
+ width: 16px; height: 100%;
200
+ border-left: 1px solid var(--border-dim);
201
+ margin-left: 8px;
202
+ flex-shrink: 0;
203
+ }
204
+ .guide-stub { width: 16px; flex-shrink: 0; }
205
+
206
+ .agent-status { width: 16px; flex-shrink: 0; text-align: center; font-size: 11px; margin-right: 6px; }
207
+ .agent-status.running { color: var(--yellow); animation: spin 1.4s linear infinite; display: inline-block; }
208
+ .agent-status.done { color: var(--green); }
209
+ .agent-status.error { color: var(--red); }
210
+ .agent-status.cancelled { color: var(--dim); }
211
+ @keyframes spin { from{transform:rotate(0deg)} to{transform:rotate(360deg)} }
212
+
213
+ .agent-label { font-size: 12px; font-weight: 500; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
214
+
215
+ .agent-meta { display: flex; align-items: center; gap: 5px; flex-shrink: 0; margin-left: 6px; }
216
+ .tok-badge {
217
+ font-size: 10px; color: var(--dim);
218
+ display: flex; align-items: center; gap: 2px;
219
+ font-variant-numeric: tabular-nums;
220
+ }
221
+ .tok-icon { font-size: 9px; }
222
+ .dur-pill {
223
+ font-size: 10px; color: var(--muted);
224
+ background: var(--surface2); border: 1px solid var(--border-dim);
225
+ padding: 1px 5px; border-radius: 4px;
226
+ font-variant-numeric: tabular-nums;
227
+ }
228
+
229
+ /* ── Tool rows ── */
230
+ .tool-rows { }
231
+
232
+ .tool-row {
233
+ display: flex; align-items: center; gap: 0;
234
+ padding: 2px 8px 2px 0;
235
+ cursor: pointer; user-select: none;
236
+ border-left: 2px solid transparent;
237
+ min-height: 22px;
238
+ transition: background 0.1s;
239
+ }
240
+ .tool-row:hover { background: var(--hover); }
241
+ .tool-row.selected { background: var(--selected); border-left-color: var(--blue-soft); }
242
+
243
+ .tool-status { width: 14px; flex-shrink: 0; text-align: center; font-size: 10px; margin-right: 6px; }
244
+ .tool-status.done { color: var(--green); }
245
+ .tool-status.running { color: var(--yellow); animation: spin 1.4s linear infinite; display: inline-block; }
246
+
247
+ /* Type badge */
248
+ .type-badge {
249
+ font-size: 9px; font-weight: 700; letter-spacing: 0.05em;
250
+ padding: 1px 5px; border-radius: 4px;
251
+ flex-shrink: 0; margin-right: 7px;
252
+ text-transform: uppercase;
253
+ border: 1px solid transparent;
254
+ }
255
+ .tb-agent { background: #2d1b4e; color: var(--purple); border-color: #5a3a80; }
256
+ .tb-bash { background: #0d2918; color: var(--green); border-color: #1a5c30; }
257
+ .tb-read { background: #0a2236; color: var(--sky); border-color: #1a4a6e; }
258
+ .tb-write { background: #2d1a0a; color: var(--orange); border-color: #6b3a18; }
259
+ .tb-edit { background: #2a1f0a; color: var(--gold); border-color: #5a4210; }
260
+ .tb-search { background: #1a1a2e; color: var(--blue-soft); border-color: #2a3a6e; }
261
+ .tb-web { background: #0a2030; color: var(--teal); border-color: #1a4a50; }
262
+ .tb-compact { background: #0a2a28; color: #5eead4; border-color: #1a5050; }
263
+ .tb-default { background: var(--surface2); color: var(--muted); border-color: var(--border-dim); }
264
+
265
+ .tool-summary-text { font-size: 11px; color: var(--muted); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
266
+ .tool-dur-text { font-size: 10px; color: var(--dim); flex-shrink: 0; margin-left: 4px; font-variant-numeric: tabular-nums; }
267
+
268
+ /* thinking */
269
+ .agent-thinking {
270
+ font-size: 11px; color: var(--dim); font-style: italic;
271
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
272
+ padding: 2px 10px 5px 0;
273
+ animation: breathe 2.4s ease-in-out infinite;
274
+ }
275
+ @keyframes breathe { 0%,100%{opacity:0.4} 50%{opacity:0.85} }
276
+
277
+ /* ── Detail panel ── */
278
+ #detail-panel {
279
+ flex: 1; overflow-y: auto; overflow-x: hidden;
280
+ padding: 0;
281
+ background: var(--bg);
282
+ }
283
+ .detail-empty {
284
+ height: 100%; display: flex; flex-direction: column;
285
+ align-items: center; justify-content: center;
286
+ gap: 10px; color: var(--muted); font-size: 12px;
287
+ }
288
+ .detail-empty-icon { font-size: 28px; opacity: 0.2; }
289
+
290
+ .detail-body { padding: 24px 28px; }
291
+
292
+ .detail-title {
293
+ font-size: 15px; font-weight: 600; color: var(--text);
294
+ margin-bottom: 20px; display: flex; align-items: center; gap: 10px;
295
+ padding-bottom: 14px; border-bottom: 1px solid var(--border-dim);
296
+ }
297
+ .detail-title .type-badge { font-size: 10px; padding: 2px 7px; }
298
+
299
+ .section { margin-bottom: 24px; }
300
+ .section-label {
301
+ font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em;
302
+ color: var(--dim); font-weight: 600; margin-bottom: 10px;
303
+ display: flex; align-items: center; gap: 8px;
304
+ }
305
+ .section-label::after { content: ''; flex: 1; height: 1px; background: var(--border-dim); }
306
+
307
+ .prop-grid { display: grid; grid-template-columns: 100px 1fr; row-gap: 8px; column-gap: 16px; }
308
+ .prop-key { font-size: 12px; color: var(--muted); }
309
+ .prop-val { font-size: 12px; color: var(--text); word-break: break-all; }
310
+ .prop-val.green { color: var(--green); }
311
+ .prop-val.yellow { color: var(--yellow); }
312
+ .prop-val.red { color: var(--red); }
313
+ .prop-val.blue { color: var(--blue-soft); }
314
+ .prop-val.mono { font-family: 'SF Mono', 'Fira Code', ui-monospace, monospace; }
315
+
316
+ .status-chip {
317
+ display: inline-flex; align-items: center; gap: 5px;
318
+ padding: 2px 8px; border-radius: 5px; font-size: 11px; font-weight: 600;
319
+ border: 1px solid transparent;
320
+ }
321
+ .status-chip.running { background: #2d2006; color: var(--yellow); border-color: #5a4010; }
322
+ .status-chip.done { background: #0d2118; color: var(--green); border-color: #1a4a2e; }
323
+ .status-chip.error { background: #2d0f0f; color: var(--red); border-color: #5a1a1a; }
324
+ .status-chip.cancelled { background: var(--surface2); color: var(--muted); border-color: var(--border-dim); }
325
+
326
+ .code-block {
327
+ background: var(--surface); border: 1px solid var(--border);
328
+ border-radius: 8px; padding: 14px;
329
+ overflow-x: auto; overflow-y: auto;
330
+ white-space: pre; /* preserve formatting, don't wrap */
331
+ font-family: 'SF Mono', 'Fira Code', ui-monospace, monospace;
332
+ font-size: 11.5px; color: var(--text);
333
+ max-height: 380px; line-height: 1.6;
334
+ tab-size: 2;
335
+ }
336
+
337
+ /* JSON syntax highlighting */
338
+ .jk { color: #79c0ff; } /* key */
339
+ .js { color: #a5d6ff; } /* string value */
340
+ .jn { color: #ffa657; } /* number */
341
+ .jb { color: #56d364; } /* boolean */
342
+ .jz { color: var(--muted); } /* null */
343
+
344
+ /* Payload field layout */
345
+ .payload-field { margin-bottom: 14px; }
346
+ .payload-field:last-child { margin-bottom: 0; }
347
+ .payload-field-label {
348
+ font-size: 10px; color: var(--dim); text-transform: uppercase;
349
+ letter-spacing: 0.08em; font-weight: 600; margin-bottom: 5px;
350
+ }
351
+ .payload-text {
352
+ white-space: pre-wrap; word-break: break-word;
353
+ font-size: 12px; color: var(--text); line-height: 1.65;
354
+ background: var(--surface); border: 1px solid var(--border);
355
+ border-radius: 8px; padding: 12px 14px;
356
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
357
+ max-height: 320px; overflow-y: auto;
358
+ }
359
+ .payload-path {
360
+ font-family: 'SF Mono', 'Fira Code', ui-monospace, monospace;
361
+ font-size: 11.5px; color: var(--teal);
362
+ background: var(--surface); border: 1px solid var(--border);
363
+ border-radius: 6px; padding: 7px 12px;
364
+ word-break: break-all; white-space: pre-wrap;
365
+ }
366
+ .payload-diff-old {
367
+ background: #3f0d0d; border: 1px solid #7f1d1d;
368
+ border-radius: 8px; padding: 12px 14px;
369
+ white-space: pre-wrap; word-break: break-word;
370
+ font-family: 'SF Mono', 'Fira Code', ui-monospace, monospace;
371
+ font-size: 11.5px; color: #fca5a5; line-height: 1.6;
372
+ max-height: 260px; overflow-y: auto;
373
+ }
374
+ .payload-diff-new {
375
+ background: #0d2a12; border: 1px solid #14532d;
376
+ border-radius: 8px; padding: 12px 14px;
377
+ white-space: pre-wrap; word-break: break-word;
378
+ font-family: 'SF Mono', 'Fira Code', ui-monospace, monospace;
379
+ font-size: 11.5px; color: #86efac; line-height: 1.6;
380
+ max-height: 260px; overflow-y: auto;
381
+ }
382
+
383
+ /* Copy button */
384
+ .code-wrap { position: relative; }
385
+ .code-copy {
386
+ position: absolute; top: 8px; right: 8px;
387
+ background: var(--surface2); border: 1px solid var(--border-dim);
388
+ color: var(--muted); font-size: 10px; padding: 3px 9px;
389
+ border-radius: 4px; cursor: pointer; opacity: 0;
390
+ transition: opacity 0.15s, color 0.15s; user-select: none;
391
+ }
392
+ .code-wrap:hover .code-copy { opacity: 1; }
393
+ .code-copy.copied { color: var(--teal); border-color: var(--teal); }
394
+
395
+ .tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border-dim); margin-bottom: 12px; }
396
+ .tab {
397
+ padding: 6px 14px; font-size: 12px; font-weight: 500;
398
+ color: var(--muted); cursor: pointer; border-bottom: 2px solid transparent;
399
+ margin-bottom: -1px; transition: color 0.1s;
400
+ }
401
+ .tab:hover { color: var(--text); }
402
+ .tab.active { color: var(--blue); border-bottom-color: var(--blue); }
403
+ .tab-body { display: none; }
404
+ .tab-body.active { display: block; }
405
+
406
+ .token-bar {
407
+ display: flex; gap: 8px; flex-wrap: wrap; margin-top: 2px;
408
+ }
409
+ .token-item {
410
+ display: flex; align-items: center; gap: 5px;
411
+ padding: 4px 10px; border-radius: 6px;
412
+ background: var(--surface2); border: 1px solid var(--border-dim);
413
+ }
414
+ .token-item-icon { font-size: 11px; color: var(--blue-soft); }
415
+ .token-item-label { font-size: 10px; color: var(--dim); }
416
+ .token-item-val { font-size: 12px; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
417
+
418
+ /* ── Summary popover ── */
419
+ #summary-popover {
420
+ display: none;
421
+ position: fixed; top: 48px; left: 0; right: 0; bottom: 0;
422
+ z-index: 50;
423
+ }
424
+ #summary-popover.open { display: flex; }
425
+ .summary-backdrop {
426
+ position: absolute; inset: 0;
427
+ background: rgba(0,0,0,0.4);
428
+ }
429
+ .summary-panel {
430
+ position: relative; z-index: 1;
431
+ width: 420px; background: var(--surface);
432
+ border-right: 1px solid var(--border);
433
+ overflow-y: auto; padding: 20px;
434
+ animation: slideIn 0.15s ease;
435
+ }
436
+ @keyframes slideIn { from{transform:translateX(-12px);opacity:0} to{transform:none;opacity:1} }
437
+ .summary-title {
438
+ font-size: 13px; font-weight: 600; color: var(--text);
439
+ margin-bottom: 16px; display: flex; justify-content: space-between; align-items: center;
440
+ }
441
+ .summary-close { cursor: pointer; color: var(--muted); font-size: 16px; line-height: 1; }
442
+ .summary-close:hover { color: var(--text); }
443
+ .summary-row {
444
+ display: flex; align-items: center; justify-content: space-between;
445
+ padding: 7px 0; border-bottom: 1px solid var(--border-dim);
446
+ font-size: 12px;
447
+ }
448
+ .summary-row:last-child { border-bottom: none; }
449
+ .summary-row-label { color: var(--muted); }
450
+ .summary-row-val { font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
451
+ .summary-row-val.green { color: var(--green); }
452
+ .summary-row-val.blue { color: var(--blue-soft); }
453
+
454
+ /* ── Nav tabs (below header) ── */
455
+ .nav-tabs {
456
+ display: flex; gap: 0;
457
+ background: var(--surface); border-bottom: 1px solid var(--border);
458
+ padding: 0 16px;
459
+ }
460
+ .nav-tab {
461
+ padding: 8px 14px; font-size: 12px; font-weight: 500;
462
+ color: var(--muted); cursor: pointer;
463
+ border-bottom: 2px solid transparent; margin-bottom: -1px;
464
+ transition: color 0.1s;
465
+ }
466
+ .nav-tab:hover { color: var(--text); }
467
+ .nav-tab.active { color: var(--blue); border-bottom-color: var(--blue); }
468
+
469
+ /* ── History sidebar ── */
470
+ #history-panel {
471
+ width: 260px; flex-shrink: 0;
472
+ border-right: 1px solid var(--border);
473
+ overflow-y: auto; padding: 6px 0 24px;
474
+ }
475
+ .history-session {
476
+ padding: 8px 14px; cursor: pointer;
477
+ border-left: 2px solid transparent;
478
+ transition: background 0.1s;
479
+ }
480
+ .history-session:hover { background: var(--hover); }
481
+ .history-session.active { background: var(--selected); border-left-color: var(--blue); }
482
+ .history-session-label { font-size: 12px; font-weight: 500; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
483
+ .history-session-meta { display: flex; gap: 8px; margin-top: 2px; }
484
+ .history-session-date { font-size: 10px; color: var(--dim); }
485
+ .history-session-cost { font-size: 10px; color: var(--green); }
486
+ .history-status-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; margin-top: 3px; }
487
+ .history-status-dot.running { background: var(--yellow); }
488
+ .history-status-dot.done { background: var(--green); }
489
+ .history-status-dot.error { background: var(--red); }
490
+ .history-section-header { padding: 10px 14px 4px; font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--dim); font-weight: 600; }
491
+
492
+ /* ── Permissions panel ── */
493
+ #permissions-view {
494
+ flex: 1; overflow-y: auto; padding: 24px 28px;
495
+ display: none;
496
+ }
497
+ #permissions-view.active { display: block; }
498
+ .perm-section { margin-bottom: 28px; }
499
+ .perm-group { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
500
+ .perm-chip {
501
+ display: inline-flex; align-items: center; gap: 5px;
502
+ padding: 3px 10px; border-radius: 5px;
503
+ font-size: 11px; font-family: 'SF Mono', 'Fira Code', ui-monospace, monospace;
504
+ border: 1px solid transparent;
505
+ }
506
+ .perm-chip.allow { background: #0d2118; color: var(--green); border-color: #1a4a2e; }
507
+ .perm-chip.deny { background: #2d0f0f; color: var(--red); border-color: #5a1a1a; }
508
+ .perm-chip.ask { background: #2d2006; color: var(--yellow); border-color: #5a4010; }
509
+ .perm-chip.mode { background: var(--surface2); color: var(--blue-soft); border-color: var(--border); }
510
+ .perm-empty { color: var(--dim); font-size: 12px; font-style: italic; margin-top: 6px; }
511
+ .perm-tool-row {
512
+ display: flex; align-items: baseline; gap: 10px;
513
+ padding: 6px 0; border-bottom: 1px solid var(--border-dim);
514
+ }
515
+ .perm-tool-name { font-size: 12px; font-weight: 600; color: var(--text); min-width: 80px; }
516
+ .perm-tool-count { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
517
+
518
+ /* ── Page views ── */
519
+ #trace-view { display: flex; flex: 1; overflow: hidden; }
520
+ #trace-view.hidden { display: none; }
521
+
522
+ /* scrollbar */
523
+ ::-webkit-scrollbar { width: 5px; height: 5px; }
524
+ ::-webkit-scrollbar-track { background: transparent; }
525
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
526
+ ::-webkit-scrollbar-thumb:hover { background: #484f58; }
527
+
528
+ /* ── Search bar ── */
529
+ .tree-search-wrap {
530
+ padding: 8px 10px 4px;
531
+ border-bottom: 1px solid var(--border-dim);
532
+ position: sticky; top: 0; z-index: 2;
533
+ background: var(--bg);
534
+ }
535
+ .tree-search {
536
+ width: 100%; background: var(--surface2); border: 1px solid var(--border-dim);
537
+ border-radius: 6px; padding: 5px 10px 5px 28px;
538
+ color: var(--text); font-size: 12px; outline: none;
539
+ transition: border-color 0.15s;
540
+ font-family: inherit;
541
+ }
542
+ .tree-search:focus { border-color: var(--blue); }
543
+ .tree-search-icon {
544
+ position: absolute; left: 18px; top: 50%; transform: translateY(-50%);
545
+ color: var(--dim); font-size: 12px; pointer-events: none;
546
+ }
547
+ .tree-search-wrap { position: relative; }
548
+
549
+ /* ── Graph overlay (slides in from right) ── */
550
+ #graph-overlay {
551
+ display: none; width: 0; flex-shrink: 0; overflow: hidden;
552
+ transition: width 0.2s ease;
553
+ flex-direction: row;
554
+ }
555
+ #graph-overlay.open {
556
+ display: flex; width: 420px;
557
+ }
558
+ #graph-resize-handle {
559
+ width: 5px; flex-shrink: 0; cursor: col-resize;
560
+ background: transparent;
561
+ border-left: 1px solid var(--border-dim);
562
+ transition: background 0.1s;
563
+ }
564
+ #graph-resize-handle:hover { background: var(--border); }
565
+ .graph-backdrop { display: none; }
566
+ .graph-panel {
567
+ display: flex; flex-direction: column;
568
+ flex: 1; min-width: 0;
569
+ height: 100%; background: var(--surface);
570
+ border-left: 1px solid var(--border);
571
+ }
572
+ .graph-header {
573
+ display: flex; align-items: center; justify-content: space-between;
574
+ padding: 12px 16px; border-bottom: 1px solid var(--border-dim);
575
+ flex-shrink: 0;
576
+ }
577
+ .graph-title { font-size: 13px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
578
+ .graph-close { color: var(--muted); cursor: pointer; font-size: 16px; line-height: 1; padding: 2px 6px; border-radius: 4px; }
579
+ .graph-close:hover { background: var(--hover); color: var(--text); }
580
+ #graph-svg-wrap {
581
+ flex: 1; overflow: auto; padding: 24px;
582
+ }
583
+ #graph-svg-wrap svg {
584
+ display: block; min-width: 100%;
585
+ }
586
+
587
+ /* graph node styles */
588
+ .gnode { cursor: pointer; }
589
+ .gnode rect {
590
+ fill: var(--surface2); stroke: var(--border); stroke-width: 1;
591
+ rx: 6;
592
+ }
593
+ .gnode.running rect { stroke: var(--yellow); }
594
+ .gnode.done rect { stroke: var(--green); }
595
+ .gnode.error rect { stroke: var(--red); }
596
+ .gnode.selected rect { fill: var(--selected); stroke: var(--blue); stroke-width: 2; }
597
+ .gnode.worktree rect { stroke: var(--teal); stroke-dasharray: 5 3; fill: #0d1f1f; }
598
+ .gnode.sidechain rect { stroke: var(--purple); stroke-dasharray: 4 3; fill: #140d1f; }
599
+ .gedge { fill: none; stroke: var(--border); stroke-width: 1.5; }
600
+ .gedge.worktree { stroke: var(--teal); stroke-dasharray: 5 3; }
601
+ .gedge.sidechain { stroke: var(--purple); stroke-dasharray: 4 3; }
602
+
603
+ /* ── Thread view ── */
604
+ .thread-msg { margin-bottom: 12px; }
605
+ .thread-msg.assistant { padding-left: 12px; border-left: 2px solid var(--border-dim); }
606
+ .thread-role { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
607
+ .thread-role.user { color: var(--blue-soft); }
608
+ .thread-role.assistant { color: var(--purple); }
609
+ .thread-text { font-size: 12px; color: var(--muted); white-space: pre-wrap; word-break: break-word; line-height: 1.6; }
610
+
611
+ /* graph zoom buttons */
612
+ .graph-zoom-btn {
613
+ display: inline-flex; align-items: center; justify-content: center;
614
+ width: 22px; height: 22px; border-radius: 4px; cursor: pointer;
615
+ font-size: 14px; color: var(--muted); line-height: 1;
616
+ transition: background 0.1s, color 0.1s;
617
+ }
618
+ .graph-zoom-btn:hover { background: var(--hover); color: var(--text); }
619
+
620
+ /* graph tooltip */
621
+ #graph-tooltip {
622
+ position: fixed; z-index: 200; pointer-events: none;
623
+ background: var(--surface); border: 1px solid var(--border);
624
+ border-radius: 8px; padding: 10px 12px;
625
+ font-size: 11px; color: var(--text);
626
+ box-shadow: 0 4px 16px rgba(0,0,0,0.5);
627
+ max-width: 240px; display: none;
628
+ line-height: 1.6;
629
+ }
630
+ #graph-tooltip.visible { display: block; }
631
+
632
+ /* graph btn in header */
633
+ .graph-btn {
634
+ display: flex; align-items: center; gap: 5px;
635
+ padding: 4px 10px; border-radius: 6px; cursor: pointer;
636
+ font-size: 11px; font-weight: 600; color: var(--muted);
637
+ background: var(--surface2); border: 1px solid var(--border-dim);
638
+ transition: all 0.1s; white-space: nowrap;
639
+ }
640
+ .graph-btn:hover { color: var(--text); border-color: var(--border); }
641
+ .graph-btn.active { color: var(--blue); border-color: var(--blue); background: #0a1e36; }
642
+ </style>
643
+ </head>
644
+ <body>
645
+
646
+ <header>
647
+ <div class="brand">
648
+ <span class="brand-icon">◈</span>
649
+ <span class="brand-name">Agent Trace</span>
650
+ </div>
651
+ <div class="stats">
652
+ <div class="stat-pill" onclick="openSummary('agents')" style="cursor:pointer"><span class="stat-key">Agents</span><span class="stat-val" id="s-agents">0</span></div>
653
+ <div class="stat-pill" onclick="openSummary('tools')" style="cursor:pointer"><span class="stat-key">Tool Calls</span><span class="stat-val" id="s-tools">0</span></div>
654
+ <div class="stat-pill" onclick="openSummary('tokens')" style="cursor:pointer"><span class="stat-key">In</span><span class="stat-val" id="s-in">0</span></div>
655
+ <div class="stat-pill" onclick="openSummary('tokens')" style="cursor:pointer"><span class="stat-key">Out</span><span class="stat-val" id="s-out">0</span></div>
656
+ <div class="stat-pill" onclick="openSummary('tokens')" style="cursor:pointer"><span class="stat-key">Cache</span><span class="stat-val" id="s-cache">0</span></div>
657
+ <div class="stat-pill" onclick="openSummary('cost')" style="cursor:pointer"><span class="stat-key">Cost</span><span class="stat-val cost" id="s-cost">$0.0000</span></div>
658
+ </div>
659
+ <div class="header-right">
660
+ <div class="graph-btn" id="graph-btn" onclick="toggleGraph()">⬡ Graph</div>
661
+ <div class="pulse" id="pulse"></div>
662
+ </div>
663
+ </header>
664
+
665
+ <!-- Graph overlay -->
666
+ <!-- Summary popover -->
667
+ <div id="summary-popover">
668
+ <div class="summary-backdrop" onclick="closeSummary()"></div>
669
+ <div class="summary-panel" id="summary-panel-content"></div>
670
+ </div>
671
+
672
+ <div class="nav-tabs">
673
+ <div class="nav-tab active" onclick="showView('trace')">Trace</div>
674
+ <div class="nav-tab" onclick="showView('history')">History</div>
675
+ <div class="nav-tab" onclick="showView('permissions')">Permissions</div>
676
+ </div>
677
+
678
+ <div class="layout" style="height:calc(100vh - 84px)">
679
+ <!-- Trace view -->
680
+ <div id="trace-view" style="display:flex;flex:1;overflow:hidden">
681
+ <div id="tree-panel" style="display:flex;flex-direction:column;overflow:hidden">
682
+ <!-- Projects panel -->
683
+ <div id="projects-panel">
684
+ <div class="proj-header" id="proj-header" onclick="toggleProjects()">
685
+ <span class="proj-chevron" id="proj-chevron">▾</span>
686
+ <span>Projects</span>
687
+ <span id="proj-count" style="margin-left:4px"></span>
688
+ </div>
689
+ <div id="projects-list" style="display:none"></div>
690
+ </div>
691
+
692
+ <!-- Search + tree -->
693
+ <div style="flex:1;overflow-y:auto;overflow-x:hidden;padding:6px 0 24px" id="tree-scroll">
694
+ <div class="tree-search-wrap">
695
+ <span class="tree-search-icon">⌕</span>
696
+ <input class="tree-search" id="tree-search" type="text" placeholder="Filter agents and tools…" oninput="applySearch(this.value)">
697
+ </div>
698
+ <div class="tree-placeholder" id="tree-placeholder">
699
+ <span class="tree-placeholder-icon">◌</span>
700
+ Waiting for session…
701
+ <br><span style="color:var(--dim);font-size:11px">Run Claude Code to start</span>
702
+ </div>
703
+ </div>
704
+ </div>
705
+ <div id="panel-resize-handle" style="width:5px;cursor:col-resize;flex-shrink:0;background:transparent;border-right:1px solid var(--border-dim);transition:background 0.1s" onmouseenter="this.style.background='var(--border)'" onmouseleave="this.style.background='transparent'"></div>
706
+ <div id="detail-panel">
707
+ <div class="detail-empty">
708
+ <div class="detail-empty-icon">⊡</div>
709
+ <div>Select an agent or tool call</div>
710
+ </div>
711
+ </div>
712
+ </div>
713
+
714
+ <!-- History view -->
715
+ <div id="history-view" style="display:none;flex:1;overflow:hidden">
716
+ <div id="history-panel">
717
+ <div class="history-section-header">Past Sessions</div>
718
+ <div id="history-list"><div style="padding:20px 14px;color:var(--dim);font-size:12px">Loading…</div></div>
719
+ </div>
720
+ <div id="history-detail" style="flex:1;overflow-y:auto;padding:24px 28px">
721
+ <div class="detail-empty"><div class="detail-empty-icon">⊡</div><div>Select a session</div></div>
722
+ </div>
723
+ </div>
724
+
725
+ <!-- Permissions view -->
726
+ <div id="permissions-view" style="display:none;flex:1;overflow-y:auto;padding:24px 28px;flex-direction:column">
727
+ <div style="font-size:15px;font-weight:600;margin-bottom:20px;padding-bottom:14px;border-bottom:1px solid var(--border-dim)">Permissions Overview</div>
728
+ <div id="permissions-content"><div style="color:var(--dim);font-size:12px">Loading…</div></div>
729
+ </div>
730
+
731
+ <!-- Graph side panel — sits inside layout as a flex sibling, pushes content left -->
732
+ <div id="graph-overlay">
733
+ <div id="graph-resize-handle"></div>
734
+ <div class="graph-panel">
735
+ <div class="graph-header">
736
+ <span class="graph-title">⬡ Agent Graph</span>
737
+ <div style="display:flex;align-items:center;gap:4px">
738
+ <span class="graph-zoom-btn" onclick="graphZoom(0.2)" title="Zoom in">+</span>
739
+ <span class="graph-zoom-btn" onclick="graphZoom(-0.2)" title="Zoom out">−</span>
740
+ <span class="graph-zoom-btn" onclick="graphFit()" title="Fit to view">⊡</span>
741
+ <span class="graph-close" onclick="closeGraph()">✕</span>
742
+ </div>
743
+ </div>
744
+ <div id="graph-svg-wrap" style="position:relative"></div>
745
+ <div id="graph-tooltip"></div>
746
+ </div>
747
+ </div>
748
+ </div>
749
+
750
+ <script>
751
+ // ── State ──────────────────────────────────────────────────────────────────
752
+ let selected = null; // { type: 'agent'|'tool', sessionId, toolId? }
753
+ let lastRoot = null;
754
+ let lastRoots = []; // all roots from last SSE — needed to find nodes in non-primary roots
755
+ let raf = null;
756
+
757
+ // DOM element maps for flicker-free diffing
758
+ const agentEls = new Map(); // sessionId → { group, row, toolRows, thinking, childrenWrap }
759
+ const toolEls = new Map(); // `${sessionId}:${toolId}` → rowEl
760
+
761
+ // ── SSE ──────────────────────────────────────────────────────────────────
762
+ let es;
763
+ function connect() {
764
+ if (es) { es.close(); es = null; }
765
+ es = new EventSource('/events');
766
+ es.onmessage = e => {
767
+ const msg = JSON.parse(e.data);
768
+ if (msg.disconnected) { markCancelled(); setStatus('done'); return; }
769
+ const { root, roots, totals } = msg;
770
+ lastRoots = roots || (root ? [root] : []);
771
+ lastRoot = lastRoots[0] || null;
772
+ if (raf) cancelAnimationFrame(raf);
773
+ raf = requestAnimationFrame(() => {
774
+ updateHeader(totals);
775
+ patchTree(lastRoot);
776
+ if (selected) renderDetail();
777
+ setStatus(lastRoot?.status === 'running' ? 'live' : 'done');
778
+ maybeAutoOpenGraph(lastRoot);
779
+ if (searchQuery) applySearchToTree(lastRoot);
780
+ scheduleProjectRefresh();
781
+ });
782
+ };
783
+ es.onerror = () => {
784
+ setStatus('done');
785
+ markCancelled();
786
+ es.close();
787
+ setTimeout(connect, 3000);
788
+ };
789
+ }
790
+
791
+ function setStatus(state) {
792
+ const pulse = document.getElementById('pulse');
793
+ if (pulse) pulse.className = state === 'live' ? 'pulse live' : 'pulse';
794
+ }
795
+
796
+ function markCancelled() {
797
+ if (!lastRoot) return;
798
+ function walk(n) {
799
+ if (!n) return;
800
+ if (n.status === 'running') { n.status = 'cancelled'; n.endedAt = Date.now(); }
801
+ for (const tc of n.toolCalls) { if (!tc.done) tc.done = true; }
802
+ for (const c of (n.children||[])) walk(c);
803
+ }
804
+ walk(lastRoot);
805
+ patchTree(lastRoot);
806
+ if (selected) renderDetail();
807
+ }
808
+
809
+ connect();
810
+ loadProjects(); // load project list on startup
811
+
812
+ // ── Event delegation (replaces unsafe inline onclick string interpolation) ─
813
+ document.addEventListener('click', e => {
814
+ // Summary rows → navigate to agent
815
+ const gotoEl = e.target.closest('[data-goto-sid]');
816
+ if (gotoEl) { closeSummary(); showView('trace'); selectAgent(gotoEl.dataset.gotoSid); return; }
817
+ // Graph nodes → select agent
818
+ const graphEl = e.target.closest('[data-graph-sid]');
819
+ if (graphEl) { selectAgent(graphEl.dataset.graphSid); renderGraph(); return; }
820
+ // Thread reload button
821
+ const reloadEl = e.target.closest('[data-reload-thread]');
822
+ if (reloadEl) { threadCache.delete(reloadEl.dataset.reloadThread); loadThread(reloadEl.dataset.reloadThread, reloadEl.dataset.threadContainer); return; }
823
+ // History session rows
824
+ const histEl = e.target.closest('[data-history-sid]');
825
+ if (histEl) { loadHistorySession(histEl.dataset.historySid); return; }
826
+ // Log filter chips
827
+ const chipEl = e.target.closest('.log-filter-chip[data-filter-name]');
828
+ if (chipEl) {
829
+ applyLogFilter(chipEl.dataset.filterName, chipEl.dataset.logList, chipEl);
830
+ return;
831
+ }
832
+ });
833
+
834
+ // ── Panel resize handle ───────────────────────────────────────────────────
835
+ (function() {
836
+ const handle = document.getElementById('panel-resize-handle');
837
+ const treePanel = document.getElementById('tree-panel');
838
+ if (!handle || !treePanel) return;
839
+ let dragging = false, startX = 0, startW = 0;
840
+ handle.addEventListener('mousedown', e => {
841
+ dragging = true;
842
+ startX = e.clientX;
843
+ startW = treePanel.offsetWidth;
844
+ document.body.style.cursor = 'col-resize';
845
+ document.body.style.userSelect = 'none';
846
+ });
847
+ document.addEventListener('mousemove', e => {
848
+ if (!dragging) return;
849
+ const w = Math.max(180, Math.min(700, startW + (e.clientX - startX)));
850
+ treePanel.style.width = w + 'px';
851
+ treePanel.style.minWidth = w + 'px';
852
+ treePanel.style.maxWidth = w + 'px';
853
+ });
854
+ document.addEventListener('mouseup', () => {
855
+ if (!dragging) return;
856
+ dragging = false;
857
+ document.body.style.cursor = '';
858
+ document.body.style.userSelect = '';
859
+ });
860
+ })();
861
+
862
+ // ── Graph resize handle ───────────────────────────────────────────────────
863
+ (function() {
864
+ const handle = document.getElementById('graph-resize-handle');
865
+ const overlay = document.getElementById('graph-overlay');
866
+ if (!handle || !overlay) return;
867
+ let dragging = false, startX = 0, startW = 0;
868
+ handle.addEventListener('mousedown', e => {
869
+ dragging = true;
870
+ startX = e.clientX;
871
+ startW = overlay.offsetWidth;
872
+ document.body.style.cursor = 'col-resize';
873
+ document.body.style.userSelect = 'none';
874
+ e.preventDefault();
875
+ });
876
+ document.addEventListener('mousemove', e => {
877
+ if (!dragging) return;
878
+ // Dragging left = larger graph (startX - e.clientX increases width)
879
+ const w = Math.max(260, Math.min(900, startW + (startX - e.clientX)));
880
+ overlay.style.width = w + 'px';
881
+ overlay.style.transition = 'none';
882
+ });
883
+ document.addEventListener('mouseup', () => {
884
+ if (!dragging) return;
885
+ dragging = false;
886
+ document.body.style.cursor = '';
887
+ document.body.style.userSelect = '';
888
+ overlay.style.transition = '';
889
+ });
890
+ })();
891
+
892
+ // ── Header ────────────────────────────────────────────────────────────────
893
+ function updateHeader(t) {
894
+ setText('s-agents', t.agents);
895
+ setText('s-tools', t.tools);
896
+ setText('s-in', fmt(t.tokensIn));
897
+ setText('s-out', fmt(t.tokensOut));
898
+ setText('s-cache', fmt(t.cacheRead));
899
+ setText('s-cost', '$' + t.cost.toFixed(4));
900
+ }
901
+ function setText(id, v) {
902
+ const el = document.getElementById(id);
903
+ if (el && el.textContent !== String(v)) el.textContent = v;
904
+ }
905
+
906
+ // ── Formatters ────────────────────────────────────────────────────────────
907
+ function fmt(n) { return n >= 1000 ? (n/1000).toFixed(1)+'k' : String(n); }
908
+ function dur(ms) { return ms >= 1000 ? (ms/1000).toFixed(1)+'s' : ms+'ms'; }
909
+ function esc(s) {
910
+ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
911
+ }
912
+ function toolBadgeClass(name) {
913
+ if (name === '__compact__') return 'tb-compact';
914
+ if (name === 'Agent' || name === 'Task') return 'tb-agent';
915
+ if (name === 'Bash') return 'tb-bash';
916
+ if (name === 'Read') return 'tb-read';
917
+ if (name === 'Write') return 'tb-write';
918
+ if (name === 'Edit' || name === 'NotebookEdit') return 'tb-edit';
919
+ if (name === 'Grep' || name === 'Glob') return 'tb-search';
920
+ if (name === 'WebSearch' || name === 'WebFetch') return 'tb-web';
921
+ return 'tb-default';
922
+ }
923
+ function toolBadgeLabel(name, summary) {
924
+ if (name === '__compact__') return 'COMPACT';
925
+ if (name === 'Bash' && summary) {
926
+ // Extract the actual CLI tool from the command
927
+ const cmd = summary.trim().split(/\s+/)[0].replace(/^[!$]/, '');
928
+ const known = { npm:'NPM', npx:'NPX', node:'NODE', git:'GIT', curl:'CURL',
929
+ python:'PY', python3:'PY', pip:'PIP', pip3:'PIP',
930
+ brew:'BREW', ls:'LS', cat:'CAT', echo:'ECHO', cp:'CP',
931
+ mv:'MV', rm:'RM', mkdir:'MKDIR', find:'FIND', grep:'GREP',
932
+ sqlite3:'SQL', jq:'JQ', launchctl:'LCTL', open:'OPEN',
933
+ bash:'BASH', sh:'SH', zsh:'ZSH', code:'CODE' };
934
+ const base = cmd.split('/').pop();
935
+ return known[base] || known[cmd] || cmd.slice(0,5).toUpperCase() || 'BASH';
936
+ }
937
+ const map = { Agent:'AGENT', Task:'TASK', Bash:'BASH', Read:'READ', Write:'WRITE',
938
+ Edit:'EDIT', NotebookEdit:'NB', Grep:'GREP', Glob:'GLOB',
939
+ WebSearch:'WEB', WebFetch:'FETCH' };
940
+ return map[name] || name.slice(0,4).toUpperCase();
941
+ }
942
+ function statusIcon(s) {
943
+ return s === 'running' ? '◌' : s === 'done' ? '✓' : s === 'error' ? '✗' : '○';
944
+ }
945
+
946
+ // ── Tree: flicker-free patch ──────────────────────────────────────────────
947
+ function patchTree(root) {
948
+ const panel = document.getElementById('tree-scroll');
949
+ const placeholder = document.getElementById('tree-placeholder');
950
+
951
+ if (!root) {
952
+ if (placeholder) placeholder.style.display = '';
953
+ return;
954
+ }
955
+ if (placeholder) placeholder.style.display = 'none';
956
+
957
+ // Walk the tree and upsert every node
958
+ const seen = new Set();
959
+ walkNode(root, panel, 0, seen);
960
+
961
+ // Remove stale agents no longer in tree
962
+ for (const [sid, els] of agentEls) {
963
+ if (!seen.has(sid)) {
964
+ els.group.remove();
965
+ agentEls.delete(sid);
966
+ }
967
+ }
968
+ }
969
+
970
+ function walkNode(node, container, depth, seen) {
971
+ seen.add(node.sessionId);
972
+ const key = node.sessionId;
973
+
974
+ let els = agentEls.get(key);
975
+ if (!els) {
976
+ els = createAgentEls(node, depth);
977
+ container.appendChild(els.group);
978
+ agentEls.set(key, els);
979
+ }
980
+
981
+ // Patch agent row in-place
982
+ patchAgentRow(els, node, depth);
983
+
984
+ // Patch tool rows
985
+ patchToolRows(els, node, depth);
986
+
987
+ // Patch thinking line
988
+ patchThinking(els, node, depth);
989
+
990
+ // Recurse into children — they live inside els.childrenWrap
991
+ for (const child of (node.children || [])) {
992
+ walkNode(child, els.childrenWrap, depth + 1, seen);
993
+ }
994
+ }
995
+
996
+ // Track collapsed state per session (persists across re-renders)
997
+ const collapsedSessions = new Set();
998
+
999
+ function createAgentEls(node, depth) {
1000
+ const group = document.createElement('div');
1001
+ group.className = 'agent-group';
1002
+ group.dataset.sid = node.sessionId;
1003
+
1004
+ const row = document.createElement('div');
1005
+ row.className = 'agent-row';
1006
+ row.innerHTML = agentRowHTML(node, depth);
1007
+ // Left-click selects; chevron click (first 20px) toggles collapse
1008
+ row.onclick = (e) => {
1009
+ const x = e.clientX - row.getBoundingClientRect().left;
1010
+ const chevronEnd = 12 + depth * 16 + 28; // indent + chevron width
1011
+ if (x < chevronEnd) {
1012
+ toggleCollapse(node.sessionId, group);
1013
+ } else {
1014
+ selectAgent(node.sessionId);
1015
+ }
1016
+ };
1017
+ group.appendChild(row);
1018
+
1019
+ // Session info strip (hidden by default)
1020
+ const sessionInfo = document.createElement('div');
1021
+ sessionInfo.className = 'session-info';
1022
+ sessionInfo.innerHTML = sessionInfoHTML(node);
1023
+ group.appendChild(sessionInfo);
1024
+
1025
+ const toolRows = document.createElement('div');
1026
+ toolRows.className = 'tool-rows';
1027
+ group.appendChild(toolRows);
1028
+
1029
+ const thinking = document.createElement('div');
1030
+ thinking.className = 'agent-thinking';
1031
+ thinking.style.display = 'none';
1032
+ group.appendChild(thinking);
1033
+
1034
+ const childrenWrap = document.createElement('div');
1035
+ childrenWrap.className = 'children-wrap';
1036
+ group.appendChild(childrenWrap);
1037
+
1038
+ return { group, row, toolRows, thinking, childrenWrap, sessionInfo };
1039
+ }
1040
+
1041
+ function toggleCollapse(sessionId, group) {
1042
+ if (collapsedSessions.has(sessionId)) {
1043
+ collapsedSessions.delete(sessionId);
1044
+ group.classList.remove('collapsed');
1045
+ // Show session info when expanding
1046
+ group.querySelector('.session-info')?.classList.add('open');
1047
+ } else {
1048
+ collapsedSessions.add(sessionId);
1049
+ group.classList.add('collapsed');
1050
+ }
1051
+ }
1052
+
1053
+ function sessionInfoHTML(node) {
1054
+ const started = new Date(node.startedAt).toLocaleString();
1055
+ const ended = node.endedAt ? new Date(node.endedAt).toLocaleTimeString() : '—';
1056
+ const cost = node.costUsd > 0 ? `$${node.costUsd.toFixed(4)}` : '—';
1057
+ const elapsed = node.endedAt ? dur(node.endedAt - node.startedAt) : dur(Date.now() - node.startedAt) + ' (running)';
1058
+ const toks = node.tokens.input + node.tokens.output;
1059
+ const rows = [
1060
+ ['Session ID', `<span style="word-break:break-all">${esc(node.sessionId)}</span>`],
1061
+ ['Status', node.status],
1062
+ ['Started', started],
1063
+ ['Duration', elapsed],
1064
+ ['Cost', `<span style="color:var(--green)">${cost}</span>`],
1065
+ toks > 0 ? ['Tokens', `↑${fmt(node.tokens.input)} ↓${fmt(node.tokens.output)} ◎${fmt(node.tokens.cacheRead)}`] : null,
1066
+ node.permissionMode ? ['Mode', esc(node.permissionMode)] : null,
1067
+ node.entrypoint ? ['Entrypoint', esc(node.entrypoint)] : null,
1068
+ node.version ? ['Version', `v${esc(node.version)}`] : null,
1069
+ node.gitBranch ? ['Branch', esc(node.gitBranch)] : null,
1070
+ node.cwd ? ['CWD', `<span style="font-size:10px;word-break:break-all">${esc(node.cwd)}</span>`] : null,
1071
+ ].filter(Boolean);
1072
+ return `<div class="session-info-grid">${rows.map(([k,v]) =>
1073
+ `<span class="si-key">${k}</span><span class="si-val">${v}</span>`
1074
+ ).join('')}</div>`;
1075
+ }
1076
+
1077
+ const MODE_BADGE = {
1078
+ default: { label: 'default', color: 'var(--dim)', bg: 'var(--surface2)', border: 'var(--border-dim)' },
1079
+ acceptEdits: { label: 'auto-edit', color: 'var(--blue-soft)', bg: '#0a1e36', border: '#1a3a60' },
1080
+ auto: { label: 'auto', color: 'var(--yellow)', bg: '#2d2006', border: '#5a4010' },
1081
+ bypassPermissions: { label: '⚡ bypass', color: 'var(--red)', bg: '#2d0f0f', border: '#5a1a1a' },
1082
+ plan: { label: 'plan', color: 'var(--purple)', bg: '#2d1b4e', border: '#5a3a80' },
1083
+ };
1084
+
1085
+ function agentRowHTML(node, depth) {
1086
+ const indent = indentHTML(depth);
1087
+ const chevron = `<span class="chevron">▾</span>`;
1088
+
1089
+ // Status dot: pulsing for running, solid for ended
1090
+ const dotColor = node.status === 'running' ? 'var(--yellow)'
1091
+ : node.status === 'done' ? 'var(--green)'
1092
+ : node.status === 'error' ? 'var(--red)' : 'var(--dim)';
1093
+ const dotAnim = node.status === 'running' ? 'animation:ripple-yellow 1.8s ease-out infinite' : '';
1094
+ const dot = `<span style="width:7px;height:7px;border-radius:50%;background:${dotColor};flex-shrink:0;${dotAnim}"></span>`;
1095
+
1096
+ const label = `<span class="agent-label">${esc(node.label)}</span>`;
1097
+
1098
+ // Type badge: worktree / sidechain / permission mode
1099
+ let typeBadge = '';
1100
+ if (node.isWorktree) {
1101
+ typeBadge = `<span style="font-size:9px;font-weight:700;padding:1px 5px;border-radius:3px;background:#0d1f1f;color:var(--teal);border:1px solid #1a4040;flex-shrink:0;letter-spacing:0.04em">⎇ worktree</span>`;
1102
+ } else if (node.isSidechain) {
1103
+ typeBadge = `<span style="font-size:9px;font-weight:700;padding:1px 5px;border-radius:3px;background:#140d1f;color:var(--purple);border:1px solid #3a1f5a;flex-shrink:0;letter-spacing:0.04em">⊕ inline</span>`;
1104
+ } else {
1105
+ const m = MODE_BADGE[node.permissionMode] || (node.permissionMode ? { label: esc(node.permissionMode), color: 'var(--muted)', bg: 'var(--surface2)', border: 'var(--border-dim)' } : null);
1106
+ if (m) typeBadge = `<span style="font-size:9px;font-weight:700;padding:1px 5px;border-radius:3px;background:${m.bg};color:${m.color};border:1px solid ${m.border};flex-shrink:0;letter-spacing:0.04em">${m.label}</span>`;
1107
+ }
1108
+
1109
+ const toks = (node.tokens.input + node.tokens.output > 0)
1110
+ ? `<span class="tok-badge"><span class="tok-icon">◎</span>${fmt(node.tokens.input + node.tokens.output)}</span>` : '';
1111
+ const d = node.endedAt ? `<span class="dur-pill">${dur(node.endedAt - node.startedAt)}</span>` : '';
1112
+ const cost = node.costUsd > 0 ? `<span class="dur-pill" style="color:var(--green)">$${node.costUsd.toFixed(3)}</span>` : '';
1113
+ const startTs = node.startedAt ? `<span style="font-size:9px;color:var(--dim);flex-shrink:0;font-variant-numeric:tabular-nums">${new Date(node.startedAt).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'})}</span>` : '';
1114
+ const meta = `<span class="agent-meta">${typeBadge}${toks}${d}${cost}${startTs}</span>`;
1115
+ return indent + chevron + dot + `<span style="width:6px;flex-shrink:0"></span>` + label + meta;
1116
+ }
1117
+
1118
+ function indentHTML(depth) {
1119
+ if (depth === 0) return '<span class="guide-stub"></span>';
1120
+ let html = '<span class="indent">';
1121
+ for (let i = 0; i < depth; i++) {
1122
+ html += `<span class="guide-line"></span>`;
1123
+ }
1124
+ html += '</span>';
1125
+ return html;
1126
+ }
1127
+
1128
+ function patchAgentRow(els, node, depth) {
1129
+ const row = els.row;
1130
+ const isSelected = selected?.type === 'agent' && selected?.sessionId === node.sessionId;
1131
+ const cls = 'agent-row' + (isSelected ? ' selected' : '');
1132
+ if (row.className !== cls) row.className = cls;
1133
+
1134
+ // Rebuild innerHTML only when key fields change — track via data attrs
1135
+ const newStatus = node.status;
1136
+ const newLabel = node.label;
1137
+ const newToks = fmt(node.tokens.input + node.tokens.output);
1138
+ const newDur = node.endedAt ? dur(node.endedAt - node.startedAt) : '';
1139
+
1140
+ const newCost = node.costUsd > 0 ? node.costUsd.toFixed(4) : '';
1141
+ const newMode = node.permissionMode || '';
1142
+ // For running sessions use current elapsed time so it always updates
1143
+ const newElapsed = node.status === 'running' ? String(Math.floor((Date.now() - node.startedAt) / 1000)) : '';
1144
+ if (row.dataset.status !== newStatus || row.dataset.label !== newLabel ||
1145
+ row.dataset.toks !== newToks || row.dataset.dur !== newDur ||
1146
+ row.dataset.depth !== String(depth) || row.dataset.cost !== newCost ||
1147
+ row.dataset.mode !== newMode || row.dataset.elapsed !== newElapsed) {
1148
+ row.innerHTML = agentRowHTML(node, depth);
1149
+ row.onclick = (e) => {
1150
+ const x = e.clientX - row.getBoundingClientRect().left;
1151
+ const chevronEnd = 12 + depth * 16 + 28;
1152
+ if (x < chevronEnd) toggleCollapse(node.sessionId, els.group);
1153
+ else selectAgent(node.sessionId);
1154
+ };
1155
+ row.dataset.status = newStatus;
1156
+ row.dataset.label = newLabel;
1157
+ row.dataset.toks = newToks;
1158
+ row.dataset.dur = newDur;
1159
+ row.dataset.depth = depth;
1160
+ row.dataset.cost = newCost;
1161
+ row.dataset.mode = newMode;
1162
+ row.dataset.elapsed = newElapsed;
1163
+ // Refresh session info strip
1164
+ if (els.sessionInfo) els.sessionInfo.innerHTML = sessionInfoHTML(node);
1165
+ }
1166
+ // Apply collapse state
1167
+ els.group.classList.toggle('collapsed', collapsedSessions.has(node.sessionId));
1168
+ }
1169
+
1170
+ function patchToolRows(els, node, depth) {
1171
+ const container = els.toolRows;
1172
+ const toolIndent = depth + 1; // tools are visually one level deeper
1173
+
1174
+ const seenTools = new Set();
1175
+
1176
+ // Interleave tool calls and compaction events by timestamp — newest first
1177
+ // Only show compacts that occurred during (or after) this session, not pre-session backfills
1178
+ const sessionStart = node.startedAt || 0;
1179
+ const items = [
1180
+ ...node.toolCalls.map(tc => ({ kind: 'tool', data: tc, ts: tc.startedAt || 0 })),
1181
+ ...(node.compactions || []).filter(c => c.timestamp >= sessionStart).map(c => ({ kind: 'compact', data: c, ts: c.timestamp })),
1182
+ ].sort((a, b) => b.ts - a.ts); // descending = newest first
1183
+
1184
+ for (const item of items) {
1185
+ if (item.kind === 'compact') {
1186
+ const c = item.data;
1187
+ const tkey = `${node.sessionId}:${c.id}`;
1188
+ seenTools.add(tkey);
1189
+
1190
+ let rowEl = toolEls.get(tkey);
1191
+ if (!rowEl) {
1192
+ rowEl = document.createElement('div');
1193
+ rowEl.className = 'tool-row';
1194
+ rowEl.dataset.tkey = tkey;
1195
+ rowEl.dataset.ts = String(item.ts);
1196
+ rowEl.onclick = e => { e.stopPropagation(); selectCompact(node.sessionId, c.id); };
1197
+ toolEls.set(tkey, rowEl);
1198
+ }
1199
+ patchCompactRow(rowEl, node, c, toolIndent);
1200
+ } else {
1201
+ const tc = item.data;
1202
+ const tkey = `${node.sessionId}:${tc.id}`;
1203
+ seenTools.add(tkey);
1204
+
1205
+ let rowEl = toolEls.get(tkey);
1206
+ if (!rowEl) {
1207
+ rowEl = document.createElement('div');
1208
+ rowEl.className = 'tool-row';
1209
+ rowEl.dataset.tkey = tkey;
1210
+ rowEl.dataset.ts = String(item.ts);
1211
+ rowEl.onclick = e => { e.stopPropagation(); selectTool(node.sessionId, tc.id); };
1212
+ toolEls.set(tkey, rowEl);
1213
+ }
1214
+ patchToolRow(rowEl, node, tc, toolIndent);
1215
+ }
1216
+ }
1217
+
1218
+ // Sync DOM order to match sorted items (newest first)
1219
+ for (const item of items) {
1220
+ const kind = item.kind;
1221
+ const id = kind === 'compact' ? item.data.id : item.data.id;
1222
+ const tkey = `${node.sessionId}:${id}`;
1223
+ const rowEl = toolEls.get(tkey);
1224
+ if (rowEl) container.appendChild(rowEl); // move to end = last processed = oldest; reverse by prepending
1225
+ }
1226
+ // Re-order: prepend in reverse so newest ends up at top
1227
+ const orderedKeys = items.map(i => `${node.sessionId}:${i.data.id}`);
1228
+ for (let i = orderedKeys.length - 1; i >= 0; i--) {
1229
+ const rowEl = toolEls.get(orderedKeys[i]);
1230
+ if (rowEl) container.prepend(rowEl);
1231
+ }
1232
+
1233
+ // Remove stale tools/compacts from this node's container
1234
+ for (const [tkey, rowEl] of toolEls) {
1235
+ if (tkey.startsWith(node.sessionId + ':') && !seenTools.has(tkey)) {
1236
+ rowEl.remove();
1237
+ toolEls.delete(tkey);
1238
+ }
1239
+ }
1240
+ }
1241
+
1242
+ function patchCompactRow(rowEl, node, c, depth) {
1243
+ const isSelected = selected?.type === 'compact' &&
1244
+ selected?.sessionId === node.sessionId &&
1245
+ selected?.compactId === c.id;
1246
+ const cls = 'tool-row' + (isSelected ? ' selected' : '');
1247
+ if (rowEl.className !== cls) rowEl.className = cls;
1248
+
1249
+ if (rowEl.dataset.cid !== c.id) {
1250
+ const indent = indentHTML(depth);
1251
+ const savings = (c.tokensBefore && c.tokensAfter)
1252
+ ? `−${fmt(c.tokensBefore - c.tokensAfter)} tok ${fmt(c.tokensBefore)}→${fmt(c.tokensAfter)}`
1253
+ : c.tokensBefore
1254
+ ? `${fmt(c.tokensBefore)} tok compacted`
1255
+ : 'context compacted';
1256
+ const cts = c.timestamp ? new Date(c.timestamp).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '';
1257
+ rowEl.innerHTML = `${indent}<span class="tool-status done" style="color:var(--teal)">↺</span>` +
1258
+ `<span class="type-badge tb-compact">COMPACT</span>` +
1259
+ `<span class="tool-summary-text" style="color:var(--teal);opacity:0.8">${esc(savings)}</span>` +
1260
+ `${cts ? `<span style="font-size:9px;color:var(--dim);flex-shrink:0;margin-left:5px;font-variant-numeric:tabular-nums">${cts}</span>` : ''}`;
1261
+ rowEl.dataset.cid = c.id;
1262
+ rowEl.dataset.depth = depth;
1263
+ }
1264
+ }
1265
+
1266
+ // Extract a compact display string from a tool summary (strip long paths to basename)
1267
+ function shortSummary(name, summary) {
1268
+ if (!summary) return '';
1269
+ // File-based tools: show just the filename
1270
+ if (['Read','Write','Edit','NotebookEdit'].includes(name)) {
1271
+ const base = summary.replace(/\\/g,'/').split('/').pop();
1272
+ return base || summary;
1273
+ }
1274
+ // Bash: strip leading path prefixes, keep command meaningful
1275
+ if (name === 'Bash') {
1276
+ return summary
1277
+ .replace(/\/Users\/[^\s]+?\/(Desktop|home|Documents)\/[^\s/]+\//g, '…/')
1278
+ .slice(0, 72);
1279
+ }
1280
+ // Grep/Glob: truncate pattern
1281
+ if (name === 'Grep' || name === 'Glob') return summary.slice(0, 50);
1282
+ return summary.slice(0, 60);
1283
+ }
1284
+
1285
+ function patchToolRow(rowEl, node, tc, depth) {
1286
+ const isSelected = selected?.type === 'tool' &&
1287
+ selected?.sessionId === node.sessionId &&
1288
+ selected?.toolId === tc.id;
1289
+ const cls = 'tool-row' + (isSelected ? ' selected' : '');
1290
+ if (rowEl.className !== cls) rowEl.className = cls;
1291
+
1292
+ // If session has ended but tool was never marked done (daemon restart / missed PostToolUse),
1293
+ // treat it as done in the UI to avoid infinite spinner.
1294
+ const effectiveDone = tc.done || (node.status !== 'running');
1295
+ const newDone = String(effectiveDone);
1296
+ const newSummary = tc.summary;
1297
+ const newDur = tc.durationMs ? dur(tc.durationMs) : '';
1298
+
1299
+ if (rowEl.dataset.done !== newDone || rowEl.dataset.summary !== newSummary ||
1300
+ rowEl.dataset.dur !== newDur || rowEl.dataset.depth !== String(depth)) {
1301
+ const indent = indentHTML(depth);
1302
+ const statusCls = effectiveDone ? 'done' : 'running';
1303
+ const statusChar = effectiveDone ? '✓' : '◌';
1304
+ const badge = toolBadgeClass(tc.name);
1305
+ const badgeTxt = toolBadgeLabel(tc.name, tc.summary);
1306
+ const short = shortSummary(tc.name, tc.summary);
1307
+ const ts = tc.startedAt ? new Date(tc.startedAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '';
1308
+ const isDestructive = tc.name === 'Bash' && tc.summary &&
1309
+ /\brm\s+-[a-zA-Z]*[rf]|\bDROP\s+(?:TABLE|DATABASE)\b|\bDELETE\s+FROM\b|\bTRUNCATE\s+(?:TABLE\b)?|\bgit\s+reset\s+--hard\b|\bgit\s+push\b[^|&;]*-(?:-force\b|f\b)|\bgit\s+branch\s+-[Dd]\b|\bkill\s+-9\b|\b(?:pkill|wipefs|mkfs|shred)\b|\bdd\s+if=/i.test(tc.summary);
1310
+ const summaryStyle = isDestructive ? 'color:var(--red)' : '';
1311
+ rowEl.innerHTML = `${indent}<span class="tool-status ${statusCls}">${statusChar}</span>` +
1312
+ `<span class="type-badge ${badge}"${isDestructive ? ' style="background:#2d0f0f;color:var(--red);border-color:#5a1a1a"' : ''}>${badgeTxt}</span>` +
1313
+ `<span class="tool-summary-text"${summaryStyle ? ` style="${summaryStyle}"` : ''} title="${esc(tc.summary)}">${esc(short)}</span>` +
1314
+ `<span class="tool-dur-text">${newDur}</span>` +
1315
+ `${ts ? `<span style="font-size:9px;color:var(--dim);flex-shrink:0;margin-left:4px;font-variant-numeric:tabular-nums">${ts}</span>` : ''}`;
1316
+ rowEl.dataset.done = newDone;
1317
+ rowEl.dataset.summary = newSummary;
1318
+ rowEl.dataset.dur = newDur;
1319
+ rowEl.dataset.depth = depth;
1320
+ }
1321
+ }
1322
+
1323
+ function patchThinking(els, node, depth) {
1324
+ const t = els.thinking;
1325
+ if (node.lastText && node.status === 'running') {
1326
+ const txt = '▸ ' + node.lastText.slice(-120).replace(/\n/g, ' ');
1327
+ if (t.dataset.txt !== txt) {
1328
+ t.textContent = txt;
1329
+ t.dataset.txt = txt;
1330
+ const padLeft = (12 + depth * 16 + 20) + 'px';
1331
+ t.style.paddingLeft = padLeft;
1332
+ }
1333
+ t.style.display = '';
1334
+ } else {
1335
+ t.style.display = 'none';
1336
+ }
1337
+ }
1338
+
1339
+ // ── Selection ─────────────────────────────────────────────────────────────
1340
+ function selectAgent(sessionId) {
1341
+ selected = { type: 'agent', sessionId };
1342
+ patchTree(lastRoot);
1343
+ renderDetail();
1344
+ }
1345
+
1346
+ function selectTool(sessionId, toolId) {
1347
+ selected = { type: 'tool', sessionId, toolId };
1348
+ patchTree(lastRoot);
1349
+ renderDetail();
1350
+ }
1351
+
1352
+ function selectCompact(sessionId, compactId) {
1353
+ selected = { type: 'compact', sessionId, compactId };
1354
+ patchTree(lastRoot);
1355
+ renderDetail();
1356
+ }
1357
+
1358
+ function applyLogFilter(name, listId, chipEl) {
1359
+ const list = document.getElementById(listId);
1360
+ if (!list) return;
1361
+ const filterBar = chipEl.parentElement;
1362
+ const clearBtn = document.getElementById(filterBar?.id + '-clear') ||
1363
+ filterBar?.parentElement?.querySelector('[id$="-clear"]');
1364
+
1365
+ // Toggle: clicking same chip again clears filter
1366
+ const isActive = chipEl.dataset.active === '1';
1367
+ // Reset all chips
1368
+ filterBar?.querySelectorAll('.log-filter-chip').forEach(c => {
1369
+ c.dataset.active = '0';
1370
+ c.style.opacity = '0.55';
1371
+ c.style.outline = 'none';
1372
+ });
1373
+ if (isActive) {
1374
+ // Clear — show all
1375
+ list.querySelectorAll('.log-row').forEach(r => r.style.display = '');
1376
+ if (clearBtn) clearBtn.style.display = 'none';
1377
+ filterBar?.querySelectorAll('.log-filter-chip').forEach(c => c.style.opacity = '1');
1378
+ } else {
1379
+ // Apply filter
1380
+ chipEl.dataset.active = '1';
1381
+ chipEl.style.opacity = '1';
1382
+ chipEl.style.outline = '2px solid currentColor';
1383
+ list.querySelectorAll('.log-row').forEach(r => {
1384
+ r.style.display = r.dataset.toolName === name ? '' : 'none';
1385
+ });
1386
+ if (clearBtn) clearBtn.style.display = '';
1387
+ }
1388
+ }
1389
+
1390
+ function clearLogFilter(filterId, listId) {
1391
+ const list = document.getElementById(listId);
1392
+ if (list) list.querySelectorAll('.log-row').forEach(r => r.style.display = '');
1393
+ const filterBar = document.getElementById(filterId);
1394
+ if (filterBar) {
1395
+ filterBar.querySelectorAll('.log-filter-chip').forEach(c => {
1396
+ c.dataset.active = '0'; c.style.opacity = '1'; c.style.outline = 'none';
1397
+ });
1398
+ }
1399
+ const clearBtn = document.getElementById(filterId + '-clear');
1400
+ if (clearBtn) clearBtn.style.display = 'none';
1401
+ }
1402
+
1403
+ function findNode(root, sessionId) {
1404
+ if (!root) return null;
1405
+ if (root.sessionId === sessionId) return root;
1406
+ for (const c of (root.children || [])) {
1407
+ const f = findNode(c, sessionId);
1408
+ if (f) return f;
1409
+ }
1410
+ return null;
1411
+ }
1412
+ // Search across all roots (for history / non-primary sessions)
1413
+ function findNodeAcrossRoots(sessionId) {
1414
+ for (const r of lastRoots) {
1415
+ const f = findNode(r, sessionId);
1416
+ if (f) return f;
1417
+ }
1418
+ return null;
1419
+ }
1420
+
1421
+ // ── Detail panel ──────────────────────────────────────────────────────────
1422
+ function renderDetail() {
1423
+ const panel = document.getElementById('detail-panel');
1424
+ if (!selected || !lastRoot) {
1425
+ panel.innerHTML = '<div class="detail-empty"><div class="detail-empty-icon">⊡</div><div>Select an agent or tool call</div></div>';
1426
+ return;
1427
+ }
1428
+ const node = findNode(lastRoot, selected.sessionId) || findNodeAcrossRoots(selected.sessionId);
1429
+ if (!node) return;
1430
+
1431
+ if (selected.type === 'agent') {
1432
+ panel.innerHTML = '<div class="detail-body">' + agentDetailHTML(node) + '</div>';
1433
+ // Restore or auto-load thread; invalidate cache for live sessions
1434
+ const threadTabId = `thread-tab-${node.sessionId}`;
1435
+ if (node.status === 'running') threadCache.delete(node.sessionId);
1436
+ if (threadCache.has(node.sessionId)) {
1437
+ const threadEl = document.getElementById(threadTabId);
1438
+ if (threadEl) threadEl.innerHTML = threadCache.get(node.sessionId);
1439
+ } else {
1440
+ loadThread(node.sessionId, threadTabId);
1441
+ }
1442
+ } else if (selected.type === 'compact') {
1443
+ const c = (node.compactions || []).find(x => x.id === selected.compactId);
1444
+ if (c) panel.innerHTML = '<div class="detail-body">' + compactDetailHTML(node, c) + '</div>';
1445
+ } else {
1446
+ const tc = node.toolCalls.find(t => t.id === selected.toolId);
1447
+ if (tc) panel.innerHTML = '<div class="detail-body">' + toolDetailHTML(node, tc) + '</div>';
1448
+ }
1449
+ }
1450
+
1451
+ function agentDetailHTML(node) {
1452
+ const elapsed = node.endedAt
1453
+ ? dur(node.endedAt - node.startedAt)
1454
+ : dur(Date.now() - node.startedAt) + ' (running)';
1455
+
1456
+ const badgeCls = toolBadgeClass('Agent');
1457
+ const threadTabId = `thread-tab-${node.sessionId}`;
1458
+
1459
+ const toolRows = node.toolCalls.map(tc => {
1460
+ const bc = toolBadgeClass(tc.name);
1461
+ const bl = toolBadgeLabel(tc.name, tc.summary);
1462
+ const d = tc.durationMs ? `<span style="color:var(--green);margin-left:4px">${dur(tc.durationMs)}</span>` : '';
1463
+ return `<div style="display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border-dim)">
1464
+ <span class="type-badge ${bc}" style="flex-shrink:0">${bl}</span>
1465
+ <span style="font-size:12px;color:var(--muted);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(tc.summary)}</span>
1466
+ ${d}
1467
+ </div>`;
1468
+ }).join('');
1469
+
1470
+ // Project folder
1471
+ const projectFolder = node.cwd ? (() => {
1472
+ const parts = node.cwd.replace(/\\/g, '/').split('/').filter(Boolean);
1473
+ const name = parts[parts.length - 1] || node.cwd;
1474
+ const parent = parts.length > 1 ? parts[parts.length - 2] : '';
1475
+ return { name, parent, full: node.cwd };
1476
+ })() : null;
1477
+
1478
+ // Type-based summary for logs filter
1479
+ const toolTypeMap = new Map();
1480
+ for (const tc of node.toolCalls) {
1481
+ toolTypeMap.set(tc.name, (toolTypeMap.get(tc.name) || 0) + 1);
1482
+ }
1483
+ const sortedTypes = [...toolTypeMap.entries()].sort((a,b) => b[1]-a[1]);
1484
+ const logFilterId = `log-filter-${node.sessionId}`;
1485
+ const logListId = `log-list-${node.sessionId}`;
1486
+
1487
+ const typeFilterChips = sortedTypes.map(([name, count]) => {
1488
+ const bc = toolBadgeClass(name);
1489
+ const bl = toolBadgeLabel(name, '');
1490
+ return `<span class="log-filter-chip ${bc}" data-filter-name="${esc(name)}" data-log-list="${logListId}"
1491
+ style="cursor:pointer;font-size:9px;font-weight:700;padding:2px 7px;border-radius:4px;letter-spacing:0.05em;user-select:none"
1492
+ title="Filter to ${esc(name)}">${bl} <span style="opacity:0.7">${count}</span></span>`;
1493
+ }).join('');
1494
+
1495
+ const logRows = node.toolCalls.map((tc, i) => {
1496
+ const bc = toolBadgeClass(tc.name);
1497
+ const bl = toolBadgeLabel(tc.name, tc.summary);
1498
+ const d = tc.durationMs ? `<span style="font-size:9px;color:var(--green);flex-shrink:0">${dur(tc.durationMs)}</span>` : '';
1499
+ const ts = tc.startedAt ? new Date(tc.startedAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '';
1500
+ const isDestructive = tc.name === 'Bash' && tc.summary &&
1501
+ /\brm\s+-[a-zA-Z]*[rf]|\bDROP\s+|\bDELETE\s+FROM\b|\bTRUNCATE\b|\bgit\s+reset\s+--hard|\bgit\s+push\b[^|&;]*-(?:-force\b|f\b)|\bkill\s+-9\b|\b(?:pkill|wipefs|mkfs|shred)\b/i.test(tc.summary);
1502
+ const statusDot = tc.done
1503
+ ? `<span style="width:5px;height:5px;border-radius:50%;background:${isDestructive?'var(--red)':'var(--green)'};flex-shrink:0"></span>`
1504
+ : `<span style="width:5px;height:5px;border-radius:50%;background:var(--yellow);flex-shrink:0;animation:ripple 1.4s ease-out infinite"></span>`;
1505
+ const summaryColor = isDestructive ? 'color:var(--red)' : 'color:var(--muted)';
1506
+ return `<div class="log-row" data-tool-name="${esc(tc.name)}" data-log-list="${logListId}"
1507
+ style="display:flex;align-items:center;gap:6px;padding:4px 8px;border-bottom:1px solid var(--border-dim);min-height:0;cursor:pointer"
1508
+ onclick="selectTool('${esc(node.sessionId)}','${esc(tc.id)}')">
1509
+ <span style="font-size:9px;color:var(--dim);width:20px;text-align:right;flex-shrink:0;font-variant-numeric:tabular-nums">${i+1}</span>
1510
+ ${statusDot}
1511
+ <span class="type-badge ${bc}" style="font-size:8px;padding:1px 4px;flex-shrink:0;border-radius:3px${isDestructive?';background:#2d0f0f;color:var(--red);border-color:#5a1a1a':''}">${bl}</span>
1512
+ <span style="font-size:11px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;${summaryColor}" title="${esc(tc.summary)}">${esc(tc.summary)}</span>
1513
+ ${d}
1514
+ ${ts ? `<span style="font-size:9px;color:var(--dim);flex-shrink:0;font-variant-numeric:tabular-nums">${ts}</span>` : ''}
1515
+ </div>`;
1516
+ }).join('');
1517
+
1518
+ return `
1519
+ <div class="detail-title">
1520
+ <span class="type-badge ${badgeCls}">AGENT</span>
1521
+ ${esc(node.label)}
1522
+ </div>
1523
+
1524
+ <div class="section">
1525
+ <div class="section-label">Overview</div>
1526
+ <div class="prop-grid">
1527
+ <span class="prop-key">Status</span>
1528
+ <span class="prop-val"><span class="status-chip ${node.status}">${node.status}</span></span>
1529
+ <span class="prop-key">Duration</span>
1530
+ <span class="prop-val">${elapsed}</span>
1531
+ <span class="prop-key">Cost</span>
1532
+ <span class="prop-val green">$${node.costUsd.toFixed(4)}</span>
1533
+ ${node.isSidechain ? `<span class="prop-key">Type</span><span class="prop-val" style="color:var(--purple)">⊕ Inline sidechain</span>` : ''}
1534
+ ${node.isWorktree ? `<span class="prop-key">Type</span><span class="prop-val" style="color:var(--teal)">⎇ Worktree</span>` : ''}
1535
+ ${node.permissionMode && !node.isSidechain && !node.isWorktree ? `<span class="prop-key">Mode</span><span class="prop-val">${esc(node.permissionMode)}</span>` : ''}
1536
+ ${node.gitBranch ? `<span class="prop-key">Branch</span><span class="prop-val mono" style="color:var(--green)">⎇ ${esc(node.gitBranch)}</span>` : ''}
1537
+ <span class="prop-key mono" style="font-size:11px">Session ID</span>
1538
+ <span class="prop-val mono" style="font-size:10px;color:var(--dim);word-break:break-all">${esc(node.sessionId)}</span>
1539
+ </div>
1540
+ </div>
1541
+
1542
+ ${projectFolder ? `
1543
+ <div class="section">
1544
+ <div class="section-label">Project</div>
1545
+ <div style="display:flex;align-items:flex-start;gap:10px;padding:10px 12px;background:var(--surface2);border:1px solid var(--border-dim);border-radius:8px">
1546
+ <span style="font-size:18px;line-height:1;flex-shrink:0;margin-top:1px">📁</span>
1547
+ <div style="flex:1;min-width:0">
1548
+ <div style="font-size:13px;font-weight:600;color:var(--text)">${esc(projectFolder.name)}</div>
1549
+ <div style="font-size:10px;color:var(--dim);margin-top:2px;word-break:break-all" title="${esc(projectFolder.full)}">${esc(projectFolder.full)}</div>
1550
+ ${node.entrypoint ? `<div style="font-size:10px;color:var(--muted);margin-top:4px">⎋ ${esc(node.entrypoint)}${node.version ? ` <span style="color:var(--dim)">v${esc(node.version)}</span>` : ''}</div>` : ''}
1551
+ </div>
1552
+ </div>
1553
+ </div>` : ''}
1554
+
1555
+ <div class="section">
1556
+ <div class="section-label">Tokens</div>
1557
+ <div class="token-bar">
1558
+ <div class="token-item">
1559
+ <span class="token-item-icon">↑</span>
1560
+ <span class="token-item-label">In</span>
1561
+ <span class="token-item-val">${fmt(node.tokens.input)}</span>
1562
+ </div>
1563
+ <div class="token-item">
1564
+ <span class="token-item-icon">↓</span>
1565
+ <span class="token-item-label">Out</span>
1566
+ <span class="token-item-val">${fmt(node.tokens.output)}</span>
1567
+ </div>
1568
+ <div class="token-item">
1569
+ <span class="token-item-icon">◎</span>
1570
+ <span class="token-item-label">Cache</span>
1571
+ <span class="token-item-val">${fmt(node.tokens.cacheRead)}</span>
1572
+ </div>
1573
+ </div>
1574
+ </div>
1575
+
1576
+ ${(node.compactions || []).length ? (() => {
1577
+ const sessionStart = node.startedAt || 0;
1578
+ const preCompacts = (node.compactions || []).filter(c => c.timestamp < sessionStart);
1579
+ const inSession = (node.compactions || []).filter(c => c.timestamp >= sessionStart);
1580
+ const rows = (node.compactions || []).map(c => {
1581
+ const isPreSession = c.timestamp < sessionStart;
1582
+ const savings = (c.tokensBefore && c.tokensAfter)
1583
+ ? `${fmt(c.tokensBefore)}→${fmt(c.tokensAfter)} (−${fmt(c.tokensBefore - c.tokensAfter)})`
1584
+ : c.tokensBefore ? `${fmt(c.tokensBefore)} tok` : '—';
1585
+ const ts = c.timestamp ? new Date(c.timestamp).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '';
1586
+ const cleanSummary = (c.summary || '').replace(/^<analysis>\s*/i, '').replace(/\s*<\/analysis>\s*$/i, '').trim();
1587
+ const isContinuation = /^This session is being continued/i.test(cleanSummary);
1588
+ const typeLabel = isPreSession ? 'Continuation' : isContinuation ? 'Continuation' : 'Compact';
1589
+ const typeColor = isPreSession || isContinuation ? 'var(--teal)' : 'var(--purple)';
1590
+ return `<div style="display:flex;align-items:flex-start;gap:8px;padding:6px 0;border-bottom:1px solid var(--border-dim);cursor:pointer"
1591
+ onclick="selectCompact('${esc(node.sessionId)}','${esc(c.id)}')">
1592
+ <span style="font-size:9px;font-weight:700;color:${typeColor};flex-shrink:0;width:72px;margin-top:1px">${typeLabel}</span>
1593
+ <span style="font-size:10px;color:var(--muted);flex:1">${savings}</span>
1594
+ ${ts ? `<span style="font-size:9px;color:var(--dim);flex-shrink:0;font-variant-numeric:tabular-nums">${ts}</span>` : ''}
1595
+ </div>`;
1596
+ }).join('');
1597
+ return `<div class="section">
1598
+ <div class="section-label">Context Events <span style="font-size:10px;color:var(--dim);font-weight:400;text-transform:none;letter-spacing:0">(${(node.compactions || []).length})</span></div>
1599
+ <div style="border:1px solid var(--border-dim);border-radius:6px;padding:4px 10px;background:var(--surface2)">${rows}</div>
1600
+ </div>`;
1601
+ })() : ''}
1602
+
1603
+ ${node.toolCalls.length ? `
1604
+ <div class="section">
1605
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
1606
+ <span class="section-label" style="margin-bottom:0">Logs <span style="color:var(--dim);font-weight:400">(${node.toolCalls.length})</span></span>
1607
+ <span id="${logFilterId}-clear" style="font-size:10px;color:var(--dim);cursor:pointer;display:none" onclick="clearLogFilter('${esc(logFilterId)}','${esc(logListId)}')">✕ clear filter</span>
1608
+ </div>
1609
+ ${sortedTypes.length > 1 ? `<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px" id="${logFilterId}">${typeFilterChips}</div>` : ''}
1610
+ <div id="${logListId}" style="max-height:320px;overflow-y:auto;border:1px solid var(--border-dim);border-radius:6px;background:var(--surface2)">
1611
+ ${logRows}
1612
+ </div>
1613
+ </div>` : ''}
1614
+
1615
+ ${node.lastText ? `
1616
+ <div class="section">
1617
+ <div class="section-label">Last output</div>
1618
+ <div class="code-block">${esc(node.lastText)}</div>
1619
+ </div>` : ''}
1620
+
1621
+ <div class="section">
1622
+ <div class="section-label" style="display:flex;align-items:center;justify-content:space-between">
1623
+ Conversation thread
1624
+ <button data-reload-thread="${esc(node.sessionId)}" data-thread-container="${esc(threadTabId)}"
1625
+ style="background:var(--surface2);border:1px solid var(--border-dim);color:var(--muted);padding:2px 8px;border-radius:4px;cursor:pointer;font-size:10px">
1626
+ ↺ Reload
1627
+ </button>
1628
+ </div>
1629
+ <div id="${threadTabId}" style="color:var(--dim);font-size:12px;margin-top:10px;max-height:400px;overflow-y:auto;border:1px solid var(--border-dim);border-radius:6px;padding:12px">
1630
+ Loading…
1631
+ </div>
1632
+ </div>`;
1633
+ }
1634
+
1635
+ function toolDetailHTML(node, tc) {
1636
+ const bc = toolBadgeClass(tc.name);
1637
+ const bl = toolBadgeLabel(tc.name, tc.summary);
1638
+ const status = tc.done ? 'done' : 'running';
1639
+
1640
+ const hasInput = tc.input && Object.keys(tc.input).length > 0;
1641
+ const hasOutput = tc.output !== undefined && tc.output !== null;
1642
+
1643
+ const inputHtml = hasInput ? renderInputPayload(tc.name, tc.input) : '';
1644
+ const outputHtml = hasOutput ? renderOutputPayload(tc.name, tc.output) : '';
1645
+
1646
+ const tabsHtml = (hasInput || hasOutput) ? `
1647
+ <div class="tabs">
1648
+ ${hasInput ? `<div class="tab active" onclick="switchTab(this,'io-input')">Input</div>` : ''}
1649
+ ${hasOutput ? `<div class="tab ${hasInput?'':'active'}" onclick="switchTab(this,'io-output')">Output</div>` : ''}
1650
+ </div>
1651
+ ${hasInput ? `<div class="tab-body active" id="io-input">${inputHtml}</div>` : ''}
1652
+ ${hasOutput ? `<div class="tab-body ${hasInput?'':'active'}" id="io-output">${outputHtml}</div>` : ''}
1653
+ ` : '';
1654
+
1655
+ return `
1656
+ <div class="detail-title">
1657
+ <span class="type-badge ${bc}">${bl}</span>
1658
+ ${esc(tc.name)}
1659
+ </div>
1660
+
1661
+ <div class="section">
1662
+ <div class="section-label">Details</div>
1663
+ <div class="prop-grid">
1664
+ <span class="prop-key">Status</span>
1665
+ <span class="prop-val"><span class="status-chip ${status}">${status}</span></span>
1666
+ <span class="prop-key">Duration</span>
1667
+ <span class="prop-val">${tc.durationMs ? dur(tc.durationMs) : '—'}</span>
1668
+ <span class="prop-key">Agent</span>
1669
+ <span class="prop-val blue">${esc(node.label)}</span>
1670
+ <span class="prop-key">Summary</span>
1671
+ <span class="prop-val" style="color:var(--muted)">${esc(tc.summary)}</span>
1672
+ </div>
1673
+ </div>
1674
+
1675
+ ${(hasInput || hasOutput) ? `
1676
+ <div class="section">
1677
+ <div class="section-label">Payload</div>
1678
+ ${tabsHtml}
1679
+ </div>` : ''}`;
1680
+ }
1681
+
1682
+ function compactDetailHTML(node, c) {
1683
+ const savings = (c.tokensBefore && c.tokensAfter)
1684
+ ? `${fmt(c.tokensBefore)} → ${fmt(c.tokensAfter)} (−${fmt(c.tokensBefore - c.tokensAfter)})`
1685
+ : '—';
1686
+ const pct = (c.tokensBefore && c.tokensAfter)
1687
+ ? Math.round((1 - c.tokensAfter / c.tokensBefore) * 100) + '% reduction'
1688
+ : '';
1689
+
1690
+ return `
1691
+ <div class="detail-title">
1692
+ <span class="type-badge tb-compact">COMPACT</span>
1693
+ Context Compaction
1694
+ </div>
1695
+
1696
+ <div class="section">
1697
+ <div class="section-label">Details</div>
1698
+ <div class="prop-grid">
1699
+ <span class="prop-key">Agent</span>
1700
+ <span class="prop-val blue">${esc(node.label)}</span>
1701
+ <span class="prop-key">Tokens</span>
1702
+ <span class="prop-val" style="color:var(--teal)">${savings}</span>
1703
+ ${pct ? `<span class="prop-key">Savings</span><span class="prop-val green">${pct}</span>` : ''}
1704
+ <span class="prop-key">Time</span>
1705
+ <span class="prop-val">${new Date(c.timestamp).toLocaleTimeString()}</span>
1706
+ </div>
1707
+ </div>
1708
+
1709
+ ${c.summary && c.summary.length > 20 ? (() => {
1710
+ // Strip XML analysis wrapper tags if present (Claude wraps compaction summaries in <analysis>...</analysis>)
1711
+ const cleanSummary = c.summary.replace(/^<analysis>\s*/i, '').replace(/\s*<\/analysis>\s*$/i, '').trim();
1712
+ // Detect if this is a continuation prompt (from previous session's compaction injected at session start)
1713
+ const isContinuation = /^This session is being continued from a previous conversation/i.test(cleanSummary);
1714
+ const label = isContinuation ? 'Continuation Context' : 'Compact Summary';
1715
+ const note = isContinuation
1716
+ ? '<span style="font-size:10px;color:var(--dim);font-weight:400;text-transform:none;letter-spacing:0">· summary injected at session start from previous compaction</span>'
1717
+ : '<span style="font-size:10px;color:var(--dim);font-weight:400;text-transform:none;letter-spacing:0">· what Claude preserved across context window</span>';
1718
+ return `
1719
+ <div class="section">
1720
+ <div class="section-label">${label} ${note}</div>
1721
+ <div class="code-block" style="white-space:pre-wrap;word-break:break-word;max-height:480px">${esc(cleanSummary)}</div>
1722
+ </div>`;
1723
+ })() : `
1724
+ <div class="section">
1725
+ <p style="color:var(--muted);font-size:12px">Claude Code compacted the conversation context to free up tokens.</p>
1726
+ <p style="color:var(--dim);font-size:11px;margin-top:8px">Summary not available — may be read from transcript after the session ends.</p>
1727
+ </div>`}`;
1728
+ }
1729
+
1730
+ // ── Summary popover ───────────────────────────────────────────────────────
1731
+ function openSummary(type) {
1732
+ const panel = document.getElementById('summary-panel-content');
1733
+ panel.innerHTML = buildSummary(type);
1734
+ document.getElementById('summary-popover').classList.add('open');
1735
+ }
1736
+ function closeSummary() {
1737
+ document.getElementById('summary-popover').classList.remove('open');
1738
+ }
1739
+
1740
+ function buildSummary(type) {
1741
+ const closeBtn = `<span class="summary-close" onclick="closeSummary()">✕</span>`;
1742
+
1743
+ if (!lastRoot) {
1744
+ return `<div class="summary-title">No active session ${closeBtn}</div>
1745
+ <p style="color:var(--muted);font-size:12px">Run Claude Code to see data here.</p>`;
1746
+ }
1747
+
1748
+ // Flatten all nodes
1749
+ function flatten(node, depth = 0) {
1750
+ if (!node) return [];
1751
+ return [{ ...node, depth }, ...(node.children || []).flatMap(c => flatten(c, depth + 1))];
1752
+ }
1753
+ const allNodes = flatten(lastRoot);
1754
+
1755
+ if (type === 'agents') {
1756
+ const rows = allNodes.map(n => {
1757
+ const indent = ' '.repeat(n.depth);
1758
+ const elapsed = n.endedAt ? dur(n.endedAt - n.startedAt) : dur(Date.now() - n.startedAt);
1759
+ const dot = n.status === 'running' ? '⟳' : n.status === 'done' ? '✓' : '✗';
1760
+ const dotColor = n.status === 'running' ? 'var(--yellow)' : n.status === 'done' ? 'var(--green)' : 'var(--red)';
1761
+ return `<div class="summary-row" style="cursor:pointer" data-goto-sid="${esc(n.sessionId)}">
1762
+ <span class="summary-row-label" style="display:flex;align-items:center;gap:6px">
1763
+ <span style="color:${dotColor}">${dot}</span>
1764
+ <span style="font-size:11px">${indent}${esc(n.label)}</span>
1765
+ </span>
1766
+ <span class="summary-row-val">${elapsed}</span>
1767
+ </div>`;
1768
+ }).join('');
1769
+ return `<div class="summary-title">Agents (${allNodes.length}) ${closeBtn}</div>${rows}`;
1770
+ }
1771
+
1772
+ if (type === 'tools') {
1773
+ // Aggregate tool call counts across all nodes
1774
+ const toolCounts = new Map();
1775
+ for (const n of allNodes) {
1776
+ for (const tc of n.toolCalls) {
1777
+ const e = toolCounts.get(tc.name) || { calls: 0, done: 0, totalMs: 0 };
1778
+ e.calls++;
1779
+ if (tc.done) e.done++;
1780
+ if (tc.durationMs) e.totalMs += tc.durationMs;
1781
+ toolCounts.set(tc.name, e);
1782
+ }
1783
+ }
1784
+ const sorted = [...toolCounts.entries()].sort((a,b) => b[1].calls - a[1].calls);
1785
+ const rows = sorted.map(([name, e]) => {
1786
+ const bc = toolBadgeClass(name);
1787
+ const bl = toolBadgeLabel(name);
1788
+ const avg = e.totalMs && e.done ? ' · avg ' + dur(Math.round(e.totalMs / e.done)) : '';
1789
+ return `<div class="summary-row">
1790
+ <span style="display:flex;align-items:center;gap:8px">
1791
+ <span class="type-badge ${bc}">${bl}</span>
1792
+ <span class="summary-row-label">${esc(name)}</span>
1793
+ </span>
1794
+ <span class="summary-row-val blue">${e.calls} calls${avg}</span>
1795
+ </div>`;
1796
+ }).join('');
1797
+ const total = [...toolCounts.values()].reduce((s,e) => s + e.calls, 0);
1798
+ return `<div class="summary-title">Tool calls (${total}) ${closeBtn}</div>${rows || '<p style="color:var(--muted);font-size:12px">No tool calls yet.</p>'}`;
1799
+ }
1800
+
1801
+ if (type === 'tokens') {
1802
+ const rows = allNodes.map(n => {
1803
+ const total = n.tokens.input + n.tokens.output;
1804
+ if (!total && !n.tokens.cacheRead) return '';
1805
+ return `<div class="summary-row" style="cursor:pointer" data-goto-sid="${esc(n.sessionId)}">
1806
+ <span class="summary-row-label" style="font-size:11px">${esc(n.label)}</span>
1807
+ <span style="display:flex;gap:10px;font-size:11px;font-variant-numeric:tabular-nums">
1808
+ <span style="color:var(--dim)">↑${fmt(n.tokens.input)}</span>
1809
+ <span style="color:var(--dim)">↓${fmt(n.tokens.output)}</span>
1810
+ <span style="color:var(--blue-soft)">◎${fmt(n.tokens.cacheRead)}</span>
1811
+ </span>
1812
+ </div>`;
1813
+ }).filter(Boolean).join('');
1814
+ // Root nodes already include child tokens recursively — sum roots only to avoid double-counting
1815
+ const rootNodes = allNodes.filter(n => n.depth === 0);
1816
+ const totalIn = rootNodes.reduce((s,n) => s + n.tokens.input, 0);
1817
+ const totalOut = rootNodes.reduce((s,n) => s + n.tokens.output, 0);
1818
+ const totalCache = rootNodes.reduce((s,n) => s + n.tokens.cacheRead, 0);
1819
+ return `<div class="summary-title">Token breakdown ${closeBtn}</div>
1820
+ <div class="summary-row"><span class="summary-row-label">Total input</span><span class="summary-row-val">${fmt(totalIn)}</span></div>
1821
+ <div class="summary-row"><span class="summary-row-label">Total output</span><span class="summary-row-val">${fmt(totalOut)}</span></div>
1822
+ <div class="summary-row"><span class="summary-row-label">Cache read</span><span class="summary-row-val blue">${fmt(totalCache)}</span></div>
1823
+ <div style="height:12px"></div>
1824
+ ${rows || '<p style="color:var(--muted);font-size:12px">No token data yet.</p>'}`;
1825
+ }
1826
+
1827
+ if (type === 'cost') {
1828
+ const rows = allNodes.map(n => {
1829
+ if (!n.costUsd) return '';
1830
+ return `<div class="summary-row" style="cursor:pointer" data-goto-sid="${esc(n.sessionId)}">
1831
+ <span class="summary-row-label" style="font-size:11px">${esc(n.label)}</span>
1832
+ <span class="summary-row-val green">$${n.costUsd.toFixed(4)}</span>
1833
+ </div>`;
1834
+ }).filter(Boolean).join('');
1835
+ // Root nodes include child costs recursively — sum roots only to avoid double-counting
1836
+ const rootNodes = allNodes.filter(n => n.depth === 0);
1837
+ const total = rootNodes.reduce((s,n) => s + (n.costUsd || 0), 0);
1838
+ return `<div class="summary-title">Cost breakdown ${closeBtn}</div>
1839
+ <div class="summary-row"><span class="summary-row-label">Total this session</span><span class="summary-row-val green">$${total.toFixed(4)}</span></div>
1840
+ <div style="height:12px"></div>
1841
+ ${rows || '<p style="color:var(--muted);font-size:12px">Cost data available after session ends.</p>'}
1842
+ <div style="margin-top:16px;padding-top:12px;border-top:1px solid var(--border-dim)">
1843
+ <div style="font-size:11px;color:var(--dim);margin-bottom:8px">Past sessions →</div>
1844
+ <div style="cursor:pointer;font-size:12px;color:var(--blue)" onclick="closeSummary();showView('history')">View history</div>
1845
+ </div>`;
1846
+ }
1847
+
1848
+ return `<div class="summary-title">Summary ${closeBtn}</div>`;
1849
+ }
1850
+
1851
+ // ── View switching ─────────────────────────────────────────────────────────
1852
+ let currentView = 'trace';
1853
+ function showView(view) {
1854
+ currentView = view;
1855
+ document.querySelectorAll('.nav-tab').forEach((t, i) => {
1856
+ const views = ['trace', 'history', 'permissions'];
1857
+ t.classList.toggle('active', views[i] === view);
1858
+ });
1859
+ document.getElementById('trace-view').style.display = view === 'trace' ? 'flex' : 'none';
1860
+ document.getElementById('history-view').style.display = view === 'history' ? 'flex' : 'none';
1861
+ document.getElementById('permissions-view').style.display = view === 'permissions' ? 'flex' : 'none';
1862
+
1863
+ // Graph only makes sense on the Trace view — close it on other views and hide button
1864
+ if (view !== 'trace' && graphOpen) closeGraph();
1865
+ const graphBtn = document.getElementById('graph-btn');
1866
+ if (graphBtn) graphBtn.style.display = view === 'trace' ? '' : 'none';
1867
+
1868
+ if (view === 'history') loadHistoryList();
1869
+ if (view === 'permissions') loadPermissions();
1870
+ }
1871
+
1872
+ // ── History ────────────────────────────────────────────────────────────────
1873
+ let selectedHistorySid = null;
1874
+
1875
+ // ── Projects panel ────────────────────────────────────────────────────────
1876
+ let projectsOpen = true;
1877
+ let projectsData = null;
1878
+
1879
+ function toggleProjects() {
1880
+ projectsOpen = !projectsOpen;
1881
+ const list = document.getElementById('projects-list');
1882
+ const header = document.getElementById('proj-header');
1883
+ const chev = document.getElementById('proj-chevron');
1884
+ if (list) list.style.display = projectsOpen ? '' : 'none';
1885
+ if (header) header.classList.toggle('collapsed', !projectsOpen);
1886
+ if (chev) chev.style.transform = projectsOpen ? '' : 'rotate(-90deg)';
1887
+ if (projectsOpen && !projectsData) loadProjects();
1888
+ }
1889
+
1890
+ async function loadProjects() {
1891
+ try {
1892
+ const res = await fetch('/api/projects');
1893
+ projectsData = await res.json();
1894
+ renderProjects();
1895
+ } catch {}
1896
+ }
1897
+
1898
+ function relativeTime(ms) {
1899
+ const s = Math.floor((Date.now() - ms) / 1000);
1900
+ if (s < 60) return `${s}s ago`;
1901
+ if (s < 3600) return `${Math.floor(s/60)}m ago`;
1902
+ if (s < 86400) return `${Math.floor(s/3600)}h ago`;
1903
+ return `${Math.floor(s/86400)}d ago`;
1904
+ }
1905
+
1906
+ function renderProjects() {
1907
+ const list = document.getElementById('projects-list');
1908
+ const countEl = document.getElementById('proj-count');
1909
+ if (!list || !projectsData) return;
1910
+
1911
+ const projects = projectsData.filter(p => p.cwd); // skip unknown
1912
+ if (countEl) countEl.textContent = projects.length ? `(${projects.length})` : '';
1913
+
1914
+ list.innerHTML = projects.map(proj => {
1915
+ const sessions = proj.sessions.slice(0, 6);
1916
+ const activeSid = selected?.sessionId;
1917
+ const sessionRows = sessions.map(s => {
1918
+ const dotColor = s.status === 'running' ? 'var(--yellow)' : s.status === 'done' ? 'var(--green)' : 'var(--red)';
1919
+ const cost = s.cost_usd > 0 ? `$${s.cost_usd.toFixed(2)}` : '';
1920
+ const age = s.started_at ? relativeTime(s.started_at) : '';
1921
+ const isActive = s.id === activeSid;
1922
+ return `<div class="proj-session-row${isActive?' active':''}" data-proj-sid="${esc(s.id)}">
1923
+ <span class="proj-status-dot" style="background:${dotColor}"></span>
1924
+ <span class="proj-session-label" title="${esc(s.label)}">${esc(s.label)}</span>
1925
+ ${cost ? `<span class="proj-session-cost">${esc(cost)}</span>` : ''}
1926
+ <span class="proj-session-age">${esc(age)}</span>
1927
+ </div>`;
1928
+ }).join('');
1929
+
1930
+ const hasRunning = sessions.some(s => s.status === 'running');
1931
+ const folderColor = hasRunning ? 'var(--yellow)' : 'var(--dim)';
1932
+ return `<div class="proj-group">
1933
+ <div class="proj-folder-row" title="${esc(proj.cwd)}">
1934
+ <span style="font-size:14px">📁</span>
1935
+ <div style="flex:1;min-width:0">
1936
+ <div class="proj-folder-name">${esc(proj.name)}</div>
1937
+ <div style="font-size:9px;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(proj.cwd)}</div>
1938
+ </div>
1939
+ <span class="proj-folder-count">${sessions.length}</span>
1940
+ </div>
1941
+ ${sessionRows}
1942
+ </div>`;
1943
+ }).join('') || '<div style="padding:12px 14px;font-size:11px;color:var(--dim)">No sessions recorded yet</div>';
1944
+ }
1945
+
1946
+ // Handle project session clicks
1947
+ document.addEventListener('click', e => {
1948
+ const projEl = e.target.closest('[data-proj-sid]');
1949
+ if (projEl) {
1950
+ const sid = projEl.dataset.projSid;
1951
+ // Try selecting live session first, else load from history
1952
+ if (sessions && sessions.has && sessions.has(sid)) {
1953
+ showView('trace');
1954
+ selectAgent(sid);
1955
+ } else {
1956
+ showView('history');
1957
+ loadHistorySession(sid);
1958
+ }
1959
+ // Refresh active state
1960
+ renderProjects();
1961
+ return;
1962
+ }
1963
+ });
1964
+
1965
+ // Refresh project panel periodically and after session updates
1966
+ let projRefreshTimer = null;
1967
+ function scheduleProjectRefresh() {
1968
+ clearTimeout(projRefreshTimer);
1969
+ projRefreshTimer = setTimeout(() => { projectsData = null; if (projectsOpen) loadProjects(); }, 3000);
1970
+ }
1971
+
1972
+ async function loadHistoryList() {
1973
+ try {
1974
+ const res = await fetch('/api/sessions');
1975
+ const sessions = await res.json();
1976
+ const list = document.getElementById('history-list');
1977
+ if (!sessions.length) {
1978
+ list.innerHTML = '<div style="padding:20px 14px;color:var(--dim);font-size:12px">No sessions yet.</div>';
1979
+ return;
1980
+ }
1981
+ // Group by date
1982
+ const groups = new Map(); // 'Apr 6, 2026' → sessions[]
1983
+ for (const s of sessions) {
1984
+ const d = new Date(s.started_at);
1985
+ const key = d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
1986
+ if (!groups.has(key)) groups.set(key, []);
1987
+ groups.get(key).push(s);
1988
+ }
1989
+ let html = '';
1990
+ for (const [dateKey, group] of groups) {
1991
+ html += `<div style="padding:6px 14px 2px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:var(--dim)">${esc(dateKey)}</div>`;
1992
+ for (const s of group) {
1993
+ const cost = s.cost_usd > 0 ? `$${s.cost_usd.toFixed(2)}` : '';
1994
+ const time = new Date(s.started_at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
1995
+ const active = s.id === selectedHistorySid ? ' active' : '';
1996
+ html += `<div class="history-session${active}" data-history-sid="${esc(s.id)}">
1997
+ <div style="display:flex;align-items:center;gap:6px">
1998
+ <div class="history-status-dot ${esc(s.status)}"></div>
1999
+ <div class="history-session-label">${esc(s.label)}</div>
2000
+ </div>
2001
+ <div class="history-session-meta">
2002
+ <span class="history-session-date">${esc(time)}</span>
2003
+ ${cost ? `<span class="history-session-cost">${esc(cost)}</span>` : ''}
2004
+ </div>
2005
+ </div>`;
2006
+ }
2007
+ }
2008
+ list.innerHTML = html;
2009
+ } catch(e) {
2010
+ document.getElementById('history-list').innerHTML =
2011
+ '<div style="padding:20px 14px;color:var(--red);font-size:12px">Failed to load sessions.</div>';
2012
+ }
2013
+ }
2014
+
2015
+ async function loadHistorySession(sid) {
2016
+ selectedHistorySid = sid;
2017
+ // Update active state without re-fetching
2018
+ document.querySelectorAll('.history-session').forEach(el => {
2019
+ el.classList.toggle('active', el.dataset.historySid === sid);
2020
+ });
2021
+ const detail = document.getElementById('history-detail');
2022
+ detail.innerHTML = '<div class="detail-empty"><div class="detail-empty-icon">◌</div><div>Loading…</div></div>';
2023
+ try {
2024
+ const res = await fetch(`/api/sessions/${encodeURIComponent(sid)}`);
2025
+ const node = await res.json();
2026
+ detail.innerHTML = '<div style="padding:24px 28px">' + agentDetailHTML(node) + '</div>';
2027
+ } catch {
2028
+ detail.innerHTML = '<div style="padding:24px;color:var(--red);font-size:12px">Failed to load session.</div>';
2029
+ }
2030
+ }
2031
+
2032
+ function relDate(d) {
2033
+ const diff = Date.now() - d.getTime();
2034
+ if (diff < 60000) return 'just now';
2035
+ if (diff < 3600000) return Math.floor(diff/60000) + 'm ago';
2036
+ if (diff < 86400000) return Math.floor(diff/3600000) + 'h ago';
2037
+ return d.toLocaleDateString();
2038
+ }
2039
+
2040
+ // ── Permissions ────────────────────────────────────────────────────────────
2041
+ async function loadPermissions() {
2042
+ const el = document.getElementById('permissions-content');
2043
+ try {
2044
+ const [permRes, auditRes] = await Promise.all([
2045
+ fetch('/api/permissions'),
2046
+ fetch('/api/security-audit'),
2047
+ ]);
2048
+ const data = await permRes.json();
2049
+ const audit = auditRes.ok ? await auditRes.json() : {};
2050
+ el.innerHTML = renderPermissions(data, audit);
2051
+ } catch {
2052
+ el.innerHTML = '<div style="color:var(--dim);font-size:12px">Could not load permissions — daemon may not support this endpoint yet.</div>';
2053
+ }
2054
+ }
2055
+
2056
+ function renderPermissions(data, audit = {}) {
2057
+ const { defaultMode, allow = [], deny = [], ask = [], toolStats = [], plugins = [], env = {}, mcpServers = [], fileAccess = {} } = data;
2058
+ const { hooks = [], bashCommands = [], sensitiveFiles = [], webRequests = [], bypassSessions = [], blockedActions = [] } = audit;
2059
+
2060
+ const chipList = (items, cls) => items.length
2061
+ ? items.map(i => `<span class="perm-chip ${cls}">${esc(i)}</span>`).join('')
2062
+ : `<span class="perm-empty">None</span>`;
2063
+
2064
+ // MCP servers
2065
+ const mcpRows = mcpServers.length ? mcpServers.map(s => {
2066
+ const cmdStr = s.command ? esc(s.command + (s.args?.length ? ' ' + s.args.join(' ') : '')) : '—';
2067
+ const typeBadge = `<span class="perm-chip mode" style="font-size:9px;padding:1px 5px">${esc(s.type)}</span>`;
2068
+ return `<div class="perm-tool-row" style="flex-direction:column;align-items:flex-start;gap:3px">
2069
+ <div style="display:flex;align-items:center;gap:8px">
2070
+ ${typeBadge}
2071
+ <span style="font-size:12px;font-weight:600;color:var(--text)">${esc(s.name)}</span>
2072
+ </div>
2073
+ <span class="mono" style="font-size:10px;color:var(--dim)">${cmdStr}</span>
2074
+ </div>`;
2075
+ }).join('') : `<span class="perm-empty">No MCP servers configured</span>`;
2076
+
2077
+ // File access
2078
+ const cwdRow = fileAccess.cwd
2079
+ ? `<div class="perm-tool-row">
2080
+ <span class="perm-chip allow">✓ ${esc(fileAccess.cwd)}</span>
2081
+ <span style="font-size:10px;color:var(--dim);margin-left:4px">working directory</span>
2082
+ </div>`
2083
+ : `<div style="color:var(--dim);font-size:12px;font-style:italic">No active session — working directory unknown</div>`;
2084
+
2085
+ const extraDirs = (fileAccess.additionalDirs || []).map(d =>
2086
+ `<div class="perm-tool-row"><span class="perm-chip allow">✓ ${esc(d)}</span></div>`
2087
+ ).join('');
2088
+
2089
+ const deniedPaths = (fileAccess.denied || []).map(d =>
2090
+ `<span class="perm-chip deny">${esc(d)}</span>`
2091
+ ).join('');
2092
+
2093
+ // Tool stats
2094
+ const toolRows = toolStats.map(t =>
2095
+ `<div class="perm-tool-row">
2096
+ <span class="perm-tool-name">${esc(t.name)}</span>
2097
+ <span class="perm-tool-count">${t.calls} call${t.calls !== 1 ? 's' : ''}${t.totalMs ? ' · ' + (t.totalMs/1000).toFixed(1) + 's' : ''}</span>
2098
+ </div>`).join('');
2099
+
2100
+ // Plugins
2101
+ const pluginRows = plugins.map(p => `<span class="perm-chip mode">${esc(p)}</span>`).join('');
2102
+
2103
+ // Env
2104
+ const envRows = Object.entries(env).map(([k, v]) =>
2105
+ `<div class="perm-tool-row">
2106
+ <span class="perm-tool-name mono" style="font-size:11px">${esc(k)}</span>
2107
+ <span class="perm-tool-count mono" style="font-size:11px">${esc(v)}</span>
2108
+ </div>`).join('');
2109
+
2110
+ // ── Audit: hooks ──
2111
+ const DESTRUCTIVE_BASH_RE = /\brm\s+-[a-zA-Z]*[rf]|\bDROP\s+(?:TABLE|DATABASE)\b|\btruncate\b|\bchmod\s+777\b|\bgit\s+push\b[^|&;]*-(?:-force\b|f\b)|\b(?:pkill|wipefs|mkfs|shred)\b|>\s*\/dev\/sda|\bdd\b.*of=/i;
2112
+ // Strip quoted strings before testing so `echo "rm -rf"` doesn't false-positive
2113
+ function isDestructiveBash(cmd) {
2114
+ const stripped = cmd.replace(/'(?:[^'\\]|\\.)*'/g, "''").replace(/"(?:[^"\\]|\\.)*"/g, '""');
2115
+ return DESTRUCTIVE_BASH_RE.test(stripped);
2116
+ }
2117
+
2118
+ const hookRows = hooks.length ? hooks.map(h => {
2119
+ const eventColor = { PreToolUse:'var(--yellow)', PostToolUse:'var(--teal)', Stop:'var(--purple)', PreCompact:'var(--muted)', PostCompact:'var(--muted)', SessionStart:'var(--blue)' }[h.event] || 'var(--muted)';
2120
+ const sourceBadge = h.source === 'global' ? '' : `<span style="font-size:9px;color:var(--muted);border:1px solid var(--border-dim);padding:1px 4px;border-radius:3px">${esc(h.source)}</span>`;
2121
+ return `<div class="perm-tool-row" style="flex-direction:column;align-items:flex-start;gap:4px;padding:8px 10px;background:var(--surface2);border-radius:6px;margin-bottom:6px">
2122
+ <div style="display:flex;align-items:center;gap:8px;width:100%">
2123
+ <span style="font-size:10px;font-weight:700;color:${eventColor};letter-spacing:0.05em">${esc(h.event)}</span>
2124
+ ${h.matcher ? `<span style="font-size:10px;color:var(--dim)">on <span style="color:var(--text)">${esc(h.matcher)}</span></span>` : ''}
2125
+ ${sourceBadge}
2126
+ </div>
2127
+ <div class="mono" style="font-size:10px;color:var(--muted);word-break:break-all;line-height:1.6">${esc(h.command)}</div>
2128
+ </div>`;
2129
+ }).join('') : `<span class="perm-empty">No hooks configured</span>`;
2130
+
2131
+ // ── Audit: bash commands ──
2132
+ const bashRows2 = bashCommands.length ? bashCommands.slice(0, 100).map(b => {
2133
+ const isDanger = isDestructiveBash(b.command);
2134
+ const ts = b.startedAt ? new Date(b.startedAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '';
2135
+ return `<div class="perm-tool-row" style="gap:8px;align-items:flex-start;padding:5px 8px;border-radius:4px;${isDanger?'background:#1a0808;':''}">
2136
+ <span class="mono" style="font-size:10px;color:${isDanger?'var(--red)':'var(--muted)'};flex:1;word-break:break-all">${esc(b.command)}</span>
2137
+ <span style="font-size:9px;color:var(--dim);white-space:nowrap;flex-shrink:0">${esc(ts)}</span>
2138
+ </div>`;
2139
+ }).join('') : `<span class="perm-empty">No bash commands recorded</span>`;
2140
+
2141
+ // ── Audit: sensitive files ──
2142
+ const sensitiveRows = sensitiveFiles.length ? sensitiveFiles.map(f => {
2143
+ const toolColor = { Read:'var(--blue)', Write:'var(--red)', Edit:'var(--yellow)' }[f.tool] || 'var(--muted)';
2144
+ return `<div class="perm-tool-row" style="gap:8px;padding:5px 8px;border-radius:4px;background:var(--surface2)">
2145
+ <span style="font-size:9px;font-weight:700;color:${toolColor};width:34px;flex-shrink:0">${esc(f.tool)}</span>
2146
+ <span class="mono" style="font-size:10px;color:var(--red);flex:1;word-break:break-all">${esc(f.filePath)}</span>
2147
+ </div>`;
2148
+ }).join('') : `<span class="perm-empty">No sensitive file access detected</span>`;
2149
+
2150
+ // ── Audit: network requests ──
2151
+ const webRows2 = webRequests.length ? webRequests.map(w => {
2152
+ return `<div class="perm-tool-row" style="gap:8px;padding:5px 8px;border-radius:4px;background:var(--surface2)">
2153
+ <span style="font-size:9px;font-weight:700;color:var(--blue);width:60px;flex-shrink:0">${esc(w.tool)}</span>
2154
+ <span class="mono" style="font-size:10px;color:var(--blue-soft);flex:1;word-break:break-all">${esc(w.url)}</span>
2155
+ </div>`;
2156
+ }).join('') : `<span class="perm-empty">No network requests recorded</span>`;
2157
+
2158
+ // ── Audit: bypass sessions ──
2159
+ const bypassRows2 = bypassSessions.length ? bypassSessions.map(s => {
2160
+ const ts = new Date(s.started_at).toLocaleDateString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'});
2161
+ return `<div class="perm-tool-row" style="gap:8px;padding:6px 10px;border-radius:4px;background:#2d0f0f;border:1px solid #5a1a1a">
2162
+ <span style="font-size:9px;font-weight:700;color:var(--red)">BYPASS</span>
2163
+ <span style="font-size:11px;flex:1;color:var(--text)">${esc(s.label)}</span>
2164
+ <span style="font-size:10px;color:var(--dim);white-space:nowrap">${esc(ts)}</span>
2165
+ </div>`;
2166
+ }).join('') : `<span class="perm-empty" style="color:var(--green)">No bypass-mode sessions — good</span>`;
2167
+
2168
+ // ── Risk summary banner ──
2169
+ const dangerCount = bashCommands.filter(b => isDestructiveBash(b.command)).length;
2170
+ const riskItems = [
2171
+ bypassSessions.length && `<span style="color:var(--red);font-weight:600">${bypassSessions.length} bypass-mode session${bypassSessions.length>1?'s':''}</span>`,
2172
+ dangerCount && `<span style="color:var(--red)">${dangerCount} destructive command${dangerCount>1?'s':''}</span>`,
2173
+ sensitiveFiles.length && `<span style="color:var(--yellow)">${sensitiveFiles.length} sensitive file access${sensitiveFiles.length>1?'es':''}</span>`,
2174
+ hooks.length && `<span style="color:var(--muted)">${hooks.length} active hook${hooks.length>1?'s':''}</span>`,
2175
+ ].filter(Boolean);
2176
+
2177
+ const riskBanner = riskItems.length
2178
+ ? `<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center;padding:10px 14px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;margin-bottom:24px;font-size:11px">
2179
+ <span style="color:var(--dim);font-weight:600;letter-spacing:0.05em;text-transform:uppercase;font-size:10px">Risk Signals</span>
2180
+ ${riskItems.join('<span style="color:var(--border)">·</span>')}
2181
+ </div>`
2182
+ : `<div style="padding:10px 14px;background:#0d1f12;border:1px solid #1a4a2e;border-radius:8px;margin-bottom:24px;font-size:11px;color:var(--green)">No risk signals detected</div>`;
2183
+
2184
+ const result = `
2185
+ ${riskBanner}
2186
+
2187
+ <div class="perm-section">
2188
+ <div class="section-label">Permission Mode</div>
2189
+ <div class="perm-group"><span class="perm-chip mode">${esc(defaultMode || 'default')}</span></div>
2190
+ </div>
2191
+
2192
+ <div class="perm-section">
2193
+ <div class="section-label">File Access</div>
2194
+ ${cwdRow}
2195
+ ${extraDirs}
2196
+ ${deniedPaths ? `<div style="margin-top:8px"><div style="font-size:10px;color:var(--dim);margin-bottom:6px">Denied read patterns:</div><div class="perm-group">${deniedPaths}</div></div>` : ''}
2197
+ </div>
2198
+
2199
+ <div class="perm-section">
2200
+ <div class="section-label">MCP Servers</div>
2201
+ ${mcpRows}
2202
+ </div>
2203
+
2204
+ <div class="perm-section">
2205
+ <div class="section-label">Allowed Rules</div>
2206
+ <div class="perm-group">${chipList(allow, 'allow')}</div>
2207
+ </div>
2208
+
2209
+ <div class="perm-section">
2210
+ <div class="section-label">Denied Rules</div>
2211
+ <div class="perm-group">${chipList(deny, 'deny')}</div>
2212
+ </div>
2213
+
2214
+ ${ask.length ? `<div class="perm-section"><div class="section-label">Always Ask</div><div class="perm-group">${chipList(ask, 'ask')}</div></div>` : ''}
2215
+
2216
+ ${plugins.length ? `<div class="perm-section"><div class="section-label">Active Plugins</div><div class="perm-group">${pluginRows}</div></div>` : ''}
2217
+
2218
+ ${envRows ? `<div class="perm-section"><div class="section-label">Environment</div>${envRows}</div>` : ''}
2219
+
2220
+ <div class="perm-section">
2221
+ <div class="section-label">Configured Hooks <span style="font-size:9px;color:var(--yellow);font-weight:400;margin-left:6px;text-transform:none;letter-spacing:0">runs arbitrary shell code</span></div>
2222
+ ${hookRows}
2223
+ </div>
2224
+
2225
+ <div class="perm-section">
2226
+ <div class="section-label">Bypass-mode Sessions</div>
2227
+ ${bypassRows2}
2228
+ </div>
2229
+
2230
+ <div class="perm-section">
2231
+ <div class="section-label">Sensitive File Access</div>
2232
+ <div style="max-height:240px;overflow-y:auto">${sensitiveRows}</div>
2233
+ </div>
2234
+
2235
+ <div class="perm-section">
2236
+ <div class="section-label">Bash Command Audit <span style="font-size:9px;color:var(--dim);font-weight:400;margin-left:6px;text-transform:none;letter-spacing:0">last 100 · destructive in red</span></div>
2237
+ <div style="max-height:300px;overflow-y:auto">${bashRows2}</div>
2238
+ </div>
2239
+
2240
+ <div class="perm-section">
2241
+ <div class="section-label">Network Requests</div>
2242
+ <div style="max-height:240px;overflow-y:auto">${webRows2}</div>
2243
+ </div>
2244
+
2245
+ ${toolRows ? `<div class="perm-section"><div class="section-label">Tool Usage This Session</div>${toolRows}</div>` : ''}
2246
+
2247
+ <div class="perm-section" id="packages-section">
2248
+ <div class="section-label" style="display:flex;align-items:center;justify-content:space-between">
2249
+ <span>Packages</span>
2250
+ <span style="font-size:10px;color:var(--blue);cursor:pointer;font-weight:400;text-transform:none;letter-spacing:0" onclick="loadPackages()">↺ Refresh</span>
2251
+ </div>
2252
+ <div id="packages-content" style="color:var(--dim);font-size:12px">Loading…</div>
2253
+ </div>
2254
+ `;
2255
+
2256
+ setTimeout(loadPackages, 0);
2257
+ return result;
2258
+ }
2259
+
2260
+ async function loadPackages() {
2261
+ const el = document.getElementById('packages-content');
2262
+ if (!el) return;
2263
+ try {
2264
+ const res = await fetch('/api/packages');
2265
+ const data = await res.json();
2266
+ el.innerHTML = renderPackages(data);
2267
+ } catch {
2268
+ el.innerHTML = '<span style="color:var(--dim);font-size:12px">Could not load packages.</span>';
2269
+ }
2270
+ }
2271
+
2272
+ function renderPackages(data) {
2273
+ if (!data.cwd) return '<span class="perm-empty">No active session — run Claude Code to detect project packages</span>';
2274
+ let html = `<div style="font-size:11px;color:var(--dim);margin-bottom:10px">Project: <span style="color:var(--muted)">${esc(data.cwd)}</span></div>`;
2275
+
2276
+ if (data.npm) {
2277
+ const allPkgs = [...data.npm.deps, ...data.npm.devDeps];
2278
+ if (allPkgs.length) {
2279
+ html += `<div style="font-size:11px;font-weight:600;color:var(--muted);margin-bottom:6px">npm — ${data.npm.name}@${data.npm.version} (${allPkgs.length} packages)</div>`;
2280
+ html += allPkgs.map(p =>
2281
+ `<div class="perm-tool-row">
2282
+ <span class="perm-tool-name mono" style="font-size:11px">${esc(p.name)}</span>
2283
+ <span style="display:flex;gap:8px;align-items:center">
2284
+ <span class="perm-tool-count mono">${esc(p.version)}</span>
2285
+ ${p.type === 'dev' ? `<span style="font-size:9px;color:var(--dim);border:1px solid var(--border-dim);padding:1px 4px;border-radius:3px">dev</span>` : ''}
2286
+ </span>
2287
+ </div>`
2288
+ ).join('');
2289
+ } else {
2290
+ html += '<span class="perm-empty">No npm dependencies</span>';
2291
+ }
2292
+ }
2293
+
2294
+ if (data.pip && data.pip.length) {
2295
+ html += `<div style="font-size:11px;font-weight:600;color:var(--muted);margin:14px 0 6px">pip (${data.pip.length} packages)</div>`;
2296
+ html += data.pip.map(p =>
2297
+ `<div class="perm-tool-row">
2298
+ <span class="perm-tool-name mono" style="font-size:11px">${esc(p.name)}</span>
2299
+ <span class="perm-tool-count mono">${esc(p.version)}</span>
2300
+ </div>`
2301
+ ).join('');
2302
+ }
2303
+
2304
+ for (const o of (data.other || [])) {
2305
+ html += `<div style="font-size:11px;font-weight:600;color:var(--muted);margin:14px 0 6px">${esc(o.file)}</div>`;
2306
+ html += o.packages.map(p => `<div class="perm-tool-row"><span class="perm-tool-name mono" style="font-size:11px">${esc(p)}</span></div>`).join('');
2307
+ }
2308
+
2309
+ if (!data.npm && (!data.pip || !data.pip.length) && !data.other?.length) {
2310
+ html += '<span class="perm-empty">No package files found (package.json, requirements.txt)</span>';
2311
+ }
2312
+
2313
+ return html;
2314
+ }
2315
+
2316
+ function switchTab(tabEl, targetId) {
2317
+ const parent = tabEl.closest('.section') || tabEl.parentElement.parentElement;
2318
+ parent.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
2319
+ parent.querySelectorAll('.tab-body').forEach(b => b.classList.remove('active'));
2320
+ tabEl.classList.add('active');
2321
+ const target = parent.querySelector('#' + targetId);
2322
+ if (target) target.classList.add('active');
2323
+ }
2324
+
2325
+ // ── Payload rendering ─────────────────────────────────────────────────────
2326
+
2327
+ function highlightJson(obj) {
2328
+ const raw = JSON.stringify(obj, null, 2);
2329
+ return raw
2330
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
2331
+ .replace(
2332
+ /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+\.?\d*([eE][+-]?\d+)?)/g,
2333
+ m => {
2334
+ if (/^"/.test(m)) {
2335
+ return /:$/.test(m)
2336
+ ? `<span class="jk">${m}</span>`
2337
+ : `<span class="js">${m}</span>`;
2338
+ }
2339
+ if (/true|false/.test(m)) return `<span class="jb">${m}</span>`;
2340
+ if (/null/.test(m)) return `<span class="jz">${m}</span>`;
2341
+ return `<span class="jn">${m}</span>`;
2342
+ }
2343
+ );
2344
+ }
2345
+
2346
+ function copyCode(btn, text) {
2347
+ navigator.clipboard.writeText(text).then(() => {
2348
+ btn.textContent = 'Copied!';
2349
+ btn.classList.add('copied');
2350
+ setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1500);
2351
+ }).catch(() => {
2352
+ btn.textContent = 'Failed';
2353
+ setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
2354
+ });
2355
+ }
2356
+
2357
+ function codeBlock(text, extraClass) {
2358
+ const cls = 'code-block' + (extraClass ? ' ' + extraClass : '');
2359
+ return `<div class="code-wrap"><div class="${cls}">${esc(text)}</div>` +
2360
+ `<button class="code-copy" onclick="copyCode(this,${JSON.stringify(text)})">Copy</button></div>`;
2361
+ }
2362
+
2363
+ function jsonBlock(obj) {
2364
+ const raw = JSON.stringify(obj, null, 2);
2365
+ return `<div class="code-wrap"><div class="code-block">${highlightJson(obj)}</div>` +
2366
+ `<button class="code-copy" onclick="copyCode(this,${JSON.stringify(raw)})">Copy</button></div>`;
2367
+ }
2368
+
2369
+ function payloadField(label, content) {
2370
+ return `<div class="payload-field"><div class="payload-field-label">${esc(label)}</div>${content}</div>`;
2371
+ }
2372
+
2373
+ function renderInputPayload(name, input) {
2374
+ if (!input || !Object.keys(input).length) return '';
2375
+
2376
+ if (input._truncated) {
2377
+ return `<div style="padding:10px 14px;background:#2d1f06;border:1px solid #5a3a10;border-radius:8px;color:#fbbf24;font-size:12px">
2378
+ Input exceeded 64 KB and was not stored. Check the session transcript for the full content.
2379
+ </div>`;
2380
+ }
2381
+
2382
+ if (name === 'Bash') {
2383
+ const cmd = input.command || '';
2384
+ return payloadField('Command', codeBlock(cmd));
2385
+ }
2386
+
2387
+ if (name === 'Write') {
2388
+ return payloadField('File', `<div class="payload-path">${esc(input.file_path || '')}</div>`) +
2389
+ (input.content != null ? payloadField('Content', codeBlock(input.content)) : '');
2390
+ }
2391
+
2392
+ if (name === 'Edit' || name === 'NotebookEdit') {
2393
+ let out = payloadField('File', `<div class="payload-path">${esc(input.file_path || input.notebook_path || '')}</div>`);
2394
+ if (input.old_string != null) {
2395
+ out += payloadField('Remove', `<div class="payload-diff-old">${esc(input.old_string)}</div>`);
2396
+ out += payloadField('Insert', `<div class="payload-diff-new">${esc(input.new_string || '')}</div>`);
2397
+ } else if (input.edits) {
2398
+ // MultiEdit
2399
+ input.edits.forEach((e, i) => {
2400
+ out += payloadField(`Edit ${i + 1} — Remove`, `<div class="payload-diff-old">${esc(e.old_string)}</div>`);
2401
+ out += payloadField(`Edit ${i + 1} — Insert`, `<div class="payload-diff-new">${esc(e.new_string || '')}</div>`);
2402
+ });
2403
+ }
2404
+ return out;
2405
+ }
2406
+
2407
+ if (name === 'Read') {
2408
+ let out = payloadField('File', `<div class="payload-path">${esc(input.file_path || '')}</div>`);
2409
+ const meta = {};
2410
+ if (input.offset != null) meta.offset = input.offset;
2411
+ if (input.limit != null) meta.limit = input.limit;
2412
+ if (Object.keys(meta).length) out += payloadField('Range', jsonBlock(meta));
2413
+ return out;
2414
+ }
2415
+
2416
+ if (name === 'Agent' || name === 'Task') {
2417
+ let out = '';
2418
+ if (input.prompt) out += payloadField('Prompt', `<div class="payload-text">${esc(input.prompt)}</div>`);
2419
+ const rest = Object.fromEntries(Object.entries(input).filter(([k]) => k !== 'prompt'));
2420
+ if (Object.keys(rest).length) out += payloadField('Options', jsonBlock(rest));
2421
+ return out;
2422
+ }
2423
+
2424
+ if (name === 'Grep') {
2425
+ let out = payloadField('Pattern', codeBlock(input.pattern || ''));
2426
+ if (input.path) out += payloadField('Path', `<div class="payload-path">${esc(input.path)}</div>`);
2427
+ const rest = Object.fromEntries(Object.entries(input).filter(([k]) => !['pattern','path'].includes(k)));
2428
+ if (Object.keys(rest).length) out += payloadField('Options', jsonBlock(rest));
2429
+ return out;
2430
+ }
2431
+
2432
+ if (name === 'Glob') {
2433
+ let out = payloadField('Pattern', codeBlock(input.pattern || ''));
2434
+ if (input.path) out += payloadField('Path', `<div class="payload-path">${esc(input.path)}</div>`);
2435
+ return out;
2436
+ }
2437
+
2438
+ if (name === 'WebFetch' || name === 'WebSearch') {
2439
+ const key = input.url || input.query || '';
2440
+ const label = input.url ? 'URL' : 'Query';
2441
+ let out = payloadField(label, `<div class="payload-path">${esc(key)}</div>`);
2442
+ const rest = Object.fromEntries(Object.entries(input).filter(([k]) => k !== 'url' && k !== 'query'));
2443
+ if (Object.keys(rest).length) out += payloadField('Options', jsonBlock(rest));
2444
+ return out;
2445
+ }
2446
+
2447
+ // Default: syntax-highlighted JSON
2448
+ return jsonBlock(input);
2449
+ }
2450
+
2451
+ function renderOutputPayload(name, output) {
2452
+ if (output === undefined || output === null) return '';
2453
+ if (typeof output === 'string') {
2454
+ // Try to detect and highlight JSON strings
2455
+ const trimmed = output.trim();
2456
+ if ((trimmed.startsWith('{') || trimmed.startsWith('[')) && trimmed.length < 50000) {
2457
+ try {
2458
+ const parsed = JSON.parse(trimmed);
2459
+ return jsonBlock(parsed);
2460
+ } catch (e) { /* not JSON */ }
2461
+ }
2462
+ return codeBlock(output);
2463
+ }
2464
+ return jsonBlock(output);
2465
+ }
2466
+
2467
+ // ── Search / filter ──────────────────────────────────────────────────────
2468
+ let searchQuery = '';
2469
+ function applySearch(q) {
2470
+ searchQuery = q.toLowerCase().trim();
2471
+ if (!lastRoot) return;
2472
+ applySearchToTree(lastRoot);
2473
+ }
2474
+
2475
+ function applySearchToTree(root) {
2476
+ if (!root) return;
2477
+ // Walk all agent groups and tool rows, show/hide based on query
2478
+ for (const [sid, els] of agentEls) {
2479
+ const node = findNodeAnywhere(lastRoot, sid);
2480
+ if (!node) continue;
2481
+ if (!searchQuery) {
2482
+ els.group.style.display = '';
2483
+ for (const [tkey, rowEl] of toolEls) {
2484
+ if (tkey.startsWith(sid + ':')) rowEl.style.display = '';
2485
+ }
2486
+ continue;
2487
+ }
2488
+ // Check if agent label matches
2489
+ const agentMatch = node.label.toLowerCase().includes(searchQuery);
2490
+ let anyToolMatch = false;
2491
+ for (const [tkey, rowEl] of toolEls) {
2492
+ if (!tkey.startsWith(sid + ':')) continue;
2493
+ const toolId = tkey.slice(sid.length + 1);
2494
+ const tc = node.toolCalls.find(t => t.id === toolId);
2495
+ const match = tc && (tc.name.toLowerCase().includes(searchQuery) ||
2496
+ (tc.summary || '').toLowerCase().includes(searchQuery));
2497
+ rowEl.style.display = (agentMatch || match) ? '' : 'none';
2498
+ if (match) anyToolMatch = true;
2499
+ }
2500
+ els.group.style.display = (agentMatch || anyToolMatch) ? '' : 'none';
2501
+ }
2502
+ }
2503
+
2504
+ function findNodeAnywhere(root, sessionId) {
2505
+ if (!root) return null;
2506
+ if (root.sessionId === sessionId) return root;
2507
+ for (const c of (root.children || [])) {
2508
+ const f = findNodeAnywhere(c, sessionId);
2509
+ if (f) return f;
2510
+ }
2511
+ return null;
2512
+ }
2513
+
2514
+ // ── Graph (agent tree map) ────────────────────────────────────────────────
2515
+ let graphOpen = false;
2516
+ let graphAutoOpened = false;
2517
+
2518
+ function toggleGraph() {
2519
+ if (graphOpen) closeGraph(); else openGraph();
2520
+ }
2521
+
2522
+ function openGraph() {
2523
+ graphOpen = true;
2524
+ document.getElementById('graph-overlay').classList.add('open');
2525
+ document.getElementById('graph-btn').classList.add('active');
2526
+ renderGraph();
2527
+ }
2528
+
2529
+ function closeGraph() {
2530
+ graphOpen = false;
2531
+ document.getElementById('graph-overlay').classList.remove('open');
2532
+ document.getElementById('graph-btn').classList.remove('active');
2533
+ }
2534
+
2535
+ // ── Graph state ──────────────────────────────────────────────────────────
2536
+ let graphScale = 1, graphTx = 24, graphTy = 24;
2537
+ let graphPanning = false, graphPanStart = null;
2538
+ let graphDocMM = null, graphDocMU = null; // document-level handlers (cleaned up on re-render)
2539
+ let graphLayout = null; // { nodes: [{node, x, y}], totalW, totalH }
2540
+
2541
+ function graphZoom(delta) {
2542
+ const newScale = Math.max(0.3, Math.min(3, graphScale + delta));
2543
+ const wrap = document.getElementById('graph-svg-wrap');
2544
+ if (!wrap) return;
2545
+ const rect = wrap.getBoundingClientRect();
2546
+ const cx = rect.width / 2, cy = rect.height / 2;
2547
+ graphTx = cx - (cx - graphTx) * (newScale / graphScale);
2548
+ graphTy = cy - (cy - graphTy) * (newScale / graphScale);
2549
+ graphScale = newScale;
2550
+ applyGraphTransform();
2551
+ }
2552
+
2553
+ function graphFit() {
2554
+ if (!graphLayout) return;
2555
+ const wrap = document.getElementById('graph-svg-wrap');
2556
+ if (!wrap) return;
2557
+ const { width: ww, height: wh } = wrap.getBoundingClientRect();
2558
+ const pad = 32;
2559
+ const scaleX = (ww - pad * 2) / (graphLayout.totalW || 1);
2560
+ const scaleY = (wh - pad * 2) / (graphLayout.totalH || 1);
2561
+ graphScale = Math.max(0.3, Math.min(2, Math.min(scaleX, scaleY)));
2562
+ graphTx = (ww - graphLayout.totalW * graphScale) / 2;
2563
+ graphTy = pad;
2564
+ applyGraphTransform();
2565
+ }
2566
+
2567
+ function applyGraphTransform() {
2568
+ const g = document.getElementById('graph-content');
2569
+ if (g) g.setAttribute('transform', `translate(${graphTx.toFixed(1)},${graphTy.toFixed(1)}) scale(${graphScale.toFixed(3)})`);
2570
+ }
2571
+
2572
+ function renderGraph() {
2573
+ if (!graphOpen) return;
2574
+ const wrap = document.getElementById('graph-svg-wrap');
2575
+ if (!lastRoot) {
2576
+ wrap.innerHTML = '<div style="color:var(--muted);font-size:12px;padding:20px">No active session</div>';
2577
+ return;
2578
+ }
2579
+
2580
+ const NODE_W = 200, NODE_H = 64, H_GAP = 28, V_GAP = 60;
2581
+
2582
+ // Compute positions
2583
+ let leafCounter = 0;
2584
+ function computeLayout(node, depth) {
2585
+ const children = (node.children || []).map(c => computeLayout(c, depth + 1));
2586
+ let x;
2587
+ if (children.length === 0) {
2588
+ x = leafCounter++ * (NODE_W + H_GAP);
2589
+ } else {
2590
+ x = (children[0].x + children[children.length - 1].x) / 2;
2591
+ }
2592
+ return { node, x, y: depth * (NODE_H + V_GAP), children };
2593
+ }
2594
+ const layout = computeLayout(lastRoot, 0);
2595
+ const totalW = Math.max(leafCounter * (NODE_W + H_GAP) - H_GAP, NODE_W);
2596
+
2597
+ function getMaxDepth(l) {
2598
+ if (!l.children.length) return 0;
2599
+ return 1 + Math.max(...l.children.map(getMaxDepth));
2600
+ }
2601
+ const totalH = (getMaxDepth(layout) + 1) * (NODE_H + V_GAP) - V_GAP;
2602
+ graphLayout = { totalW, totalH };
2603
+
2604
+ // Collect all layout nodes flat (for tooltip lookup)
2605
+ const flatNodes = [];
2606
+ function collectFlat(l) { flatNodes.push(l); l.children.forEach(collectFlat); }
2607
+ collectFlat(layout);
2608
+
2609
+ // Build SVG content
2610
+ let edges = '', nodes = '';
2611
+ function buildSvg(l) {
2612
+ const cx = l.x + NODE_W / 2;
2613
+ for (const child of l.children) {
2614
+ const ccx = child.x + NODE_W / 2;
2615
+ const midy = l.y + NODE_H + V_GAP / 2;
2616
+ const edgeCls = child.node.isWorktree ? ' worktree' : child.node.isSidechain ? ' sidechain' : '';
2617
+ edges += `<path class="gedge${edgeCls}" d="M ${cx} ${l.y + NODE_H} C ${cx} ${midy}, ${ccx} ${midy}, ${ccx} ${child.y}"/>`;
2618
+ }
2619
+ const isSelected = selected?.sessionId === l.node.sessionId;
2620
+ const status = l.node.status || 'running';
2621
+ const toolCount = l.node.toolCalls?.length || 0;
2622
+ const cost = l.node.costUsd > 0 ? `$${l.node.costUsd.toFixed(3)}` : '';
2623
+ const duration = l.node.endedAt ? dur(l.node.endedAt - l.node.startedAt) : '…';
2624
+ const dotColor = status === 'running' ? '#d29922' : status === 'done' ? '#3fb950' : '#f85149';
2625
+ const compactCount = (l.node.compactions || []).length;
2626
+ const isWorktree = !!l.node.isWorktree;
2627
+ const isSidechain = !!l.node.isSidechain;
2628
+
2629
+ let typeBadge = '';
2630
+ if (isWorktree) {
2631
+ typeBadge = `<text x="${l.x + 10}" y="${l.y + 58}" font-size="9" fill="#2dd4bf" font-weight="700">⎇ WORKTREE</text>`;
2632
+ } else if (isSidechain) {
2633
+ typeBadge = `<text x="${l.x + 10}" y="${l.y + 58}" font-size="9" fill="#c084fc" font-weight="700">⊕ INLINE</text>`;
2634
+ } else if (l.node.permissionMode && l.node.permissionMode !== 'default') {
2635
+ const pColor = l.node.permissionMode === 'bypassPermissions' ? '#f85149' :
2636
+ l.node.permissionMode === 'auto' ? '#d29922' :
2637
+ l.node.permissionMode === 'acceptEdits' ? '#79c0ff' : '#c084fc';
2638
+ typeBadge = `<text x="${l.x + 10}" y="${l.y + 58}" font-size="9" fill="${pColor}" font-weight="700">${esc(l.node.permissionMode.toUpperCase())}</text>`;
2639
+ }
2640
+
2641
+ const nodeH = typeBadge ? NODE_H + 12 : NODE_H;
2642
+ const nodeClass = `gnode ${status}${isWorktree ? ' worktree' : isSidechain ? ' sidechain' : ''}${isSelected ? ' selected' : ''}`;
2643
+
2644
+ // Running pulse ring
2645
+ const pulse = status === 'running'
2646
+ ? `<circle cx="${l.x + 14}" cy="${l.y + 14}" r="5" fill="none" stroke="#d29922" stroke-width="1" opacity="0.3">
2647
+ <animate attributeName="r" values="5;11;5" dur="2s" repeatCount="indefinite"/>
2648
+ <animate attributeName="opacity" values="0.6;0;0.6" dur="2s" repeatCount="indefinite"/>
2649
+ </circle>`
2650
+ : '';
2651
+
2652
+ nodes += `
2653
+ <g class="${nodeClass}" data-graph-sid="${esc(l.node.sessionId)}">
2654
+ <rect x="${l.x}" y="${l.y}" width="${NODE_W}" height="${nodeH}" rx="7"/>
2655
+ ${pulse}
2656
+ <circle cx="${l.x + 14}" cy="${l.y + 14}" r="5" fill="${dotColor}"/>
2657
+ <text x="${l.x + 26}" y="${l.y + 18}" font-size="12" font-weight="600" fill="#e6edf3">${esc(l.node.label.slice(0, 22))}</text>
2658
+ <text x="${l.x + 10}" y="${l.y + 36}" font-size="10" fill="#8b949e">${isSidechain ? 'inline agent' : toolCount + ' tool' + (toolCount !== 1 ? 's' : '') + (compactCount ? ` · ${compactCount}⇢` : '')}</text>
2659
+ <text x="${l.x + NODE_W - 10}" y="${l.y + 36}" font-size="10" fill="#3fb950" text-anchor="end">${cost}</text>
2660
+ <text x="${l.x + NODE_W - 10}" y="${l.y + 50}" font-size="9" fill="#6e7681" text-anchor="end">${duration}</text>
2661
+ ${typeBadge}
2662
+ </g>`;
2663
+
2664
+ for (const child of l.children) buildSvg(child);
2665
+ }
2666
+ buildSvg(layout);
2667
+
2668
+ const svgW = totalW + 48;
2669
+ const svgH = totalH + 48;
2670
+
2671
+ const prevSvg = wrap.querySelector('svg');
2672
+ if (!prevSvg) {
2673
+ // First render — reset transform and fit
2674
+ graphTx = 24; graphTy = 24; graphScale = 1;
2675
+ }
2676
+
2677
+ wrap.innerHTML = `<svg id="graph-svg" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}"
2678
+ xmlns="http://www.w3.org/2000/svg" style="display:block;overflow:visible;font-family:system-ui,sans-serif;cursor:grab">
2679
+ <style>
2680
+ .gnode rect { transition: fill 0.12s; }
2681
+ .gnode:hover rect { fill: #21262d !important; }
2682
+ .gnode.selected rect { fill: #1a2d45 !important; stroke: #58a6ff !important; stroke-width: 2 !important; }
2683
+ </style>
2684
+ <g id="graph-content" transform="translate(${graphTx.toFixed(1)},${graphTy.toFixed(1)}) scale(${graphScale.toFixed(3)})">
2685
+ ${edges}${nodes}
2686
+ </g>
2687
+ </svg>`;
2688
+
2689
+ // Bind pan/zoom/tooltip on the new SVG
2690
+ const svg = wrap.querySelector('svg');
2691
+ const tooltip = document.getElementById('graph-tooltip');
2692
+
2693
+ // Pan
2694
+ svg.addEventListener('mousedown', e => {
2695
+ if (e.target.closest('.gnode')) return;
2696
+ graphPanning = true;
2697
+ graphPanStart = { x: e.clientX - graphTx, y: e.clientY - graphTy };
2698
+ svg.style.cursor = 'grabbing';
2699
+ e.preventDefault();
2700
+ });
2701
+ if (graphDocMM) document.removeEventListener('mousemove', graphDocMM);
2702
+ if (graphDocMU) document.removeEventListener('mouseup', graphDocMU);
2703
+ graphDocMM = e => {
2704
+ if (!graphPanning) return;
2705
+ graphTx = e.clientX - graphPanStart.x;
2706
+ graphTy = e.clientY - graphPanStart.y;
2707
+ applyGraphTransform();
2708
+ };
2709
+ graphDocMU = () => {
2710
+ if (graphPanning) { graphPanning = false; if (svg) svg.style.cursor = 'grab'; }
2711
+ };
2712
+ document.addEventListener('mousemove', graphDocMM);
2713
+ document.addEventListener('mouseup', graphDocMU);
2714
+
2715
+ // Wheel zoom
2716
+ svg.addEventListener('wheel', e => {
2717
+ e.preventDefault();
2718
+ const rect = svg.getBoundingClientRect();
2719
+ const mx = e.clientX - rect.left;
2720
+ const my = e.clientY - rect.top;
2721
+ const delta = e.deltaY < 0 ? 0.1 : -0.1;
2722
+ const newScale = Math.max(0.3, Math.min(3, graphScale + delta));
2723
+ graphTx = mx - (mx - graphTx) * (newScale / graphScale);
2724
+ graphTy = my - (my - graphTy) * (newScale / graphScale);
2725
+ graphScale = newScale;
2726
+ applyGraphTransform();
2727
+ }, { passive: false });
2728
+
2729
+ // Hover tooltip
2730
+ svg.addEventListener('mouseover', e => {
2731
+ const g = e.target.closest('[data-graph-sid]');
2732
+ if (!g || !tooltip) return;
2733
+ const sid = g.dataset.graphSid;
2734
+ const entry = flatNodes.find(n => n.node.sessionId === sid);
2735
+ if (!entry) return;
2736
+ const n = entry.node;
2737
+ const statusLabel = n.status === 'running' ? '⟳ Running' : n.status === 'done' ? '✓ Done' : '✗ Error';
2738
+ const statusColor = n.status === 'running' ? '#d29922' : n.status === 'done' ? '#3fb950' : '#f85149';
2739
+ const lines = [
2740
+ `<div style="font-weight:600;color:var(--text);margin-bottom:6px">${esc(n.label)}</div>`,
2741
+ `<div style="color:${statusColor}">${statusLabel}</div>`,
2742
+ n.costUsd > 0 ? `<div>Cost: <span style="color:var(--green)">$${n.costUsd.toFixed(4)}</span></div>` : '',
2743
+ `<div>Tools: <span style="color:var(--muted)">${n.toolCalls?.length || 0}</span></div>`,
2744
+ n.tokens?.input ? `<div>Tokens: <span style="color:var(--dim)">${fmt(n.tokens.input + n.tokens.output)} in/out</span></div>` : '',
2745
+ n.endedAt ? `<div>Duration: <span style="color:var(--dim)">${dur(n.endedAt - n.startedAt)}</span></div>` : '',
2746
+ n.gitBranch ? `<div>Branch: <span style="color:var(--blue-soft)">${esc(n.gitBranch)}</span></div>` : '',
2747
+ n.permissionMode && n.permissionMode !== 'default' ? `<div>Mode: <span style="color:var(--yellow)">${esc(n.permissionMode)}</span></div>` : '',
2748
+ ].filter(Boolean).join('');
2749
+ tooltip.innerHTML = lines;
2750
+ tooltip.classList.add('visible');
2751
+ });
2752
+ svg.addEventListener('mousemove', e => {
2753
+ if (!tooltip?.classList.contains('visible')) return;
2754
+ tooltip.style.left = (e.clientX + 14) + 'px';
2755
+ tooltip.style.top = (e.clientY - 8) + 'px';
2756
+ });
2757
+ svg.addEventListener('mouseout', e => {
2758
+ if (!e.target.closest('[data-graph-sid]') && tooltip) tooltip.classList.remove('visible');
2759
+ });
2760
+ }
2761
+
2762
+ // ── Conversation thread ──────────────────────────────────────────────────
2763
+ const threadCache = new Map(); // sid → rendered HTML
2764
+
2765
+ async function loadThread(sid, containerId) {
2766
+ const el = document.getElementById(containerId);
2767
+ if (!el) return;
2768
+ el.innerHTML = '<div style="color:var(--dim);font-size:12px">Loading…</div>';
2769
+ try {
2770
+ const res = await fetch(`/api/sessions/${encodeURIComponent(sid)}/thread`);
2771
+ if (!res.ok) { el.innerHTML = '<div style="color:var(--muted);font-size:12px">No transcript available yet.</div>'; return; }
2772
+ const msgs = await res.json();
2773
+ if (!msgs.length) { el.innerHTML = '<div style="color:var(--dim);font-size:12px">No messages found.</div>'; return; }
2774
+ const html = msgs.map(m => {
2775
+ const isUser = m.role === 'user';
2776
+ const ts = m.timestamp ? new Date(m.timestamp).toLocaleTimeString() : '';
2777
+ return `<div style="margin-bottom:12px;${isUser ? 'padding-left:0' : 'padding-left:12px;border-left:2px solid var(--border-dim)'}">
2778
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
2779
+ <span style="font-size:10px;font-weight:700;color:${isUser ? 'var(--blue-soft)' : 'var(--purple)'};text-transform:uppercase;letter-spacing:0.05em">${isUser ? 'User' : 'Claude'}</span>
2780
+ ${ts ? `<span style="font-size:10px;color:var(--dim)">${ts}</span>` : ''}
2781
+ </div>
2782
+ <div style="font-size:12px;color:var(--muted);white-space:pre-wrap;word-break:break-word;line-height:1.6">${esc(m.text)}</div>
2783
+ </div>`;
2784
+ }).join('<div style="height:1px;background:var(--border-dim);margin:12px 0"></div>');
2785
+ if (threadCache.size >= 30) threadCache.delete(threadCache.keys().next().value); // evict oldest
2786
+ threadCache.set(sid, html);
2787
+ el.innerHTML = html;
2788
+ } catch(e) {
2789
+ el.innerHTML = '<div style="color:var(--red);font-size:12px">Failed to load thread.</div>';
2790
+ }
2791
+ }
2792
+
2793
+ // Auto-open graph when subagents are first detected
2794
+ let knownAgentCount = 0;
2795
+ function maybeAutoOpenGraph(root) {
2796
+ if (!root) return;
2797
+ function countAgents(n) {
2798
+ return 1 + (n.children || []).reduce((s, c) => s + countAgents(c), 0);
2799
+ }
2800
+ const count = countAgents(root);
2801
+ if (count > 1 && count !== knownAgentCount && !graphOpen) {
2802
+ knownAgentCount = count;
2803
+ openGraph();
2804
+ graphAutoOpened = true;
2805
+ } else {
2806
+ knownAgentCount = count;
2807
+ if (graphOpen) renderGraph(); // refresh graph on each update
2808
+ }
2809
+ }
2810
+ </script>
2811
+ </body>
2812
+ </html>