zozul-cli 0.1.1 → 0.2.1

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.
@@ -46,6 +46,16 @@
46
46
  .header h1 { font-size: 18px; font-weight: 600; }
47
47
  .header .subtitle { color: var(--text-dim); font-size: 13px; }
48
48
  .header-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
49
+ .source-badge {
50
+ font-size: 11px;
51
+ font-family: var(--mono);
52
+ padding: 3px 8px;
53
+ border-radius: 4px;
54
+ border: 1px solid var(--border);
55
+ color: var(--text-dim);
56
+ }
57
+ .source-badge.remote { border-color: var(--green); color: var(--green); }
58
+ .source-badge.local { border-color: var(--blue); color: var(--blue); }
49
59
  .auto-btn {
50
60
  background: none;
51
61
  border: 1px solid var(--border);
@@ -73,38 +83,66 @@
73
83
  width: 9px; height: 9px;
74
84
  }
75
85
 
86
+ /* Navigation tabs */
87
+ .nav-tabs {
88
+ display: flex;
89
+ gap: 0;
90
+ border-bottom: 1px solid var(--border);
91
+ padding: 0 24px;
92
+ background: var(--bg);
93
+ }
94
+ .nav-tab {
95
+ padding: 10px 20px;
96
+ font-size: 13px;
97
+ font-weight: 500;
98
+ color: var(--text-dim);
99
+ cursor: pointer;
100
+ border: none;
101
+ background: none;
102
+ border-bottom: 2px solid transparent;
103
+ transition: all 0.15s;
104
+ }
105
+ .nav-tab:hover { color: var(--text); }
106
+ .nav-tab.active { color: var(--accent-light); border-bottom-color: var(--accent); }
107
+
76
108
  .container { max-width: 1280px; margin: 0 auto; padding: 24px; }
77
109
 
78
- .stats-grid {
79
- display: grid;
80
- grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
81
- gap: 12px;
82
- margin-bottom: 28px;
110
+ /* Time window selector */
111
+ .time-selector {
112
+ display: flex;
113
+ gap: 4px;
114
+ margin-bottom: 24px;
115
+ align-items: center;
83
116
  }
84
- .stat-card {
117
+ .time-btn {
85
118
  background: var(--surface);
86
119
  border: 1px solid var(--border);
87
- border-radius: 8px;
88
- padding: 14px 16px;
120
+ color: var(--text-dim);
121
+ padding: 5px 14px;
122
+ border-radius: 5px;
123
+ cursor: pointer;
124
+ font-size: 12px;
125
+ font-family: var(--mono);
126
+ transition: all 0.15s;
89
127
  }
90
- .stat-card .label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.06em; }
91
- .stat-card .value { font-size: 22px; font-weight: 600; margin-top: 4px; font-variant-numeric: tabular-nums; font-family: var(--mono); }
128
+ .time-btn:hover { border-color: var(--accent); color: var(--text); }
129
+ .time-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
92
130
 
93
- .charts-grid {
131
+ .stats-grid {
94
132
  display: grid;
95
- grid-template-columns: 1fr 1fr;
133
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
96
134
  gap: 16px;
97
135
  margin-bottom: 28px;
98
136
  }
99
- .chart-card.full-width { grid-column: 1 / -1; }
100
- .chart-card {
137
+ .stat-card {
101
138
  background: var(--surface);
102
139
  border: 1px solid var(--border);
103
- border-radius: 8px;
104
- padding: 16px;
140
+ border-radius: 10px;
141
+ padding: 20px 24px;
105
142
  }
106
- .chart-card h3 { font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 12px; }
107
- .chart-card canvas { width: 100% !important; }
143
+ .stat-card .label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.06em; }
144
+ .stat-card .value { font-size: 28px; font-weight: 600; margin-top: 6px; font-variant-numeric: tabular-nums; font-family: var(--mono); }
145
+ .stat-card.hero .value { font-size: 36px; }
108
146
 
109
147
  .panel {
110
148
  background: var(--surface);
@@ -156,6 +194,51 @@
156
194
  tr:last-child td { border-bottom: none; }
157
195
  tr.clickable:hover td { background: rgba(124, 110, 240, 0.05); cursor: pointer; }
158
196
 
197
+ .tag-pill {
198
+ display: inline-block;
199
+ padding: 2px 8px;
200
+ border-radius: 4px;
201
+ font-size: 11px;
202
+ font-weight: 500;
203
+ font-family: var(--mono);
204
+ background: rgba(124, 110, 240, 0.12);
205
+ color: var(--accent-light);
206
+ margin-right: 4px;
207
+ margin-bottom: 2px;
208
+ }
209
+ .tag-pill.untagged {
210
+ background: rgba(139, 143, 163, 0.12);
211
+ color: var(--text-dim);
212
+ }
213
+
214
+ .badge {
215
+ display: inline-block;
216
+ padding: 2px 7px;
217
+ border-radius: 4px;
218
+ font-size: 11px;
219
+ font-weight: 500;
220
+ font-family: var(--mono);
221
+ }
222
+ .badge-cost { background: rgba(255, 152, 0, 0.12); color: var(--orange); }
223
+ .badge-tokens { background: rgba(124, 110, 240, 0.12); color: var(--accent-light); }
224
+
225
+ .empty { padding: 40px; text-align: center; color: var(--text-dim); font-size: 13px; }
226
+
227
+ .back-btn {
228
+ background: var(--surface);
229
+ border: 1px solid var(--border);
230
+ color: var(--text-dim);
231
+ padding: 5px 12px;
232
+ border-radius: 6px;
233
+ cursor: pointer;
234
+ font-size: 12px;
235
+ margin-bottom: 16px;
236
+ display: inline-flex;
237
+ align-items: center;
238
+ gap: 6px;
239
+ }
240
+ .back-btn:hover { border-color: var(--accent); color: var(--text); }
241
+
159
242
  .session-id {
160
243
  font-family: var(--mono);
161
244
  font-size: 12px;
@@ -168,6 +251,7 @@
168
251
  .session-id:hover { color: var(--accent-light); }
169
252
  .copy-icon { opacity: 0; font-size: 10px; transition: opacity 0.15s; }
170
253
  .session-id:hover .copy-icon { opacity: 1; }
254
+
171
255
  .copied-toast {
172
256
  position: fixed;
173
257
  bottom: 24px;
@@ -186,22 +270,8 @@
186
270
  }
187
271
  .copied-toast.show { opacity: 1; transform: translateY(0); }
188
272
 
189
- .session-detail { display: none; }
190
- .session-detail.active { display: block; }
191
- .back-btn {
192
- background: var(--surface);
193
- border: 1px solid var(--border);
194
- color: var(--text-dim);
195
- padding: 5px 12px;
196
- border-radius: 6px;
197
- cursor: pointer;
198
- font-size: 12px;
199
- margin-bottom: 16px;
200
- display: inline-flex;
201
- align-items: center;
202
- gap: 6px;
203
- }
204
- .back-btn:hover { border-color: var(--accent); color: var(--text); }
273
+ .view { display: none; }
274
+ .view.active { display: block; }
205
275
 
206
276
  .turn { border-bottom: 1px solid var(--border); }
207
277
  .turn:last-child { border-bottom: none; }
@@ -271,107 +341,83 @@
271
341
  line-height: 1.5;
272
342
  }
273
343
 
274
- .empty { padding: 40px; text-align: center; color: var(--text-dim); font-size: 13px; }
275
- .badge {
276
- display: inline-block;
277
- padding: 2px 7px;
278
- border-radius: 4px;
279
- font-size: 11px;
280
- font-weight: 500;
281
- font-family: var(--mono);
282
- }
283
- .badge-cost { background: rgba(255, 152, 0, 0.12); color: var(--orange); }
284
- .badge-tokens { background: rgba(124, 110, 240, 0.12); color: var(--accent-light); }
285
-
286
- .range-picker {
344
+ .pagination {
287
345
  display: flex;
288
- gap: 4px;
289
- margin-bottom: 8px;
290
- flex-wrap: wrap;
346
+ gap: 6px;
291
347
  align-items: center;
348
+ justify-content: center;
349
+ padding: 12px 16px;
350
+ border-top: 1px solid var(--border);
351
+ }
352
+
353
+ th.sortable { cursor: pointer; user-select: none; }
354
+ th.sortable:hover { color: var(--text); }
355
+ th.sortable::after {
356
+ content: '\2195';
357
+ margin-left: 4px;
358
+ font-size: 10px;
359
+ opacity: 0.3;
292
360
  }
293
- .range-btn {
361
+ th.sortable.asc::after { content: '\2191'; opacity: 1; }
362
+ th.sortable.desc::after { content: '\2193'; opacity: 1; }
363
+
364
+ .chart-card {
294
365
  background: var(--surface);
295
366
  border: 1px solid var(--border);
296
- color: var(--text-dim);
297
- padding: 4px 12px;
298
- border-radius: 5px;
299
- cursor: pointer;
300
- font-size: 12px;
301
- font-family: var(--mono);
302
- transition: all 0.15s;
367
+ border-radius: 8px;
368
+ padding: 16px;
369
+ margin-bottom: 20px;
303
370
  }
304
- .range-btn:hover { border-color: var(--accent); color: var(--text); }
305
- .range-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
306
- .range-divider { width: 1px; height: 20px; background: var(--border); margin: 0 4px; }
371
+ .chart-card h3 { font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 12px; }
372
+ .chart-card canvas { width: 100% !important; }
307
373
 
308
- .custom-range {
309
- display: none;
374
+ .project-list { list-style: none; }
375
+ .project-item {
376
+ display: flex;
310
377
  align-items: center;
311
- gap: 8px;
312
- margin-bottom: 16px;
313
- flex-wrap: wrap;
314
- }
315
- .custom-range.visible { display: flex; }
316
- .custom-range label {
317
- font-size: 11px;
318
- color: var(--text-dim);
319
- text-transform: uppercase;
320
- letter-spacing: 0.05em;
378
+ justify-content: space-between;
379
+ padding: 10px 16px;
380
+ border-bottom: 1px solid var(--border);
321
381
  }
322
- .custom-range input[type="datetime-local"],
323
- .custom-range select {
324
- background: var(--bg);
325
- border: 1px solid var(--border);
326
- border-radius: 5px;
327
- color: var(--text);
328
- font-size: 12px;
382
+ .project-item:last-child { border-bottom: none; }
383
+ .project-name {
329
384
  font-family: var(--mono);
330
- padding: 4px 8px;
331
- outline: none;
385
+ font-size: 13px;
386
+ overflow: hidden;
387
+ text-overflow: ellipsis;
388
+ white-space: nowrap;
389
+ flex: 1;
390
+ margin-right: 16px;
391
+ }
392
+ .project-bar-wrap {
393
+ width: 120px;
394
+ height: 6px;
395
+ background: var(--surface2);
396
+ border-radius: 3px;
397
+ margin-right: 12px;
398
+ flex-shrink: 0;
332
399
  }
333
- .custom-range input[type="datetime-local"]:focus,
334
- .custom-range select:focus { border-color: var(--accent); }
335
- .custom-range select { cursor: pointer; }
336
- .apply-btn {
400
+ .project-bar {
401
+ height: 100%;
402
+ border-radius: 3px;
337
403
  background: var(--accent);
338
- border: none;
339
- color: #fff;
340
- padding: 5px 14px;
341
- border-radius: 5px;
342
- cursor: pointer;
343
- font-size: 12px;
344
- font-family: var(--mono);
345
- font-weight: 600;
346
- transition: opacity 0.15s;
347
404
  }
348
- .apply-btn:hover { opacity: 0.85; }
349
-
350
- .tag-chip {
351
- display: inline-flex;
352
- align-items: center;
353
- gap: 4px;
354
- background: var(--surface2);
355
- border: 1px solid var(--border);
356
- border-radius: 5px;
357
- padding: 3px 10px;
358
- font-size: 11px;
405
+ .project-cost {
359
406
  font-family: var(--mono);
360
- color: var(--text-dim);
361
- cursor: pointer;
362
- user-select: none;
363
- transition: all 0.15s;
407
+ font-size: 12px;
408
+ color: var(--orange);
409
+ white-space: nowrap;
410
+ min-width: 70px;
411
+ text-align: right;
364
412
  }
365
- .tag-chip:hover { border-color: var(--accent); color: var(--text); }
366
- .tag-chip.selected { background: rgba(124,110,240,0.15); border-color: var(--accent); color: var(--accent-light); }
367
- .tag-chip input { display: none; }
368
413
 
369
414
  :not(button)[title] { cursor: help; }
370
415
 
371
416
  @media (max-width: 800px) {
372
- .charts-grid { grid-template-columns: 1fr; }
373
- .stats-grid { grid-template-columns: repeat(2, 1fr); }
417
+ .stats-grid { grid-template-columns: 1fr 1fr; }
374
418
  .panel-filter { width: 150px; }
419
+ .stat-card .value { font-size: 22px; }
420
+ .stat-card.hero .value { font-size: 28px; }
375
421
  }
376
422
  </style>
377
423
  </head>
@@ -381,121 +427,153 @@
381
427
  <h1>zozul</h1>
382
428
  <span class="subtitle">Agent Observability</span>
383
429
  <div class="header-right">
384
- <button class="auto-btn" onclick="manualRefresh()" title="Auto-refresh every 10s — click to refresh now">
430
+ <span class="source-badge" id="source-badge" style="display:none">Local</span>
431
+ <button class="auto-btn" onclick="manualRefresh()" title="Auto-refresh every 10s">
385
432
  <span class="live-dot"></span>
386
433
  Auto
387
434
  </button>
388
435
  </div>
389
436
  </div>
390
437
 
438
+ <div class="nav-tabs">
439
+ <button class="nav-tab active" data-view="summary">Summary</button>
440
+ <button class="nav-tab" data-view="tasks">Tasks</button>
441
+ <button class="nav-tab" data-view="tags">Tags</button>
442
+ <button class="nav-tab" data-view="sessions">Sessions</button>
443
+ </div>
444
+
391
445
  <div class="container">
392
- <!-- Main view -->
393
- <div id="main-view">
394
- <div class="stats-grid" id="stats-grid"></div>
395
- <div class="range-picker" id="range-picker">
396
- <button class="range-btn" data-range="1h">1h</button>
397
- <button class="range-btn" data-range="6h">6h</button>
398
- <button class="range-btn" data-range="24h">24h</button>
399
- <button class="range-btn active" data-range="7d">7d</button>
400
- <button class="range-btn" data-range="30d">30d</button>
401
- <button class="range-btn" data-range="90d">90d</button>
402
- <span class="range-divider"></span>
403
- <button class="range-btn" id="custom-range-toggle">Custom</button>
446
+ <!-- Summary View -->
447
+ <div id="view-summary" class="view active">
448
+ <div class="stats-grid" id="summary-stats"></div>
449
+ <div class="chart-card"><h3>Daily Cost (30d)</h3><canvas id="chart-daily-cost"></canvas></div>
450
+ <div class="panel">
451
+ <div class="panel-header"><span>Cost by Project</span></div>
452
+ <ul class="project-list" id="project-breakdown"></ul>
404
453
  </div>
405
- <div class="custom-range" id="custom-range">
406
- <label for="range-from">From</label>
407
- <input type="datetime-local" id="range-from">
408
- <label for="range-to">To</label>
409
- <input type="datetime-local" id="range-to">
410
- <label for="range-step">Resolution</label>
411
- <select id="range-step">
412
- <option value="auto">Auto</option>
413
- <option value="1m">1 min</option>
414
- <option value="5m">5 min</option>
415
- <option value="15m">15 min</option>
416
- <option value="1h">1 hour</option>
417
- <option value="6h">6 hours</option>
418
- <option value="1d">1 day</option>
419
- </select>
420
- <button class="apply-btn" id="apply-custom">Apply</button>
454
+ </div>
455
+
456
+ <!-- Sessions View -->
457
+ <div id="view-sessions" class="view">
458
+ <div class="panel">
459
+ <div class="panel-header">
460
+ <span>Sessions <span id="sessions-count" style="font-weight:400"></span></span>
461
+ <input class="panel-filter" id="session-filter" placeholder="filter by id or project..." oninput="filterSessions(this.value)">
462
+ </div>
463
+ <table>
464
+ <thead><tr>
465
+ <th class="sortable" data-sort="sessions:id">Session ID</th>
466
+ <th class="sortable" data-sort="sessions:project_path">Project</th>
467
+ <th class="sortable" data-sort="sessions:started_at">Started</th>
468
+ <th class="sortable" data-sort="sessions:total_duration_ms">Duration</th>
469
+ <th class="sortable" data-sort="sessions:total_turns">Turns</th>
470
+ <th class="sortable desc" data-sort="sessions:total_cost_usd">Cost</th>
471
+ <th class="sortable" data-sort="sessions:model">Model</th>
472
+ </tr></thead>
473
+ <tbody id="sessions-table"></tbody>
474
+ </table>
475
+ <div id="load-more-row" style="display:none;padding:12px 16px;text-align:center;border-top:1px solid var(--border)">
476
+ <button class="time-btn" onclick="loadMoreSessions()">Load more</button>
477
+ <span id="sessions-shown" style="font-size:12px;color:var(--text-dim);margin-left:12px"></span>
478
+ </div>
421
479
  </div>
422
- <div class="charts-grid">
423
- <div class="chart-card full-width"><h3 id="tokens-label">Tokens &amp; Cost (7d)</h3><canvas id="chart-tokens"></canvas></div>
480
+ </div>
481
+
482
+ <!-- Task View -->
483
+ <div id="view-tasks" class="view">
484
+ <div class="panel">
485
+ <div class="panel-header">
486
+ <span>Tasks</span>
487
+ <input class="panel-filter" id="task-filter" placeholder="filter tasks..." oninput="filterTaskTable(this.value)">
488
+ </div>
489
+ <table>
490
+ <thead><tr>
491
+ <th class="sortable" data-sort="tasks:tags">Task</th>
492
+ <th class="sortable" data-sort="tasks:total_duration_ms">Process Time</th>
493
+ <th class="sortable desc" data-sort="tasks:total_cost_usd">Cost</th>
494
+ <th class="sortable" data-sort="tasks:human_interventions">Human Interventions</th>
495
+ <th class="sortable" data-sort="tasks:last_seen">Last Active</th>
496
+ </tr></thead>
497
+ <tbody id="task-table"></tbody>
498
+ </table>
424
499
  </div>
425
- <div class="panel" id="tasks-panel" style="display:none">
500
+ </div>
501
+
502
+ <!-- Tag View -->
503
+ <div id="view-tags" class="view">
504
+ <div class="panel">
426
505
  <div class="panel-header">
427
- <span>Tasks <span id="active-context" style="font-weight:400;font-size:11px;font-family:var(--mono)"></span></span>
428
- <div style="display:flex;gap:8px;align-items:center">
429
- <input class="panel-filter" id="task-search" placeholder="search tags…" oninput="filterTagChips(this.value)" style="width:140px">
430
- <select class="panel-filter" id="task-time-range" onchange="onTaskTimeRange()" style="width:100px">
431
- <option value="">All time</option>
432
- <option value="1h">1h</option>
433
- <option value="6h">6h</option>
434
- <option value="24h">24h</option>
435
- <option value="7d">7d</option>
436
- <option value="30d">30d</option>
437
- </select>
438
- </div>
506
+ <span>Tags</span>
439
507
  </div>
440
- <div style="padding:12px 16px;display:flex;gap:6px;flex-wrap:wrap;border-bottom:1px solid var(--border)" id="task-chips"></div>
441
- <div id="task-stats-grid" class="stats-grid" style="padding:16px;margin-bottom:0"></div>
442
- <div id="turns-section" style="display:none">
443
- <div class="panel-header">
444
- <span>Turns <span id="turns-count" style="font-weight:400;font-size:11px;color:var(--text-dim)"></span></span>
445
- <div style="display:flex;gap:6px;align-items:center" id="turns-pagination"></div>
446
- </div>
447
- <table>
448
- <thead><tr>
449
- <th>Time</th>
450
- <th>Prompt</th>
451
- <th>Tags</th>
452
- <th>Duration</th>
453
- <th>Tokens</th>
454
- <th>Cost</th>
455
- </tr></thead>
456
- <tbody id="turns-table"></tbody>
457
- </table>
508
+ <table>
509
+ <thead><tr>
510
+ <th class="sortable" data-sort="tags:tag">Tag</th>
511
+ <th class="sortable" data-sort="tags:total_duration_ms">Total Time</th>
512
+ <th class="sortable desc" data-sort="tags:total_cost_usd">Total Cost</th>
513
+ <th class="sortable" data-sort="tags:human_interventions">Human Interventions</th>
514
+ <th class="sortable" data-sort="tags:last_seen">Last Active</th>
515
+ </tr></thead>
516
+ <tbody id="tag-table"></tbody>
517
+ </table>
518
+ </div>
519
+ </div>
520
+
521
+ <!-- Tag Detail View (drill-down from Tag View) -->
522
+ <div id="view-tag-detail" class="view">
523
+ <button class="back-btn" onclick="showView('tags')">&#8592; Tags <span style="font-size:10px;margin-left:4px;color:var(--text-dim)">[Esc]</span></button>
524
+ <div class="stats-grid" id="tag-detail-stats"></div>
525
+ <div class="panel">
526
+ <div class="panel-header">
527
+ <span>Turns <span id="tag-turns-count" style="font-weight:400;font-size:11px;color:var(--text-dim)"></span></span>
528
+ <div id="tag-turns-pagination" style="display:flex;gap:6px;align-items:center"></div>
458
529
  </div>
530
+ <table>
531
+ <thead><tr>
532
+ <th>Time</th>
533
+ <th>Prompt</th>
534
+ <th>Session</th>
535
+ <th>Cost</th>
536
+ </tr></thead>
537
+ <tbody id="tag-turns-table"></tbody>
538
+ </table>
459
539
  </div>
540
+ </div>
541
+
542
+ <!-- Task Group Detail View (drill-down from Task View) -->
543
+ <div id="view-task-detail" class="view">
544
+ <button class="back-btn" onclick="showView('tasks')">&#8592; Tasks <span style="font-size:10px;margin-left:4px;color:var(--text-dim)">[Esc]</span></button>
545
+ <div class="stats-grid" id="task-detail-stats"></div>
460
546
  <div class="panel">
461
547
  <div class="panel-header">
462
- <span>Sessions <span id="sessions-count" style="font-weight:400"></span></span>
463
- <input class="panel-filter" id="session-filter" placeholder="filter by id or project…" oninput="filterSessions(this.value)">
548
+ <span>Turns <span id="task-turns-count" style="font-weight:400;font-size:11px;color:var(--text-dim)"></span></span>
549
+ <div id="task-turns-pagination" style="display:flex;gap:6px;align-items:center"></div>
464
550
  </div>
465
551
  <table>
466
552
  <thead><tr>
467
- <th>Session ID</th>
468
- <th>Project</th>
469
- <th>Started</th>
553
+ <th>Time</th>
554
+ <th>Prompt</th>
470
555
  <th>Duration</th>
471
- <th>Turns</th>
472
- <th>In Tokens</th>
473
- <th>Out Tokens</th>
556
+ <th>Tokens</th>
474
557
  <th>Cost</th>
475
- <th>Model</th>
476
558
  </tr></thead>
477
- <tbody id="sessions-table"></tbody>
559
+ <tbody id="task-turns-table"></tbody>
478
560
  </table>
479
- <div id="load-more-row" style="display:none;padding:12px 16px;text-align:center;border-top:1px solid var(--border)">
480
- <button class="range-btn" onclick="loadMoreSessions()">Load more</button>
481
- <span id="sessions-shown" style="font-size:12px;color:var(--text-dim);margin-left:12px"></span>
482
- </div>
483
561
  </div>
484
562
  </div>
485
563
 
486
- <!-- Session detail view -->
487
- <div id="detail-view" class="session-detail">
488
- <button class="back-btn" onclick="showMain()">&#8592; Sessions <span style="font-size:10px;margin-left:4px;color:var(--text-dim)">[Esc]</span></button>
489
- <div class="stats-grid" id="detail-stats"></div>
564
+ <!-- Session Detail View -->
565
+ <div id="view-session-detail" class="view">
566
+ <button class="back-btn" id="session-back-btn" onclick="showView('tasks')">&#8592; Back <span style="font-size:10px;margin-left:4px;color:var(--text-dim)">[Esc]</span></button>
567
+ <div class="stats-grid" id="session-detail-stats"></div>
490
568
  <div class="panel">
491
569
  <div class="panel-header">Conversation</div>
492
- <div id="detail-turns"></div>
570
+ <div id="session-detail-turns"></div>
493
571
  </div>
494
572
  </div>
495
573
 
496
- <!-- Turn detail view -->
497
- <div id="turn-detail-view" class="session-detail">
498
- <button class="back-btn" onclick="showMain()">&#8592; Turns <span style="font-size:10px;margin-left:4px;color:var(--text-dim)">[Esc]</span></button>
574
+ <!-- Turn Block Detail View -->
575
+ <div id="view-turn-detail" class="view">
576
+ <button class="back-btn" id="turn-back-btn" onclick="goBackFromTurn()">&#8592; Back <span style="font-size:10px;margin-left:4px;color:var(--text-dim)">[Esc]</span></button>
499
577
  <div class="stats-grid" id="turn-detail-stats"></div>
500
578
  <div class="panel">
501
579
  <div class="panel-header">Conversation</div>
@@ -507,45 +585,132 @@
507
585
  <div class="copied-toast" id="copied-toast">Copied!</div>
508
586
 
509
587
  <script>
510
- let chartInstances = {};
588
+ // ── State ──
589
+
590
+ let dataSource = 'local';
591
+ let autoRefreshTimer = null;
592
+ let currentView = 'summary';
593
+ let currentTimeWindow = '7d';
594
+ let previousView = 'tasks';
595
+ let allTaskGroups = [];
596
+ let allTagStats = [];
597
+ let sortState = {
598
+ tasks: { key: 'total_cost_usd', dir: 'desc' },
599
+ tags: { key: 'total_cost_usd', dir: 'desc' },
600
+ sessions: { key: 'started_at', dir: 'desc' },
601
+ };
511
602
  let allSessions = [];
512
603
  let sessionsTotal = 0;
513
604
  let sessionsOffset = 0;
514
605
  const SESSIONS_PAGE = 50;
515
- let autoRefreshTimer = null;
516
- let currentRange = '7d';
517
- let customFrom = null;
518
- let customTo = null;
519
- let customStep = 'auto';
520
-
521
- function chartQueryString() {
522
- if (customFrom && customTo) {
523
- const qs = 'from=' + encodeURIComponent(customFrom) + '&to=' + encodeURIComponent(customTo);
524
- return customStep !== 'auto' ? qs + '&step=' + customStep : qs;
606
+ let chartInstances = {};
607
+ let tagDetailName = '';
608
+ let tagDetailOffset = 0;
609
+ let taskDetailTags = [];
610
+ let taskDetailOffset = 0;
611
+ const PAGE_SIZE = 20;
612
+ const AUTO_REFRESH_INTERVAL = 10_000;
613
+
614
+ // ── Data source auto-detection ──
615
+
616
+ async function detectDataSource() {
617
+ const badge = document.getElementById('source-badge');
618
+ if (typeof ZOZUL_CONFIG === 'undefined') {
619
+ dataSource = 'local';
620
+ badge.textContent = 'Local';
621
+ badge.className = 'source-badge local';
622
+ badge.style.display = '';
623
+ return;
525
624
  }
526
- return 'range=' + currentRange;
527
- }
528
625
 
529
- function chartLabel() {
530
- if (customFrom && customTo) {
531
- const f = new Date(customFrom);
532
- const t = new Date(customTo);
533
- const fmt = d => d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
534
- return fmt(f) + ' \u2013 ' + fmt(t);
626
+ try {
627
+ const controller = new AbortController();
628
+ const timeout = setTimeout(() => controller.abort(), 3000);
629
+ const healthUrl = ZOZUL_CONFIG.remote.baseUrl + '/health';
630
+ const res = await fetch(healthUrl, {
631
+ signal: controller.signal,
632
+ headers: { 'X-API-Key': ZOZUL_CONFIG.remote.apiKey },
633
+ });
634
+ clearTimeout(timeout);
635
+ if (res.ok) {
636
+ dataSource = 'remote';
637
+ badge.textContent = 'Remote';
638
+ badge.className = 'source-badge remote';
639
+ } else {
640
+ dataSource = 'local';
641
+ badge.textContent = 'Local';
642
+ badge.className = 'source-badge local';
643
+ }
644
+ } catch {
645
+ dataSource = 'local';
646
+ badge.textContent = 'Local';
647
+ badge.className = 'source-badge local';
535
648
  }
536
- return currentRange;
649
+ badge.style.display = '';
537
650
  }
538
651
 
539
652
  async function fetchJson(path) {
653
+ if (dataSource === 'remote' && typeof ZOZUL_CONFIG !== 'undefined') {
654
+ try {
655
+ const url = ZOZUL_CONFIG.remote.baseUrl + path.replace('/api/', '/');
656
+ const res = await fetch(url, { headers: { 'X-API-Key': ZOZUL_CONFIG.remote.apiKey } });
657
+ if (res.ok) return res.json();
658
+ } catch {}
659
+ // Remote failed — fall back to local for this request
660
+ }
540
661
  const res = await fetch(path);
541
662
  if (!res.ok) throw new Error(res.status + ' ' + path);
542
663
  return res.json();
543
664
  }
544
665
 
666
+ // ── Client-side proportional cost ──
667
+
668
+ const sessionCache = {};
669
+
670
+ async function fetchSessionCached(sessionId) {
671
+ if (sessionCache[sessionId]) return sessionCache[sessionId];
672
+ const session = await fetchJson('/api/sessions/' + sessionId);
673
+ sessionCache[sessionId] = session;
674
+ return session;
675
+ }
676
+
677
+ function proportionalCost(session, turn) {
678
+ const hasCache = turn.cache_read !== undefined && turn.cache_read !== null;
679
+ const sessionTotal = (session.total_input_tokens || 0) + (session.total_output_tokens || 0)
680
+ + (hasCache ? (session.total_cache_read_tokens || 0) + (session.total_cache_creation_tokens || 0) : 0);
681
+ if (sessionTotal === 0 || !session.total_cost_usd) return 0;
682
+ const turnTotal = (turn.input || 0) + (turn.output || 0)
683
+ + (hasCache ? (turn.cache_read || 0) + (turn.cache_creation || 0) : 0);
684
+ return session.total_cost_usd * turnTotal / sessionTotal;
685
+ }
686
+
687
+ async function enrichTurnsWithCost(turns) {
688
+ const ids = [...new Set(turns.map(t => t.session_id).filter(Boolean))];
689
+ await Promise.all(ids.map(fetchSessionCached));
690
+ for (const t of turns) {
691
+ const s = sessionCache[t.session_id];
692
+ if (!s) continue;
693
+ t.estimated_cost_usd = proportionalCost(s, {
694
+ input: t.input_tokens, output: t.output_tokens,
695
+ cache_read: t.cache_read_tokens, cache_creation: t.cache_creation_tokens,
696
+ });
697
+ if (t.block_input_tokens !== undefined) {
698
+ t.block_cost_usd = proportionalCost(s, {
699
+ input: t.block_input_tokens, output: t.block_output_tokens,
700
+ cache_read: t.block_cache_read_tokens, cache_creation: t.block_cache_creation_tokens,
701
+ });
702
+ }
703
+ }
704
+ return turns;
705
+ }
706
+
545
707
  // ── Formatting ──
546
708
 
547
709
  function fmtNum(n) { return (n ?? 0).toLocaleString(); }
548
- function fmtCost(n) { return '$' + (n ?? 0).toFixed(4); }
710
+ function fmtCost(n) {
711
+ const v = n ?? 0;
712
+ return v >= 1 ? '$' + v.toFixed(2) : '$' + v.toFixed(4);
713
+ }
549
714
  function fmtDuration(ms) {
550
715
  if (!ms || ms <= 0) return '\u2014';
551
716
  const s = Math.floor(ms / 1000);
@@ -570,6 +735,18 @@ function fmtAbsolute(s) {
570
735
  if (!s) return '';
571
736
  try { return new Date(s).toLocaleString(); } catch { return s; }
572
737
  }
738
+ // SQLite datetime() returns "YYYY-MM-DD HH:MM:SS" with no timezone — append Z to parse as UTC
739
+ function sqliteUtcToLocal(s) {
740
+ return new Date(s.replace(' ', 'T') + 'Z');
741
+ }
742
+ function fmtChartTs(s, span) {
743
+ const d = sqliteUtcToLocal(s);
744
+ if (span && span <= 24 * 3600000) {
745
+ return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
746
+ }
747
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' +
748
+ d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
749
+ }
573
750
  function shortId(s) { return s ? s.slice(0, 8) : '\u2014'; }
574
751
  function basename(p) {
575
752
  if (!p) return '\u2014';
@@ -581,8 +758,6 @@ function escHtml(s) {
581
758
  return d.innerHTML;
582
759
  }
583
760
 
584
- // ── Copy to clipboard ──
585
-
586
761
  function copyText(text) {
587
762
  navigator.clipboard.writeText(text).then(() => {
588
763
  const toast = document.getElementById('copied-toast');
@@ -591,280 +766,521 @@ function copyText(text) {
591
766
  });
592
767
  }
593
768
 
594
- // ── Dashboard load ──
769
+ // ── Navigation ──
595
770
 
596
- async function loadDashboard() {
597
- const btn = document.querySelector('.auto-btn');
598
- btn?.classList.add('loading');
599
- const minSpinner = new Promise(r => setTimeout(r, 400));
600
- try {
601
- const qs = chartQueryString();
602
- const [stats, sessionsResp, tokens, cost, tasks, activeCtx] = await Promise.all([
603
- fetchJson('/api/stats'),
604
- fetchJson('/api/sessions?limit=' + SESSIONS_PAGE + '&offset=0'),
605
- fetchJson('/api/metrics/tokens?' + qs),
606
- fetchJson('/api/metrics/cost?' + qs),
607
- fetchJson('/api/tasks'),
608
- fetchJson('/api/context'),
609
- ]);
610
- // Handle both paginated { sessions, total } and legacy plain-array responses
611
- const sessionsArr = Array.isArray(sessionsResp) ? sessionsResp : sessionsResp.sessions;
612
- allSessions = sessionsArr;
613
- sessionsTotal = Array.isArray(sessionsResp) ? sessionsArr.length : sessionsResp.total;
614
- sessionsOffset = allSessions.length;
615
- renderStats(stats);
616
- renderTasks(tasks, activeCtx);
617
- renderSessions(allSessions);
618
- updateLoadMore();
619
- renderTokenCostChart(tokens, cost);
620
- } catch (e) {
621
- console.error('load failed', e);
622
- } finally {
623
- await minSpinner;
624
- btn?.classList.remove('loading');
771
+ function showView(name) {
772
+ currentView = name;
773
+ document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
774
+ const el = document.getElementById('view-' + name);
775
+ if (el) el.classList.add('active');
776
+
777
+ // Update tab highlight for main views
778
+ document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
779
+ const mainView = ['summary', 'sessions', 'tasks', 'tags'].includes(name) ? name : null;
780
+ if (mainView) {
781
+ const tab = document.querySelector('.nav-tab[data-view="' + mainView + '"]');
782
+ if (tab) tab.classList.add('active');
625
783
  }
626
784
  }
627
785
 
628
- function manualRefresh() {
629
- if (autoRefreshTimer) { clearTimeout(autoRefreshTimer); autoRefreshTimer = null; }
630
- loadDashboard().then(scheduleAutoRefresh);
786
+ document.querySelector('.nav-tabs').addEventListener('click', e => {
787
+ const tab = e.target.closest('.nav-tab');
788
+ if (!tab) return;
789
+ const view = tab.dataset.view;
790
+ showView(view);
791
+ loadViewData(view);
792
+ });
793
+
794
+ document.addEventListener('keydown', e => {
795
+ if (e.key === 'Escape') {
796
+ if (['tag-detail', 'task-detail'].includes(currentView)) {
797
+ showView(previousView || 'tasks');
798
+ } else if (['session-detail', 'turn-detail'].includes(currentView)) {
799
+ goBackFromTurn();
800
+ }
801
+ }
802
+ });
803
+
804
+ document.addEventListener('click', e => {
805
+ const th = e.target.closest('th.sortable');
806
+ if (th) {
807
+ const [table, key] = th.dataset.sort.split(':');
808
+ const state = sortState[table];
809
+ if (state.key === key) {
810
+ state.dir = state.dir === 'asc' ? 'desc' : 'asc';
811
+ } else {
812
+ state.key = key;
813
+ state.dir = key === 'tags' || key === 'tag' ? 'asc' : 'desc';
814
+ }
815
+ // Update header indicators
816
+ th.closest('thead').querySelectorAll('th.sortable').forEach(h => {
817
+ h.classList.remove('asc', 'desc');
818
+ const [t, k] = h.dataset.sort.split(':');
819
+ if (k === state.key) h.classList.add(state.dir);
820
+ });
821
+ if (table === 'tasks') renderTaskTable(sortData(allTaskGroups, state));
822
+ else if (table === 'tags') renderTagTable(sortData(allTagStats, state));
823
+ else if (table === 'sessions') renderSessionsTable(sortData(allSessions, state));
824
+ return;
825
+ }
826
+ });
827
+
828
+ function sortData(arr, state) {
829
+ const { key, dir } = state;
830
+ return [...arr].sort((a, b) => {
831
+ let va = a[key], vb = b[key];
832
+ if (typeof va === 'string' && typeof vb === 'string') {
833
+ va = va.toLowerCase(); vb = vb.toLowerCase();
834
+ return dir === 'asc' ? va.localeCompare(vb) : vb.localeCompare(va);
835
+ }
836
+ va = va ?? 0; vb = vb ?? 0;
837
+ return dir === 'asc' ? va - vb : vb - va;
838
+ });
631
839
  }
632
840
 
633
- async function loadMoreSessions() {
841
+ // ── Summary View ──
842
+
843
+ async function loadSummary() {
634
844
  try {
635
- const resp = await fetchJson('/api/sessions?limit=' + SESSIONS_PAGE + '&offset=' + sessionsOffset);
636
- const page = Array.isArray(resp) ? resp : resp.sessions;
637
- allSessions = allSessions.concat(page);
638
- sessionsTotal = Array.isArray(resp) ? allSessions.length : resp.total;
639
- sessionsOffset = allSessions.length;
640
- const q = document.getElementById('session-filter').value;
641
- q ? filterSessions(q) : renderSessions(allSessions);
642
- updateLoadMore();
845
+ const [stats, tasks, costSeries, sessionsResp] = await Promise.all([
846
+ fetchJson('/api/stats'),
847
+ fetchJson('/api/tasks'),
848
+ fetchJson('/api/metrics/cost?range=' + (currentTimeWindow === 'all' ? '90d' : currentTimeWindow) + '&step=1d'),
849
+ fetchJson('/api/sessions?limit=500&offset=0'),
850
+ ]);
851
+ renderSummaryStats({
852
+ totalCost: stats.total_cost_usd ?? 0,
853
+ totalSessions: stats.total_sessions ?? 0,
854
+ totalTasks: tasks.length ?? 0,
855
+ });
856
+ renderDailyCostChart(costSeries);
857
+ const sessions = Array.isArray(sessionsResp) ? sessionsResp : sessionsResp.sessions;
858
+ renderProjectBreakdown(sessions);
643
859
  } catch (e) {
644
- console.error('load more failed', e);
860
+ console.error('summary load failed', e);
645
861
  }
646
862
  }
647
863
 
648
- function updateLoadMore() {
649
- const row = document.getElementById('load-more-row');
650
- const shown = document.getElementById('sessions-shown');
651
- const count = document.getElementById('sessions-count');
652
- count.textContent = '(' + sessionsTotal + ')';
653
- if (sessionsTotal > sessionsOffset) {
654
- row.style.display = '';
655
- shown.textContent = sessionsOffset + ' of ' + sessionsTotal + ' shown';
656
- } else {
657
- row.style.display = 'none';
658
- }
659
- }
864
+ function renderDailyCostChart(costData) {
865
+ if (!costData || !costData.length) return;
866
+ const labels = costData.map(d => {
867
+ const dt = new Date(d.timestamp);
868
+ return dt.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
869
+ });
870
+ const data = costData.map(d => d.cost);
660
871
 
661
- const AUTO_REFRESH_INTERVAL = 10_000; // ms
872
+ const existing = chartInstances['chart-daily-cost'];
873
+ if (existing) {
874
+ existing.data.labels = labels;
875
+ existing.data.datasets[0].data = data;
876
+ existing.update('none');
877
+ return;
878
+ }
662
879
 
663
- function scheduleAutoRefresh() {
664
- autoRefreshTimer = setTimeout(async () => {
665
- await loadDashboard();
666
- scheduleAutoRefresh();
667
- }, AUTO_REFRESH_INTERVAL);
880
+ const ctx = document.getElementById('chart-daily-cost');
881
+ if (!ctx) return;
882
+ const chart = new Chart(ctx, {
883
+ type: 'bar',
884
+ data: {
885
+ labels,
886
+ datasets: [{
887
+ label: 'Cost (USD)',
888
+ data,
889
+ backgroundColor: 'rgba(255,152,0,0.3)',
890
+ borderColor: '#ff9800',
891
+ borderWidth: 1,
892
+ borderRadius: 3,
893
+ }]
894
+ },
895
+ options: {
896
+ responsive: true,
897
+ plugins: {
898
+ legend: { display: false },
899
+ tooltip: { callbacks: { label: ctx => '$' + ctx.parsed.y.toFixed(4) } },
900
+ },
901
+ scales: {
902
+ x: { ticks: { color: '#8b8fa3', font: { family: '"JetBrains Mono", monospace', size: 10 } } },
903
+ y: { ticks: { color: '#8b8fa3', font: { family: '"JetBrains Mono", monospace', size: 10 }, callback: v => '$' + v.toFixed(2) } },
904
+ }
905
+ }
906
+ });
907
+ chartInstances['chart-daily-cost'] = chart;
668
908
  }
669
909
 
670
- // ── Stats ──
910
+ function renderProjectBreakdown(sessions) {
911
+ const el = document.getElementById('project-breakdown');
912
+ if (!sessions || !sessions.length) {
913
+ el.innerHTML = '<li class="empty">No sessions.</li>';
914
+ return;
915
+ }
916
+ // Group by project
917
+ const byProject = {};
918
+ for (const s of sessions) {
919
+ const proj = s.project_path || 'Unknown';
920
+ if (!byProject[proj]) byProject[proj] = 0;
921
+ byProject[proj] += s.total_cost_usd || 0;
922
+ }
923
+ const sorted = Object.entries(byProject).sort((a, b) => b[1] - a[1]);
924
+ const maxCost = sorted[0]?.[1] || 1;
925
+
926
+ el.innerHTML = sorted.slice(0, 10).map(([proj, cost]) => {
927
+ const pct = Math.round(cost / maxCost * 100);
928
+ return '<li class="project-item">' +
929
+ '<span class="project-name" title="' + escHtml(proj) + '">' + escHtml(basename(proj)) + '</span>' +
930
+ '<div class="project-bar-wrap"><div class="project-bar" style="width:' + pct + '%"></div></div>' +
931
+ '<span class="project-cost">' + fmtCost(cost) + '</span>' +
932
+ '</li>';
933
+ }).join('');
934
+ }
671
935
 
672
- function renderStats(s) {
673
- const grid = document.getElementById('stats-grid');
936
+ function renderSummaryStats(s) {
937
+ const grid = document.getElementById('summary-stats');
674
938
  grid.innerHTML = [
675
- { label: 'Sessions', value: fmtNum(s.total_sessions) },
676
- { label: 'User Prompts', value: fmtNum(s.total_user_prompts), title: 'UserPromptSubmit hook events' },
677
- { label: 'Interruptions', value: fmtNum(s.total_interruptions), title: 'Times user stopped Claude mid-run' },
678
- { label: 'Input Tokens', value: fmtNum(s.total_input_tokens) },
679
- { label: 'Output Tokens', value: fmtNum(s.total_output_tokens) },
680
- { label: 'Cache Read', value: fmtNum(s.total_cache_read_tokens) },
681
- { label: 'Total Cost', value: fmtCost(s.total_cost_usd) },
939
+ { label: 'Total Cost', value: fmtCost(s.totalCost), hero: true },
940
+ { label: 'Total Sessions', value: fmtNum(s.totalSessions), hero: true },
941
+ { label: 'Total Tasks', value: fmtNum(s.totalTasks), hero: true },
682
942
  ].map(c =>
683
- '<div class="stat-card"' + (c.title ? ' title="' + c.title + '"' : '') + '>' +
943
+ '<div class="stat-card' + (c.hero ? ' hero' : '') + '">' +
684
944
  '<div class="label">' + c.label + '</div>' +
685
945
  '<div class="value">' + c.value + '</div>' +
686
946
  '</div>'
687
947
  ).join('');
688
948
  }
689
949
 
690
- // ── Tasks ──
691
-
692
- let allTasks = [];
693
- let selectedTags = new Set();
694
- let taskTurnsOffset = 0;
695
- const TURNS_PAGE = 10;
950
+ // ── Task View ──
696
951
 
697
- function getTaskTimeRange() {
698
- const val = document.getElementById('task-time-range').value;
699
- if (!val) return {};
700
- const now = new Date();
701
- const ms = val.endsWith('h') ? parseInt(val) * 3600000 : parseInt(val) * 86400000;
702
- return { from: new Date(now.getTime() - ms).toISOString(), to: now.toISOString() };
703
- }
952
+ async function loadTasks() {
953
+ try {
954
+ const [stats, tasks] = await Promise.all([
955
+ fetchJson('/api/stats'),
956
+ fetchJson('/api/tasks'),
957
+ ]);
958
+ if (!tasks.length) { allTaskGroups = []; renderTaskTable([]); return; }
704
959
 
705
- async function renderTasks(tasks, activeCtx) {
706
- const panel = document.getElementById('tasks-panel');
707
- const ctxEl = document.getElementById('active-context');
960
+ // Discover tag combinations by sampling one turn per tag
961
+ const samples = await Promise.all(
962
+ tasks.map(t => fetchJson('/api/tasks/turns?tags=' + encodeURIComponent(t.task) + '&mode=any&limit=1')
963
+ .then(turns => turns[0]?.tags || t.task)
964
+ .catch(() => t.task)
965
+ )
966
+ );
967
+ // Deduplicate: normalize each combo to sorted pipe-separated
968
+ const combos = [...new Set(samples.map(s => s.split(', ').sort().join('|')))];
969
+
970
+ // Fetch stats for each unique combo
971
+ const comboStats = await Promise.all(
972
+ combos.map(combo => {
973
+ const tags = combo.split('|');
974
+ const qs = 'tags=' + tags.map(encodeURIComponent).join(',') + '&mode=all';
975
+ return fetchJson('/api/tasks/stats?' + qs)
976
+ .then(s => ({ ...s, tags: combo }))
977
+ .catch(() => ({ tags: combo, total_turns: 0, user_turns: 0, total_duration_ms: 0, total_cost_usd: 0 }));
978
+ })
979
+ );
980
+
981
+ let taskGroups = comboStats.map(s => ({
982
+ tags: s.tags,
983
+ turn_count: s.total_turns ?? 0,
984
+ human_interventions: s.user_turns ?? 0,
985
+ total_duration_ms: s.total_duration_ms ?? 0,
986
+ total_cost_usd: s.total_cost_usd ?? 0,
987
+ last_seen: s.last_seen ?? null,
988
+ }));
989
+
990
+ // Add cost gap to the Untagged row so task costs sum to total
991
+ const attributedCost = taskGroups.reduce((s, g) => s + (g.total_cost_usd || 0), 0);
992
+ const totalCost = stats.total_cost_usd || 0;
993
+ const gap = totalCost - attributedCost;
994
+ if (gap > 0.01) {
995
+ const untagged = taskGroups.find(g => g.tags === 'Untagged');
996
+ if (untagged) {
997
+ untagged.total_cost_usd = (untagged.total_cost_usd || 0) + gap;
998
+ } else {
999
+ taskGroups.push({
1000
+ tags: 'Untagged',
1001
+ turn_count: 0,
1002
+ human_interventions: 0,
1003
+ total_duration_ms: 0,
1004
+ total_cost_usd: gap,
1005
+ last_seen: null,
1006
+ });
1007
+ }
1008
+ }
708
1009
 
709
- if (activeCtx && activeCtx.active) {
710
- const tags = Array.isArray(activeCtx.active) ? activeCtx.active : [activeCtx.active];
711
- ctxEl.innerHTML = tags.map(t =>
712
- '<span style="display:inline-block;background:rgba(124,110,240,0.15);color:var(--accent-light);padding:1px 8px;border-radius:4px;margin-left:4px;font-size:11px">' + escHtml(t) + '</span>'
713
- ).join('');
714
- } else {
715
- ctxEl.textContent = '';
1010
+ allTaskGroups = taskGroups;
1011
+ renderTaskTable(sortData(taskGroups, sortState.tasks));
1012
+ } catch (e) {
1013
+ console.error('tasks load failed', e);
716
1014
  }
1015
+ }
717
1016
 
718
- if (!tasks.length) {
719
- panel.style.display = 'none';
1017
+ function renderTaskTable(groups) {
1018
+ const tbody = document.getElementById('task-table');
1019
+ if (!groups.length) {
1020
+ tbody.innerHTML = '<tr><td colspan="5" class="empty">No tasks yet. Tag turns with <code>zozul tag</code> to see them here.</td></tr>';
720
1021
  return;
721
1022
  }
722
-
723
- panel.style.display = '';
724
- allTasks = tasks;
725
- renderTagChips('');
726
- await loadTaskData();
727
- }
728
-
729
- function renderTagChips(search) {
730
- const chipsEl = document.getElementById('task-chips');
731
- const q = search.toLowerCase();
732
- const filtered = q ? allTasks.filter(t => t.task.toLowerCase().includes(q)) : allTasks;
733
- chipsEl.innerHTML = filtered.map(t => {
734
- const sel = selectedTags.has(t.task) ? ' selected' : '';
735
- return '<label class="tag-chip' + sel + '" data-tag="' + escHtml(t.task) + '" onclick="toggleTag(this)">' +
736
- escHtml(t.task) + ' <span style="color:var(--text-dim);font-size:10px">(' + t.turn_count + ')</span></label>';
1023
+ tbody.innerHTML = groups.map(g => {
1024
+ const tags = g.tags.split('|');
1025
+ const pills = tags.map(t =>
1026
+ '<span class="tag-pill' + (t === 'Untagged' ? ' untagged' : '') + '">' + escHtml(t) + '</span>'
1027
+ ).join('');
1028
+ return '<tr class="clickable" onclick="showTaskDetail(\'' + escHtml(g.tags).replace(/'/g, "\\'") + '\')">' +
1029
+ '<td>' + pills + '</td>' +
1030
+ '<td>' + fmtDuration(g.total_duration_ms) + '</td>' +
1031
+ '<td><span class="badge badge-cost">' + fmtCost(g.total_cost_usd) + '</span></td>' +
1032
+ '<td>' + fmtNum(g.human_interventions) + '</td>' +
1033
+ '<td title="' + fmtAbsolute(g.last_seen) + '">' + fmtRelative(g.last_seen) + '</td>' +
1034
+ '</tr>';
737
1035
  }).join('');
738
1036
  }
739
1037
 
740
- function filterTagChips(q) { renderTagChips(q); }
741
-
742
- function toggleTag(el) {
743
- const tag = el.dataset.tag;
744
- if (selectedTags.has(tag)) {
745
- selectedTags.delete(tag);
746
- el.classList.remove('selected');
747
- } else {
748
- selectedTags.add(tag);
749
- el.classList.add('selected');
750
- }
751
- loadTaskData();
1038
+ function filterTaskTable(q) {
1039
+ q = q.toLowerCase();
1040
+ const filtered = q
1041
+ ? allTaskGroups.filter(g => g.tags.toLowerCase().includes(q))
1042
+ : allTaskGroups;
1043
+ renderTaskTable(sortData(filtered, sortState.tasks));
752
1044
  }
753
1045
 
754
- function onTaskTimeRange() { loadTaskData(); }
1046
+ // ── Task Group Detail ──
755
1047
 
756
- function taskQueryParams() {
757
- const selected = Array.from(selectedTags);
758
- const tags = selected.length > 0 ? selected : allTasks.map(t => t.task);
759
- const mode = selected.length > 0 ? 'all' : 'any';
760
- const { from, to } = getTaskTimeRange();
761
- let qs = 'tags=' + tags.map(encodeURIComponent).join(',') + '&mode=' + mode;
762
- if (from && to) qs += '&from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to);
763
- return { qs, tags, label: selected.length === 0 ? 'All tags' : selected.join(' + ') };
764
- }
1048
+ async function showTaskDetail(tagSet) {
1049
+ previousView = 'tasks';
1050
+ showView('task-detail');
765
1051
 
766
- async function loadTaskData() {
767
- taskTurnsOffset = 0;
768
- const { qs, tags, label } = taskQueryParams();
769
- if (tags.length === 0) return;
1052
+ const tags = tagSet.split('|');
1053
+ taskDetailTags = tags;
1054
+ taskDetailOffset = 0;
770
1055
 
771
- const selected = Array.from(selectedTags);
772
- const fetches = [fetchJson('/api/tasks/stats?' + qs)];
773
- // Only fetch turns if specific tags are selected
774
- if (selected.length > 0) {
775
- fetches.push(fetchJson('/api/tasks/turns?' + qs + '&limit=' + TURNS_PAGE + '&offset=0'));
776
- }
777
- const [stats, turns] = await Promise.all(fetches);
1056
+ const statsEl = document.getElementById('task-detail-stats');
1057
+ const pills = tags.map(t =>
1058
+ '<span class="tag-pill' + (t === 'Untagged' ? ' untagged' : '') + '">' + escHtml(t) + '</span>'
1059
+ ).join(' ');
1060
+ statsEl.innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading...</div></div>';
778
1061
 
779
- renderTaskStats(stats, label);
780
- if (selected.length > 0 && turns) {
781
- renderTurnsTable(turns, false);
782
- taskTurnsOffset = turns.length;
783
- } else {
784
- document.getElementById('turns-section').style.display = 'none';
1062
+ if (tags.length === 1 && tags[0] === 'Untagged') {
1063
+ statsEl.innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Task</div><div class="value" style="font-size:16px">' + pills + '</div></div>';
1064
+ document.getElementById('task-turns-table').innerHTML = '<tr><td colspan="5" class="empty">Untagged turns cannot be drilled into.</td></tr>';
1065
+ document.getElementById('task-turns-pagination').innerHTML = '';
1066
+ return;
785
1067
  }
786
- }
787
1068
 
788
- function renderTaskStats(stats, label) {
789
- const grid = document.getElementById('task-stats-grid');
790
- grid.innerHTML = [
791
- { label: 'Selected', value: '<span style="font-size:13px;font-family:var(--mono)">' + escHtml(label) + '</span>' },
792
- { label: 'Total Turns', value: fmtNum(stats.total_turns) },
793
- { label: 'User Interventions', value: fmtNum(stats.user_turns), title: 'Number of real user prompts' },
794
- { label: 'Duration', value: fmtDuration(stats.total_duration_ms), title: 'Total Claude processing time' },
795
- { label: 'Input Tokens', value: fmtNum(stats.total_input_tokens) },
796
- { label: 'Output Tokens', value: fmtNum(stats.total_output_tokens) },
797
- { label: 'Cost', value: fmtCost(stats.total_cost_usd) },
798
- ].map(c =>
799
- '<div class="stat-card"' + (c.title ? ' title="' + c.title + '"' : '') + '>' +
800
- '<div class="label">' + c.label + '</div>' +
801
- '<div class="value">' + c.value + '</div>' +
802
- '</div>'
803
- ).join('');
1069
+ const qs = 'tags=' + tags.map(encodeURIComponent).join(',') + '&mode=all';
1070
+
1071
+ try {
1072
+ const [stats, rawTurns] = await Promise.all([
1073
+ fetchJson('/api/tasks/stats?' + qs),
1074
+ fetchJson('/api/tasks/turns?' + qs + '&limit=' + PAGE_SIZE + '&offset=0'),
1075
+ ]);
1076
+ const turns = await enrichTurnsWithCost(rawTurns);
1077
+
1078
+ statsEl.innerHTML = [
1079
+ { label: 'Task', value: '<span style="font-size:14px">' + pills + '</span>' },
1080
+ { label: 'Total Turns', value: fmtNum(stats.total_turns) },
1081
+ { label: 'Human Interventions', value: fmtNum(stats.user_turns) },
1082
+ { label: 'Duration', value: fmtDuration(stats.total_duration_ms) },
1083
+ { label: 'Cost', value: fmtCost(stats.total_cost_usd) },
1084
+ ].map(c =>
1085
+ '<div class="stat-card"><div class="label">' + c.label + '</div><div class="value" style="font-size:16px">' + c.value + '</div></div>'
1086
+ ).join('');
1087
+
1088
+ renderTaskDetailTurns(turns);
1089
+ taskDetailOffset = turns.length;
1090
+ } catch (e) {
1091
+ console.error('task detail load failed', e);
1092
+ document.getElementById('task-detail-stats').innerHTML =
1093
+ '<div class="stat-card" style="grid-column:1/-1"><div class="label" style="color:var(--red)">Error: ' + escHtml(String(e)) + '</div></div>';
1094
+ }
804
1095
  }
805
1096
 
806
- function renderTurnsTable(turns, append) {
807
- const section = document.getElementById('turns-section');
808
- const tbody = document.getElementById('turns-table');
809
- const countEl = document.getElementById('turns-count');
810
- const paginationEl = document.getElementById('turns-pagination');
1097
+ function renderTaskDetailTurns(turns) {
1098
+ const tbody = document.getElementById('task-turns-table');
1099
+ const paginationEl = document.getElementById('task-turns-pagination');
811
1100
 
812
- if (!turns.length && !append) {
813
- section.style.display = 'none';
1101
+ if (!turns.length && taskDetailOffset === 0) {
1102
+ tbody.innerHTML = '<tr><td colspan="5" class="empty">No turns found.</td></tr>';
1103
+ paginationEl.innerHTML = '';
814
1104
  return;
815
1105
  }
816
1106
 
817
- section.style.display = '';
818
- const page = Math.floor(taskTurnsOffset / TURNS_PAGE);
819
- const hasMore = turns.length >= TURNS_PAGE;
820
- countEl.textContent = '';
1107
+ const page = Math.floor(taskDetailOffset / PAGE_SIZE);
1108
+ const hasMore = turns.length >= PAGE_SIZE;
821
1109
  paginationEl.innerHTML =
822
- (page > 0 ? '<button class="range-btn" onclick="turnsPrevPage()">\u2190 Prev</button>' : '') +
1110
+ (page > 0 ? '<button class="time-btn" onclick="taskDetailPrev()">Prev</button>' : '') +
823
1111
  '<span style="font-size:11px;color:var(--text-dim);font-family:var(--mono)">Page ' + (page + 1) + '</span>' +
824
- (hasMore ? '<button class="range-btn" onclick="turnsNextPage()">Next \u2192</button>' : '');
1112
+ (hasMore ? '<button class="time-btn" onclick="taskDetailNext()">Next</button>' : '');
825
1113
 
826
- const html = turns.map(t => {
827
- const tags = t.tags ? t.tags.split(', ').map(tag =>
828
- '<span style="display:inline-block;background:rgba(124,110,240,0.12);color:var(--accent-light);padding:0 6px;border-radius:3px;font-size:10px;font-family:var(--mono)">' + escHtml(tag) + '</span>'
829
- ).join(' ') : '';
1114
+ tbody.innerHTML = turns.map(t => {
830
1115
  const preview = t.content_text ? escHtml(t.content_text.slice(0, 80)) + (t.content_text.length > 80 ? '\u2026' : '') : '\u2014';
831
1116
  const tokens = fmtNum((t.block_input_tokens || 0) + (t.block_output_tokens || 0));
832
-
833
- return '<tr class="clickable" onclick="showTurnBlock(' + t.id + ')">' +
1117
+ return '<tr class="clickable" onclick="showTurnBlock(' + t.id + ', \'task-detail\')">' +
834
1118
  '<td title="' + fmtAbsolute(t.timestamp) + '" style="white-space:nowrap">' + fmtRelative(t.timestamp) + '</td>' +
835
1119
  '<td style="font-family:var(--mono);font-size:12px;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + preview + '</td>' +
836
- '<td>' + tags + '</td>' +
837
1120
  '<td>' + fmtDuration(t.duration_ms) + '</td>' +
838
1121
  '<td><span class="badge badge-tokens">' + tokens + '</span></td>' +
839
1122
  '<td><span class="badge badge-cost">' + fmtCost(t.block_cost_usd) + '</span></td>' +
840
1123
  '</tr>';
841
1124
  }).join('');
1125
+ }
842
1126
 
843
- if (append) {
844
- tbody.innerHTML += html;
845
- } else {
846
- tbody.innerHTML = html;
1127
+ async function taskDetailNext() {
1128
+ taskDetailOffset += PAGE_SIZE;
1129
+ const qs = 'tags=' + taskDetailTags.map(encodeURIComponent).join(',') + '&mode=all&limit=' + PAGE_SIZE + '&offset=' + taskDetailOffset;
1130
+ const turns = await enrichTurnsWithCost(await fetchJson('/api/tasks/turns?' + qs));
1131
+ renderTaskDetailTurns(turns);
1132
+ }
1133
+
1134
+ async function taskDetailPrev() {
1135
+ taskDetailOffset = Math.max(0, taskDetailOffset - PAGE_SIZE);
1136
+ const qs = 'tags=' + taskDetailTags.map(encodeURIComponent).join(',') + '&mode=all&limit=' + PAGE_SIZE + '&offset=' + taskDetailOffset;
1137
+ const turns = await enrichTurnsWithCost(await fetchJson('/api/tasks/turns?' + qs));
1138
+ renderTaskDetailTurns(turns);
1139
+ }
1140
+
1141
+ // ── Tag View ──
1142
+
1143
+ async function loadTags() {
1144
+ try {
1145
+ const tasks = await fetchJson('/api/tasks');
1146
+ if (!tasks.length) { allTagStats = []; renderTagTable([]); return; }
1147
+ const statsList = await Promise.all(
1148
+ tasks.map(t => fetchJson('/api/tasks/stats?tags=' + encodeURIComponent(t.task) + '&mode=any')
1149
+ .then(s => ({ ...s, tag: t.task, last_seen: t.last_tagged }))
1150
+ .catch(() => ({ tag: t.task, turn_count: t.turn_count, human_interventions: 0, total_duration_ms: 0, total_cost_usd: 0, last_seen: t.last_tagged }))
1151
+ )
1152
+ );
1153
+ allTagStats = statsList.map(s => ({
1154
+ tag: s.tag,
1155
+ turn_count: s.total_turns ?? s.turn_count ?? 0,
1156
+ human_interventions: s.user_turns ?? s.human_interventions ?? 0,
1157
+ total_duration_ms: s.total_duration_ms ?? 0,
1158
+ total_cost_usd: s.total_cost_usd ?? 0,
1159
+ last_seen: s.last_seen,
1160
+ }));
1161
+ renderTagTable(sortData(allTagStats, sortState.tags));
1162
+ } catch (e) {
1163
+ console.error('tags load failed', e);
1164
+ }
1165
+ }
1166
+
1167
+ function renderTagTable(stats) {
1168
+ const tbody = document.getElementById('tag-table');
1169
+ if (!stats.length) {
1170
+ tbody.innerHTML = '<tr><td colspan="5" class="empty">No tags yet. Tag turns with <code>zozul tag</code> to see them here.</td></tr>';
1171
+ return;
847
1172
  }
1173
+ tbody.innerHTML = stats.map(t =>
1174
+ '<tr class="clickable" onclick="showTagDetail(\'' + escHtml(t.tag).replace(/'/g, "\\'") + '\')">' +
1175
+ '<td><span class="tag-pill">' + escHtml(t.tag) + '</span></td>' +
1176
+ '<td>' + fmtDuration(t.total_duration_ms) + '</td>' +
1177
+ '<td><span class="badge badge-cost">' + fmtCost(t.total_cost_usd) + '</span></td>' +
1178
+ '<td>' + fmtNum(t.human_interventions) + '</td>' +
1179
+ '<td title="' + fmtAbsolute(t.last_seen) + '">' + fmtRelative(t.last_seen) + '</td>' +
1180
+ '</tr>'
1181
+ ).join('');
1182
+ }
1183
+
1184
+ // ── Tag Detail ──
1185
+
1186
+ async function showTagDetail(tagName) {
1187
+ previousView = 'tags';
1188
+ showView('tag-detail');
1189
+ tagDetailName = tagName;
1190
+ tagDetailOffset = 0;
1191
+
1192
+ const statsEl = document.getElementById('tag-detail-stats');
1193
+ statsEl.innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading...</div></div>';
1194
+
1195
+ try {
1196
+ const tagQs = 'tags=' + encodeURIComponent(tagName) + '&mode=any';
1197
+ const [stats, rawTurns] = await Promise.all([
1198
+ fetchJson('/api/tasks/stats?' + tagQs),
1199
+ fetchJson('/api/tasks/turns?' + tagQs + '&limit=' + PAGE_SIZE + '&offset=0'),
1200
+ ]);
1201
+ const turns = await enrichTurnsWithCost(rawTurns);
1202
+
1203
+ statsEl.innerHTML = [
1204
+ { label: 'Tag', value: '<span class="tag-pill" style="font-size:14px;padding:4px 12px">' + escHtml(tagName) + '</span>' },
1205
+ { label: 'Total Turns', value: fmtNum(stats.total_turns) },
1206
+ { label: 'Human Interventions', value: fmtNum(stats.user_turns) },
1207
+ { label: 'Duration', value: fmtDuration(stats.total_duration_ms) },
1208
+ { label: 'Cost', value: fmtCost(stats.total_cost_usd) },
1209
+ ].map(c =>
1210
+ '<div class="stat-card"><div class="label">' + c.label + '</div><div class="value" style="font-size:16px">' + c.value + '</div></div>'
1211
+ ).join('');
1212
+
1213
+ renderTagDetailTurns(turns);
1214
+ tagDetailOffset = turns.length;
1215
+ } catch (e) {
1216
+ console.error('tag detail load failed', e);
1217
+ document.getElementById('tag-detail-stats').innerHTML =
1218
+ '<div class="stat-card" style="grid-column:1/-1"><div class="label" style="color:var(--red)">Error: ' + escHtml(String(e)) + '</div></div>';
1219
+ }
1220
+ }
1221
+
1222
+ function renderTagDetailTurns(turns) {
1223
+ const tbody = document.getElementById('tag-turns-table');
1224
+ const paginationEl = document.getElementById('tag-turns-pagination');
1225
+
1226
+ if (!turns.length && tagDetailOffset === 0) {
1227
+ tbody.innerHTML = '<tr><td colspan="4" class="empty">No turns found.</td></tr>';
1228
+ paginationEl.innerHTML = '';
1229
+ return;
1230
+ }
1231
+
1232
+ const page = Math.floor(tagDetailOffset / PAGE_SIZE);
1233
+ const hasMore = turns.length >= PAGE_SIZE;
1234
+ paginationEl.innerHTML =
1235
+ (page > 0 ? '<button class="time-btn" onclick="tagDetailPrev()">Prev</button>' : '') +
1236
+ '<span style="font-size:11px;color:var(--text-dim);font-family:var(--mono)">Page ' + (page + 1) + '</span>' +
1237
+ (hasMore ? '<button class="time-btn" onclick="tagDetailNext()">Next</button>' : '');
1238
+
1239
+ tbody.innerHTML = turns.map(t => {
1240
+ const preview = t.content_text ? escHtml(t.content_text.slice(0, 100)) + (t.content_text.length > 100 ? '\u2026' : '') : '\u2014';
1241
+ return '<tr class="clickable" onclick="showTurnBlock(' + t.id + ', \'tag-detail\')">' +
1242
+ '<td title="' + fmtAbsolute(t.timestamp) + '" style="white-space:nowrap">' + fmtRelative(t.timestamp) + '</td>' +
1243
+ '<td style="font-family:var(--mono);font-size:12px;max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + preview + '</td>' +
1244
+ '<td><span class="session-id" onclick="event.stopPropagation();copyText(\'' + t.session_id + '\')" title="Click to copy">' +
1245
+ shortId(t.session_id) + '<span class="copy-icon">&#x2398;</span></span></td>' +
1246
+ '<td><span class="badge badge-cost">' + fmtCost(t.block_cost_usd ?? t.estimated_cost_usd ?? 0) + '</span></td>' +
1247
+ '</tr>';
1248
+ }).join('');
848
1249
  }
849
1250
 
850
- async function showTurnBlock(turnId) {
851
- document.getElementById('main-view').style.display = 'none';
852
- document.getElementById('detail-view').classList.remove('active');
853
- const view = document.getElementById('turn-detail-view');
854
- view.classList.add('active');
1251
+ async function tagDetailNext() {
1252
+ tagDetailOffset += PAGE_SIZE;
1253
+ const tagQs = 'tags=' + encodeURIComponent(tagDetailName) + '&mode=any';
1254
+ const turns = await enrichTurnsWithCost(await fetchJson('/api/tasks/turns?' + tagQs + '&limit=' + PAGE_SIZE + '&offset=' + tagDetailOffset));
1255
+ renderTagDetailTurns(turns);
1256
+ }
1257
+
1258
+ async function tagDetailPrev() {
1259
+ tagDetailOffset = Math.max(0, tagDetailOffset - PAGE_SIZE);
1260
+ const tagQs = 'tags=' + encodeURIComponent(tagDetailName) + '&mode=any';
1261
+ const turns = await enrichTurnsWithCost(await fetchJson('/api/tasks/turns?' + tagQs + '&limit=' + PAGE_SIZE + '&offset=' + tagDetailOffset));
1262
+ renderTagDetailTurns(turns);
1263
+ }
1264
+
1265
+ // ── Turn Block Detail ──
1266
+
1267
+ let turnDetailReturnView = 'tasks';
1268
+
1269
+ async function showTurnBlock(turnId, returnTo) {
1270
+ turnDetailReturnView = returnTo || 'tasks';
1271
+ showView('turn-detail');
855
1272
 
856
1273
  const statsEl = document.getElementById('turn-detail-stats');
857
1274
  const content = document.getElementById('turn-detail-content');
858
- statsEl.innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading\u2026</div></div>';
1275
+ statsEl.innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading...</div></div>';
859
1276
  content.innerHTML = '';
860
1277
 
861
- const block = await fetchJson('/api/turns/' + turnId + '/block');
1278
+ const block = await enrichTurnsWithCost(await fetchJson('/api/turns/' + turnId + '/block'));
862
1279
  if (!block.length) { content.innerHTML = '<div class="empty">No data</div>'; return; }
863
1280
 
864
- // Aggregate block stats
865
1281
  const totalIn = block.reduce((s, t) => s + (t.input_tokens || 0), 0);
866
1282
  const totalOut = block.reduce((s, t) => s + (t.output_tokens || 0), 0);
867
- const totalCost = block.reduce((s, t) => s + (t.cost_usd || 0), 0);
1283
+ const totalCost = block.reduce((s, t) => s + (t.estimated_cost_usd || 0), 0);
868
1284
  const duration = block[0].duration_ms || 0;
869
1285
  const prompt = block[0].content_text ? block[0].content_text.slice(0, 100) : '\u2014';
870
1286
 
@@ -879,367 +1295,228 @@ async function showTurnBlock(turnId) {
879
1295
  '<div class="stat-card"><div class="label">' + c.label + '</div><div class="value" style="font-size:14px">' + c.value + '</div></div>'
880
1296
  ).join('');
881
1297
 
882
- content.innerHTML = block.map((t, i) => {
883
- const roleClass = t.role === 'assistant' ? 'assistant' : 'user';
884
- const roleLabel = t.role === 'assistant' ? 'Assistant' : 'User';
885
- const meta = [];
886
- if (t.input_tokens) meta.push(fmtNum(t.input_tokens) + ' in');
887
- if (t.output_tokens) meta.push(fmtNum(t.output_tokens) + ' out');
888
- if (t.cost_usd) meta.push(fmtCost(t.cost_usd));
889
- if (t.duration_ms) meta.push(fmtDuration(t.duration_ms));
890
-
891
- const text = t.content_text
892
- ? '<div class="turn-content">' + escHtml(t.content_text) + '</div>'
893
- : '';
894
-
895
- let toolsHtml = '';
896
- if (t.tool_calls) {
897
- try {
898
- const calls = JSON.parse(t.tool_calls);
899
- toolsHtml = '<div class="tool-calls">' + calls.map((c, ci) => {
900
- const uid = 'tb-' + turnId + '-' + i + '-' + ci;
901
- const inputJson = JSON.stringify(c.toolInput, null, 2);
902
- const resultHtml = c.toolResult
903
- ? '<div class="tool-section-label">Result</div><div class="tool-json">' + escHtml(c.toolResult) + '</div>'
904
- : '';
905
- return '<div class="tool-call" id="' + uid + '">' +
906
- '<div class="tool-call-header" onclick="toggleTool(\'' + uid + '\')">' +
907
- '<span class="tool-name">' + escHtml(c.toolName) + '</span>' +
908
- '<span class="tool-chevron">&#9658;</span>' +
909
- '</div>' +
910
- '<div class="tool-body">' +
911
- '<div class="tool-section-label">Input</div>' +
912
- '<div class="tool-json">' + escHtml(inputJson) + '</div>' +
913
- resultHtml +
914
- '</div>' +
915
- '</div>';
916
- }).join('') + '</div>';
917
- } catch {}
918
- }
919
-
920
- return '<div class="turn">' +
921
- '<div class="turn-header">' +
922
- '<span class="turn-role ' + roleClass + '">' + roleLabel + '</span>' +
923
- (meta.length ? '<span class="turn-meta">' + meta.join(' \u00b7 ') + '</span>' : '') +
924
- '<span class="turn-ts" title="' + fmtAbsolute(t.timestamp) + '">' + fmtRelative(t.timestamp) + '</span>' +
925
- '</div>' +
926
- text +
927
- toolsHtml +
928
- '</div>';
929
- }).join('');
930
- }
931
-
932
- async function turnsNextPage() {
933
- taskTurnsOffset += TURNS_PAGE;
934
- const { qs } = taskQueryParams();
935
- const turns = await fetchJson('/api/tasks/turns?' + qs + '&limit=' + TURNS_PAGE + '&offset=' + taskTurnsOffset);
936
- renderTurnsTable(turns, false);
937
- }
938
-
939
- async function turnsPrevPage() {
940
- taskTurnsOffset = Math.max(0, taskTurnsOffset - TURNS_PAGE);
941
- const { qs } = taskQueryParams();
942
- const turns = await fetchJson('/api/tasks/turns?' + qs + '&limit=' + TURNS_PAGE + '&offset=' + taskTurnsOffset);
943
- renderTurnsTable(turns, false);
944
- }
945
-
946
- // ── Sessions table ──
947
-
948
- function renderSessions(sessions) {
949
- const tbody = document.getElementById('sessions-table');
950
- if (!sessions.length) {
951
- tbody.innerHTML = '<tr><td colspan="9" class="empty">No sessions yet. Use Claude Code then run <code>zozul ingest</code>.</td></tr>';
952
- return;
953
- }
954
- tbody.innerHTML = sessions.map(s => {
955
- const proj = basename(s.project_path);
956
- const projAttr = s.project_path ? ' title="' + escHtml(s.project_path) + '"' : '';
957
- return '<tr class="clickable" onclick="showSession(\'' + s.id + '\')">' +
958
- '<td><span class="session-id" onclick="event.stopPropagation();copyText(\'' + s.id + '\')" title="Click to copy full ID">' +
959
- shortId(s.id) + '<span class="copy-icon">&#x2398;</span></span></td>' +
960
- '<td' + projAttr + '>' + escHtml(proj) + '</td>' +
961
- '<td title="' + fmtAbsolute(s.started_at) + '">' + fmtRelative(s.started_at) + '</td>' +
962
- '<td>' + fmtDuration(s.total_duration_ms) + '</td>' +
963
- '<td>' + (s.total_turns ?? 0) + '</td>' +
964
- '<td><span class="badge badge-tokens">' + fmtNum(s.total_input_tokens) + '</span></td>' +
965
- '<td><span class="badge badge-tokens">' + fmtNum(s.total_output_tokens) + '</span></td>' +
966
- '<td><span class="badge badge-cost">' + fmtCost(s.total_cost_usd) + '</span></td>' +
967
- '<td style="font-family:var(--mono);font-size:11px;color:var(--text-dim)">' + escHtml(s.model ?? '\u2014') + '</td>' +
968
- '</tr>';
969
- }).join('');
970
- }
971
-
972
- function filterSessions(q) {
973
- q = q.toLowerCase();
974
- const filtered = q
975
- ? allSessions.filter(s =>
976
- (s.id && s.id.toLowerCase().includes(q)) ||
977
- (s.project_path && s.project_path.toLowerCase().includes(q)) ||
978
- (s.model && s.model.toLowerCase().includes(q))
979
- )
980
- : allSessions;
981
- renderSessions(filtered);
982
- // Hide load-more while filtering (results are client-side only)
983
- document.getElementById('load-more-row').style.display = q ? 'none' : (sessionsTotal > sessionsOffset ? '' : 'none');
1298
+ content.innerHTML = block.map((t, i) => renderTurnHtml(t, 'tb-' + turnId + '-' + i)).join('');
984
1299
  }
985
1300
 
986
- // ── Charts ──
987
-
988
- function makeChart(id, config) {
989
- const existing = chartInstances[id];
990
- if (existing) {
991
- const newHash = JSON.stringify(config.data);
992
- if (existing._dataHash === newHash) return;
993
- existing._dataHash = newHash;
994
- existing.data = config.data;
995
- existing.update('none');
996
- return;
1301
+ function goBackFromTurn() {
1302
+ if (currentView === 'turn-detail') {
1303
+ showView(turnDetailReturnView);
1304
+ } else if (currentView === 'session-detail') {
1305
+ showView(previousView || 'tasks');
997
1306
  }
998
- const ctx = document.getElementById(id);
999
- if (!ctx) return;
1000
- const chart = new Chart(ctx, config);
1001
- chart._dataHash = JSON.stringify(config.data);
1002
- chartInstances[id] = chart;
1003
1307
  }
1004
1308
 
1005
- const COLORS = ['#7c6ef0','#4caf50','#42a5f5','#ff9800','#ef5350','#ab47bc','#26a69a','#8d6e63','#78909c','#d4e157'];
1006
- const TICK = { color: '#8b8fa3', font: { family: '"JetBrains Mono", monospace', size: 10 } };
1007
- const LEGEND = { labels: { color: '#8b8fa3', font: { family: '"JetBrains Mono", monospace', size: 10 } } };
1008
-
1009
- function renderTokenCostChart(tokens, cost) {
1010
- const labels = tokens.map(d => d.timestamp);
1011
- const costByTs = Object.fromEntries(cost.map(d => [d.timestamp, d.cost]));
1012
- makeChart('chart-tokens', {
1013
- type: 'bar',
1014
- data: {
1015
- labels,
1016
- datasets: [
1017
- { label: 'Cost (USD)', data: labels.map(ts => costByTs[ts] ?? 0),
1018
- backgroundColor: 'rgba(255,152,0,0.25)', borderColor: '#ff9800', borderWidth: 1,
1019
- yAxisID: 'yCost', order: 2 },
1020
- { label: 'Input', data: tokens.map(d => d.input),
1021
- type: 'line', borderColor: '#7c6ef0', borderWidth: 2, tension: 0.3, pointRadius: 2,
1022
- yAxisID: 'yTokens', order: 1 },
1023
- { label: 'Output', data: tokens.map(d => d.output),
1024
- type: 'line', borderColor: '#4caf50', borderWidth: 2, tension: 0.3, pointRadius: 2,
1025
- yAxisID: 'yTokens', order: 1 },
1026
- ]
1027
- },
1028
- options: {
1029
- responsive: true,
1030
- plugins: {
1031
- legend: LEGEND,
1032
- tooltip: {
1033
- callbacks: {
1034
- afterLabel: (ctx) => {
1035
- if (ctx.dataset.label !== 'Output') return undefined;
1036
- const cr = tokens[ctx.dataIndex]?.cache_read ?? 0;
1037
- const inp = tokens[ctx.dataIndex]?.input ?? 0;
1038
- if (cr <= 0) return undefined;
1039
- const pct = inp + cr > 0 ? Math.round(cr / (inp + cr) * 100) : 0;
1040
- return ` Cache read: ${cr.toLocaleString()} (${pct}% of input)`;
1041
- }
1042
- }
1043
- }
1044
- },
1045
- scales: {
1046
- x: { ticks: TICK },
1047
- yTokens: { position: 'left', ticks: TICK, title: { display: true, text: 'Tokens', color: '#8b8fa3', font: { size: 10 } } },
1048
- yCost: { position: 'right', grid: { drawOnChartArea: false },
1049
- ticks: { ...TICK, callback: v => '$' + v.toFixed(3) },
1050
- title: { display: true, text: 'Cost (USD)', color: '#ff9800', font: { size: 10 } } },
1051
- }
1052
- }
1053
- });
1054
- }
1309
+ // ── Session Detail ──
1055
1310
 
1056
- // ── Session detail ──
1311
+ async function showSession(id, returnTo) {
1312
+ previousView = returnTo || 'tasks';
1313
+ showView('session-detail');
1057
1314
 
1058
- async function showSession(id) {
1059
- document.getElementById('main-view').style.display = 'none';
1060
- const view = document.getElementById('detail-view');
1061
- view.classList.add('active');
1062
- document.getElementById('detail-stats').innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading\u2026</div></div>';
1063
- document.getElementById('detail-turns').innerHTML = '';
1315
+ document.getElementById('session-detail-stats').innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading...</div></div>';
1316
+ document.getElementById('session-detail-turns').innerHTML = '';
1064
1317
 
1065
1318
  const [session, turns] = await Promise.all([
1066
1319
  fetchJson('/api/sessions/' + id),
1067
1320
  fetchJson('/api/sessions/' + id + '/turns'),
1068
1321
  ]);
1069
1322
 
1070
- document.getElementById('detail-stats').innerHTML = [
1071
- { label: 'Session ID', value: '<span class="session-id" onclick="copyText(\'' + session.id + '\')" title="Click to copy">' + shortId(session.id) + ' <span class="copy-icon">&#x2398;</span></span>' },
1072
- { label: 'Project', value: '<span title="' + escHtml(session.project_path ?? '') + '">' + escHtml(basename(session.project_path)) + '</span>' },
1073
- { label: 'Model', value: '<span style="font-family:var(--mono);font-size:13px">' + escHtml(session.model ?? '\u2014') + '</span>' },
1074
- { label: 'Started', value: '<span title="' + fmtAbsolute(session.started_at) + '">' + fmtRelative(session.started_at) + '</span>' },
1075
- { label: 'Duration', value: fmtDuration(session.total_duration_ms) },
1076
- { label: 'Turns', value: session.total_turns },
1077
- { label: 'Input Tokens', value: fmtNum(session.total_input_tokens) },
1078
- { label: 'Output Tokens', value: fmtNum(session.total_output_tokens) },
1079
- { label: 'Cost', value: fmtCost(session.total_cost_usd) },
1323
+ document.getElementById('session-detail-stats').innerHTML = [
1324
+ { label: 'Session ID', value: '<span class="session-id" onclick="copyText(\'' + session.id + '\')" title="Click to copy">' + shortId(session.id) + ' <span class="copy-icon">&#x2398;</span></span>' },
1325
+ { label: 'Project', value: '<span title="' + escHtml(session.project_path ?? '') + '">' + escHtml(basename(session.project_path)) + '</span>' },
1326
+ { label: 'Model', value: '<span style="font-family:var(--mono);font-size:13px">' + escHtml(session.model ?? '\u2014') + '</span>' },
1327
+ { label: 'Duration', value: fmtDuration(session.total_duration_ms) },
1328
+ { label: 'Turns', value: session.total_turns },
1329
+ { label: 'Input Tokens', value: fmtNum(session.total_input_tokens) },
1330
+ { label: 'Output Tokens', value: fmtNum(session.total_output_tokens) },
1331
+ { label: 'Cost', value: fmtCost(session.total_cost_usd) },
1080
1332
  ].map(c =>
1081
- '<div class="stat-card">' +
1082
- '<div class="label">' + c.label + '</div>' +
1083
- '<div class="value" style="font-size:14px">' + c.value + '</div>' +
1084
- '</div>'
1333
+ '<div class="stat-card"><div class="label">' + c.label + '</div><div class="value" style="font-size:14px">' + c.value + '</div></div>'
1085
1334
  ).join('');
1086
1335
 
1087
- const turnsDiv = document.getElementById('detail-turns');
1336
+ const turnsDiv = document.getElementById('session-detail-turns');
1088
1337
  if (!turns.length) {
1089
- turnsDiv.innerHTML = '<div class="empty">No turns recorded. Run <code>zozul ingest</code> to parse transcript files.</div>';
1338
+ turnsDiv.innerHTML = '<div class="empty">No turns recorded.</div>';
1090
1339
  return;
1091
1340
  }
1341
+ turnsDiv.innerHTML = turns.map((t, i) => renderTurnHtml(t, 'tc-' + i)).join('');
1342
+ }
1092
1343
 
1093
- turnsDiv.innerHTML = turns.map((t, i) => {
1094
- const roleClass = t.role === 'assistant' ? 'assistant' : 'user';
1095
- const roleLabel = t.role === 'assistant' ? 'Assistant' : 'User';
1096
-
1097
- const meta = [];
1098
- if (t.input_tokens) meta.push(fmtNum(t.input_tokens) + ' in');
1099
- if (t.output_tokens) meta.push(fmtNum(t.output_tokens) + ' out');
1100
- if (t.cost_usd) meta.push(fmtCost(t.cost_usd));
1101
- if (t.duration_ms) meta.push(fmtDuration(t.duration_ms));
1102
-
1103
- const content = t.content_text
1104
- ? '<div class="turn-content">' + escHtml(t.content_text) + '</div>'
1105
- : '';
1106
-
1107
- let toolsHtml = '';
1108
- if (t.tool_calls) {
1109
- try {
1110
- const calls = JSON.parse(t.tool_calls);
1111
- toolsHtml = '<div class="tool-calls">' + calls.map((c, ci) => {
1112
- const uid = 'tc-' + i + '-' + ci;
1113
- const inputJson = JSON.stringify(c.toolInput, null, 2);
1114
- const resultHtml = c.toolResult
1115
- ? '<div class="tool-section-label">Result</div><div class="tool-json">' + escHtml(c.toolResult) + '</div>'
1116
- : '';
1117
- return '<div class="tool-call" id="' + uid + '">' +
1118
- '<div class="tool-call-header" onclick="toggleTool(\'' + uid + '\')">' +
1119
- '<span class="tool-name">' + escHtml(c.toolName) + '</span>' +
1120
- '<span class="tool-chevron">&#9658;</span>' +
1121
- '</div>' +
1122
- '<div class="tool-body">' +
1123
- '<div class="tool-section-label">Input</div>' +
1124
- '<div class="tool-json">' + escHtml(inputJson) + '</div>' +
1125
- resultHtml +
1126
- '</div>' +
1127
- '</div>';
1128
- }).join('') + '</div>';
1129
- } catch {}
1130
- }
1344
+ // ── Shared turn rendering ──
1345
+
1346
+ function renderTurnHtml(t, idPrefix) {
1347
+ const roleClass = t.role === 'assistant' ? 'assistant' : 'user';
1348
+ const roleLabel = t.role === 'assistant' ? 'Assistant' : 'User';
1349
+ const meta = [];
1350
+ if (t.input_tokens) meta.push(fmtNum(t.input_tokens) + ' in');
1351
+ if (t.output_tokens) meta.push(fmtNum(t.output_tokens) + ' out');
1352
+ if (t.estimated_cost_usd || t.cost_usd) meta.push(fmtCost(t.estimated_cost_usd || t.cost_usd));
1353
+ if (t.duration_ms) meta.push(fmtDuration(t.duration_ms));
1354
+
1355
+ const text = t.content_text
1356
+ ? '<div class="turn-content">' + escHtml(t.content_text) + '</div>'
1357
+ : '';
1358
+
1359
+ let toolsHtml = '';
1360
+ if (t.tool_calls) {
1361
+ try {
1362
+ const calls = JSON.parse(t.tool_calls);
1363
+ toolsHtml = '<div class="tool-calls">' + calls.map((c, ci) => {
1364
+ const uid = idPrefix + '-' + ci;
1365
+ const inputJson = JSON.stringify(c.toolInput, null, 2);
1366
+ const resultHtml = c.toolResult
1367
+ ? '<div class="tool-section-label">Result</div><div class="tool-json">' + escHtml(c.toolResult) + '</div>'
1368
+ : '';
1369
+ return '<div class="tool-call" id="' + uid + '">' +
1370
+ '<div class="tool-call-header" onclick="toggleTool(\'' + uid + '\')">' +
1371
+ '<span class="tool-name">' + escHtml(c.toolName) + '</span>' +
1372
+ '<span class="tool-chevron">&#9658;</span>' +
1373
+ '</div>' +
1374
+ '<div class="tool-body">' +
1375
+ '<div class="tool-section-label">Input</div>' +
1376
+ '<div class="tool-json">' + escHtml(inputJson) + '</div>' +
1377
+ resultHtml +
1378
+ '</div>' +
1379
+ '</div>';
1380
+ }).join('') + '</div>';
1381
+ } catch {}
1382
+ }
1131
1383
 
1132
- return '<div class="turn">' +
1133
- '<div class="turn-header">' +
1134
- '<span class="turn-role ' + roleClass + '">' + roleLabel + '</span>' +
1135
- (meta.length ? '<span class="turn-meta">' + meta.join(' \u00b7 ') + '</span>' : '') +
1136
- '<span class="turn-ts" title="' + fmtAbsolute(t.timestamp) + '">' + fmtRelative(t.timestamp) + '</span>' +
1137
- '</div>' +
1138
- content +
1139
- toolsHtml +
1140
- '</div>';
1141
- }).join('');
1384
+ return '<div class="turn">' +
1385
+ '<div class="turn-header">' +
1386
+ '<span class="turn-role ' + roleClass + '">' + roleLabel + '</span>' +
1387
+ (meta.length ? '<span class="turn-meta">' + meta.join(' \u00b7 ') + '</span>' : '') +
1388
+ '<span class="turn-ts" title="' + fmtAbsolute(t.timestamp) + '">' + fmtRelative(t.timestamp) + '</span>' +
1389
+ '</div>' +
1390
+ text +
1391
+ toolsHtml +
1392
+ '</div>';
1142
1393
  }
1143
1394
 
1144
1395
  function toggleTool(uid) {
1145
1396
  document.getElementById(uid).classList.toggle('open');
1146
1397
  }
1147
1398
 
1148
- function showMain() {
1149
- document.getElementById('detail-view').classList.remove('active');
1150
- document.getElementById('turn-detail-view').classList.remove('active');
1151
- document.getElementById('main-view').style.display = '';
1152
- }
1399
+ // ── Sessions View ──
1153
1400
 
1154
- // ── Keyboard ──
1401
+ async function loadSessions() {
1402
+ try {
1403
+ const resp = await fetchJson('/api/sessions?limit=' + SESSIONS_PAGE + '&offset=0');
1404
+ const sessions = Array.isArray(resp) ? resp : resp.sessions;
1405
+ allSessions = sessions;
1406
+ sessionsTotal = Array.isArray(resp) ? sessions.length : resp.total;
1407
+ sessionsOffset = sessions.length;
1408
+ renderSessionsTable(sortData(sessions, sortState.sessions));
1409
+ updateLoadMore();
1410
+ } catch (e) {
1411
+ console.error('sessions load failed', e);
1412
+ }
1413
+ }
1155
1414
 
1156
- document.addEventListener('keydown', e => {
1157
- if (e.key === 'Escape') {
1158
- if (document.getElementById('detail-view').classList.contains('active') ||
1159
- document.getElementById('turn-detail-view').classList.contains('active')) {
1160
- showMain();
1161
- }
1415
+ async function loadMoreSessions() {
1416
+ try {
1417
+ const resp = await fetchJson('/api/sessions?limit=' + SESSIONS_PAGE + '&offset=' + sessionsOffset);
1418
+ const page = Array.isArray(resp) ? resp : resp.sessions;
1419
+ allSessions = allSessions.concat(page);
1420
+ sessionsTotal = Array.isArray(resp) ? allSessions.length : resp.total;
1421
+ sessionsOffset = allSessions.length;
1422
+ const q = document.getElementById('session-filter').value;
1423
+ const display = q ? allSessions.filter(s => sessionMatches(s, q)) : allSessions;
1424
+ renderSessionsTable(sortData(display, sortState.sessions));
1425
+ updateLoadMore();
1426
+ } catch (e) {
1427
+ console.error('load more failed', e);
1162
1428
  }
1163
- });
1429
+ }
1164
1430
 
1165
- // ── Range picker ──
1166
-
1167
- function setActivePreset(range) {
1168
- currentRange = range;
1169
- customFrom = null;
1170
- customTo = null;
1171
- document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
1172
- const btn = document.querySelector('.range-btn[data-range="' + range + '"]');
1173
- if (btn) btn.classList.add('active');
1174
- document.getElementById('custom-range').classList.remove('visible');
1175
- updateChartLabels();
1176
- loadCharts();
1431
+ function updateLoadMore() {
1432
+ const row = document.getElementById('load-more-row');
1433
+ const shown = document.getElementById('sessions-shown');
1434
+ const count = document.getElementById('sessions-count');
1435
+ count.textContent = '(' + sessionsTotal + ')';
1436
+ if (sessionsTotal > sessionsOffset) {
1437
+ row.style.display = '';
1438
+ shown.textContent = sessionsOffset + ' of ' + sessionsTotal + ' shown';
1439
+ } else {
1440
+ row.style.display = 'none';
1441
+ }
1177
1442
  }
1178
1443
 
1179
- function updateChartLabels() {
1180
- document.getElementById('tokens-label').textContent = 'Tokens & Cost (' + chartLabel() + ')';
1444
+ function sessionMatches(s, q) {
1445
+ q = q.toLowerCase();
1446
+ return (s.id && s.id.toLowerCase().includes(q)) ||
1447
+ (s.project_path && s.project_path.toLowerCase().includes(q)) ||
1448
+ (s.model && s.model.toLowerCase().includes(q));
1181
1449
  }
1182
1450
 
1183
- document.getElementById('range-picker').addEventListener('click', e => {
1184
- const btn = e.target.closest('.range-btn[data-range]');
1185
- if (!btn) return;
1186
- setActivePreset(btn.dataset.range);
1187
- });
1451
+ function filterSessions(q) {
1452
+ const filtered = q ? allSessions.filter(s => sessionMatches(s, q)) : allSessions;
1453
+ renderSessionsTable(sortData(filtered, sortState.sessions));
1454
+ document.getElementById('load-more-row').style.display = q ? 'none' : (sessionsTotal > sessionsOffset ? '' : 'none');
1455
+ }
1188
1456
 
1189
- // ── Custom range ──
1457
+ function renderSessionsTable(sessions) {
1458
+ const tbody = document.getElementById('sessions-table');
1459
+ if (!sessions.length) {
1460
+ tbody.innerHTML = '<tr><td colspan="7" class="empty">No sessions yet.</td></tr>';
1461
+ return;
1462
+ }
1463
+ tbody.innerHTML = sessions.map(s => {
1464
+ const proj = basename(s.project_path);
1465
+ const projAttr = s.project_path ? ' title="' + escHtml(s.project_path) + '"' : '';
1466
+ return '<tr class="clickable" onclick="showSession(\'' + s.id + '\', \'sessions\')">' +
1467
+ '<td><span class="session-id" onclick="event.stopPropagation();copyText(\'' + s.id + '\')" title="Click to copy full ID">' +
1468
+ shortId(s.id) + '<span class="copy-icon">&#x2398;</span></span></td>' +
1469
+ '<td' + projAttr + '>' + escHtml(proj) + '</td>' +
1470
+ '<td title="' + fmtAbsolute(s.started_at) + '">' + fmtRelative(s.started_at) + '</td>' +
1471
+ '<td>' + fmtDuration(s.total_duration_ms) + '</td>' +
1472
+ '<td>' + (s.total_turns ?? 0) + '</td>' +
1473
+ '<td><span class="badge badge-cost">' + fmtCost(s.total_cost_usd) + '</span></td>' +
1474
+ '<td style="font-family:var(--mono);font-size:11px;color:var(--text-dim)">' + escHtml(s.model ?? '\u2014') + '</td>' +
1475
+ '</tr>';
1476
+ }).join('');
1477
+ }
1190
1478
 
1191
- const customRangeEl = document.getElementById('custom-range');
1192
- const customToggle = document.getElementById('custom-range-toggle');
1479
+ // ── Loading orchestration ──
1193
1480
 
1194
- function initCustomDefaults() {
1195
- const now = new Date();
1196
- const yesterday = new Date(now.getTime() - 24 * 3600000);
1197
- const toLocal = d => {
1198
- const pad = n => String(n).padStart(2, '0');
1199
- return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
1200
- };
1201
- document.getElementById('range-from').value = toLocal(yesterday);
1202
- document.getElementById('range-to').value = toLocal(now);
1481
+ async function loadViewData(view) {
1482
+ if (view === 'summary') await loadSummary();
1483
+ else if (view === 'sessions') await loadSessions();
1484
+ else if (view === 'tasks') await loadTasks();
1485
+ else if (view === 'tags') await loadTags();
1203
1486
  }
1204
1487
 
1205
- customToggle.addEventListener('click', (e) => {
1206
- e.stopPropagation();
1207
- const isOpen = customRangeEl.classList.contains('visible');
1208
- if (isOpen) {
1209
- customRangeEl.classList.remove('visible');
1210
- customToggle.classList.remove('active');
1211
- var last = document.querySelector('.range-btn[data-range="' + currentRange + '"]');
1212
- if (last) last.classList.add('active');
1213
- } else {
1214
- initCustomDefaults();
1215
- customRangeEl.classList.add('visible');
1216
- document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
1217
- customToggle.classList.add('active');
1488
+ async function loadDashboard() {
1489
+ const btn = document.querySelector('.auto-btn');
1490
+ btn?.classList.add('loading');
1491
+ const minSpinner = new Promise(r => setTimeout(r, 400));
1492
+ try {
1493
+ await loadViewData(currentView);
1494
+ } catch (e) {
1495
+ console.error('load failed', e);
1496
+ } finally {
1497
+ await minSpinner;
1498
+ btn?.classList.remove('loading');
1218
1499
  }
1219
- });
1500
+ }
1220
1501
 
1221
- document.getElementById('apply-custom').addEventListener('click', () => {
1222
- const fromVal = document.getElementById('range-from').value;
1223
- const toVal = document.getElementById('range-to').value;
1224
- if (!fromVal || !toVal) return;
1225
- customFrom = new Date(fromVal).toISOString();
1226
- customTo = new Date(toVal).toISOString();
1227
- customStep = document.getElementById('range-step').value;
1228
- document.querySelectorAll('.range-btn[data-range]').forEach(b => b.classList.remove('active'));
1229
- updateChartLabels();
1230
- loadCharts();
1231
- });
1502
+ function manualRefresh() {
1503
+ if (autoRefreshTimer) { clearTimeout(autoRefreshTimer); autoRefreshTimer = null; }
1504
+ loadDashboard().then(scheduleAutoRefresh);
1505
+ }
1232
1506
 
1233
- async function loadCharts() {
1234
- const qs = chartQueryString();
1235
- const [tokens, cost] = await Promise.all([
1236
- fetchJson('/api/metrics/tokens?' + qs),
1237
- fetchJson('/api/metrics/cost?' + qs),
1238
- ]);
1239
- renderTokenCostChart(tokens, cost);
1507
+ function scheduleAutoRefresh() {
1508
+ autoRefreshTimer = setTimeout(async () => {
1509
+ // Only auto-refresh main views, not detail views
1510
+ if (['summary', 'sessions', 'tasks', 'tags'].includes(currentView)) {
1511
+ await loadDashboard();
1512
+ }
1513
+ scheduleAutoRefresh();
1514
+ }, AUTO_REFRESH_INTERVAL);
1240
1515
  }
1241
1516
 
1242
- loadDashboard().then(scheduleAutoRefresh);
1517
+ // ── Init ──
1518
+
1519
+ detectDataSource().then(() => loadDashboard()).then(scheduleAutoRefresh);
1243
1520
  </script>
1244
1521
  </body>
1245
1522
  </html>