zozul-cli 0.1.0 → 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.
@@ -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,131 @@
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 previousView = 'tasks';
594
+ let allTaskGroups = [];
595
+ let allTagStats = [];
596
+ let sortState = {
597
+ tasks: { key: 'total_cost_usd', dir: 'desc' },
598
+ tags: { key: 'total_cost_usd', dir: 'desc' },
599
+ sessions: { key: 'started_at', dir: 'desc' },
600
+ };
511
601
  let allSessions = [];
512
602
  let sessionsTotal = 0;
513
603
  let sessionsOffset = 0;
514
604
  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;
605
+ let chartInstances = {};
606
+ let tagDetailName = '';
607
+ let tagDetailOffset = 0;
608
+ let taskDetailTags = [];
609
+ let taskDetailOffset = 0;
610
+ const PAGE_SIZE = 20;
611
+ const AUTO_REFRESH_INTERVAL = 10_000;
612
+
613
+ // ── Data source auto-detection ──
614
+
615
+ async function detectDataSource() {
616
+ const badge = document.getElementById('source-badge');
617
+ if (typeof ZOZUL_CONFIG === 'undefined') {
618
+ dataSource = 'local';
619
+ badge.textContent = 'Local';
620
+ badge.className = 'source-badge local';
621
+ badge.style.display = '';
622
+ return;
525
623
  }
526
- return 'range=' + currentRange;
527
- }
528
624
 
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);
625
+ try {
626
+ const controller = new AbortController();
627
+ const timeout = setTimeout(() => controller.abort(), 3000);
628
+ const healthUrl = ZOZUL_CONFIG.remote.baseUrl + '/health';
629
+ const res = await fetch(healthUrl, {
630
+ signal: controller.signal,
631
+ headers: { 'X-API-Key': ZOZUL_CONFIG.remote.apiKey },
632
+ });
633
+ clearTimeout(timeout);
634
+ if (res.ok) {
635
+ dataSource = 'remote';
636
+ badge.textContent = 'Remote';
637
+ badge.className = 'source-badge remote';
638
+ } else {
639
+ dataSource = 'local';
640
+ badge.textContent = 'Local';
641
+ badge.className = 'source-badge local';
642
+ }
643
+ } catch {
644
+ dataSource = 'local';
645
+ badge.textContent = 'Local';
646
+ badge.className = 'source-badge local';
535
647
  }
536
- return currentRange;
648
+ badge.style.display = '';
537
649
  }
538
650
 
539
651
  async function fetchJson(path) {
652
+ if (dataSource === 'remote' && typeof ZOZUL_CONFIG !== 'undefined') {
653
+ try {
654
+ const url = ZOZUL_CONFIG.remote.baseUrl + path.replace('/api/', '/');
655
+ const res = await fetch(url, { headers: { 'X-API-Key': ZOZUL_CONFIG.remote.apiKey } });
656
+ if (res.ok) return res.json();
657
+ } catch {}
658
+ // Remote failed — fall back to local for this request
659
+ }
540
660
  const res = await fetch(path);
541
661
  if (!res.ok) throw new Error(res.status + ' ' + path);
542
662
  return res.json();
543
663
  }
544
664
 
665
+ // ── Client-side proportional cost ──
666
+
667
+ const sessionCache = {};
668
+
669
+ async function fetchSessionCached(sessionId) {
670
+ if (sessionCache[sessionId]) return sessionCache[sessionId];
671
+ const session = await fetchJson('/api/sessions/' + sessionId);
672
+ sessionCache[sessionId] = session;
673
+ return session;
674
+ }
675
+
676
+ function proportionalCost(session, turn) {
677
+ const hasCache = turn.cache_read !== undefined && turn.cache_read !== null;
678
+ const sessionTotal = (session.total_input_tokens || 0) + (session.total_output_tokens || 0)
679
+ + (hasCache ? (session.total_cache_read_tokens || 0) + (session.total_cache_creation_tokens || 0) : 0);
680
+ if (sessionTotal === 0 || !session.total_cost_usd) return 0;
681
+ const turnTotal = (turn.input || 0) + (turn.output || 0)
682
+ + (hasCache ? (turn.cache_read || 0) + (turn.cache_creation || 0) : 0);
683
+ return session.total_cost_usd * turnTotal / sessionTotal;
684
+ }
685
+
686
+ async function enrichTurnsWithCost(turns) {
687
+ const ids = [...new Set(turns.map(t => t.session_id).filter(Boolean))];
688
+ await Promise.all(ids.map(fetchSessionCached));
689
+ for (const t of turns) {
690
+ const s = sessionCache[t.session_id];
691
+ if (!s) continue;
692
+ t.estimated_cost_usd = proportionalCost(s, {
693
+ input: t.input_tokens, output: t.output_tokens,
694
+ cache_read: t.cache_read_tokens, cache_creation: t.cache_creation_tokens,
695
+ });
696
+ if (t.block_input_tokens !== undefined) {
697
+ t.block_cost_usd = proportionalCost(s, {
698
+ input: t.block_input_tokens, output: t.block_output_tokens,
699
+ cache_read: t.block_cache_read_tokens, cache_creation: t.block_cache_creation_tokens,
700
+ });
701
+ }
702
+ }
703
+ return turns;
704
+ }
705
+
545
706
  // ── Formatting ──
546
707
 
547
708
  function fmtNum(n) { return (n ?? 0).toLocaleString(); }
548
- function fmtCost(n) { return '$' + (n ?? 0).toFixed(4); }
709
+ function fmtCost(n) {
710
+ const v = n ?? 0;
711
+ return v >= 1 ? '$' + v.toFixed(2) : '$' + v.toFixed(4);
712
+ }
549
713
  function fmtDuration(ms) {
550
714
  if (!ms || ms <= 0) return '\u2014';
551
715
  const s = Math.floor(ms / 1000);
@@ -570,6 +734,18 @@ function fmtAbsolute(s) {
570
734
  if (!s) return '';
571
735
  try { return new Date(s).toLocaleString(); } catch { return s; }
572
736
  }
737
+ // SQLite datetime() returns "YYYY-MM-DD HH:MM:SS" with no timezone — append Z to parse as UTC
738
+ function sqliteUtcToLocal(s) {
739
+ return new Date(s.replace(' ', 'T') + 'Z');
740
+ }
741
+ function fmtChartTs(s, span) {
742
+ const d = sqliteUtcToLocal(s);
743
+ if (span && span <= 24 * 3600000) {
744
+ return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
745
+ }
746
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' +
747
+ d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
748
+ }
573
749
  function shortId(s) { return s ? s.slice(0, 8) : '\u2014'; }
574
750
  function basename(p) {
575
751
  if (!p) return '\u2014';
@@ -581,8 +757,6 @@ function escHtml(s) {
581
757
  return d.innerHTML;
582
758
  }
583
759
 
584
- // ── Copy to clipboard ──
585
-
586
760
  function copyText(text) {
587
761
  navigator.clipboard.writeText(text).then(() => {
588
762
  const toast = document.getElementById('copied-toast');
@@ -591,280 +765,521 @@ function copyText(text) {
591
765
  });
592
766
  }
593
767
 
594
- // ── Dashboard load ──
768
+ // ── Navigation ──
595
769
 
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');
770
+ function showView(name) {
771
+ currentView = name;
772
+ document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
773
+ const el = document.getElementById('view-' + name);
774
+ if (el) el.classList.add('active');
775
+
776
+ // Update tab highlight for main views
777
+ document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
778
+ const mainView = ['summary', 'sessions', 'tasks', 'tags'].includes(name) ? name : null;
779
+ if (mainView) {
780
+ const tab = document.querySelector('.nav-tab[data-view="' + mainView + '"]');
781
+ if (tab) tab.classList.add('active');
625
782
  }
626
783
  }
627
784
 
628
- function manualRefresh() {
629
- if (autoRefreshTimer) { clearTimeout(autoRefreshTimer); autoRefreshTimer = null; }
630
- loadDashboard().then(scheduleAutoRefresh);
785
+ document.querySelector('.nav-tabs').addEventListener('click', e => {
786
+ const tab = e.target.closest('.nav-tab');
787
+ if (!tab) return;
788
+ const view = tab.dataset.view;
789
+ showView(view);
790
+ loadViewData(view);
791
+ });
792
+
793
+ document.addEventListener('keydown', e => {
794
+ if (e.key === 'Escape') {
795
+ if (['tag-detail', 'task-detail'].includes(currentView)) {
796
+ showView(previousView || 'tasks');
797
+ } else if (['session-detail', 'turn-detail'].includes(currentView)) {
798
+ goBackFromTurn();
799
+ }
800
+ }
801
+ });
802
+
803
+ document.addEventListener('click', e => {
804
+ const th = e.target.closest('th.sortable');
805
+ if (th) {
806
+ const [table, key] = th.dataset.sort.split(':');
807
+ const state = sortState[table];
808
+ if (state.key === key) {
809
+ state.dir = state.dir === 'asc' ? 'desc' : 'asc';
810
+ } else {
811
+ state.key = key;
812
+ state.dir = key === 'tags' || key === 'tag' ? 'asc' : 'desc';
813
+ }
814
+ // Update header indicators
815
+ th.closest('thead').querySelectorAll('th.sortable').forEach(h => {
816
+ h.classList.remove('asc', 'desc');
817
+ const [t, k] = h.dataset.sort.split(':');
818
+ if (k === state.key) h.classList.add(state.dir);
819
+ });
820
+ if (table === 'tasks') renderTaskTable(sortData(allTaskGroups, state));
821
+ else if (table === 'tags') renderTagTable(sortData(allTagStats, state));
822
+ else if (table === 'sessions') renderSessionsTable(sortData(allSessions, state));
823
+ return;
824
+ }
825
+ });
826
+
827
+ function sortData(arr, state) {
828
+ const { key, dir } = state;
829
+ return [...arr].sort((a, b) => {
830
+ let va = a[key], vb = b[key];
831
+ if (typeof va === 'string' && typeof vb === 'string') {
832
+ va = va.toLowerCase(); vb = vb.toLowerCase();
833
+ return dir === 'asc' ? va.localeCompare(vb) : vb.localeCompare(va);
834
+ }
835
+ va = va ?? 0; vb = vb ?? 0;
836
+ return dir === 'asc' ? va - vb : vb - va;
837
+ });
631
838
  }
632
839
 
633
- async function loadMoreSessions() {
840
+ // ── Summary View ──
841
+
842
+ async function loadSummary() {
634
843
  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();
844
+ const [stats, tasks, costSeries, sessionsResp] = await Promise.all([
845
+ fetchJson('/api/stats'),
846
+ fetchJson('/api/tasks'),
847
+ fetchJson('/api/metrics/cost?range=30d'),
848
+ fetchJson('/api/sessions?limit=500&offset=0'),
849
+ ]);
850
+ renderSummaryStats({
851
+ totalCost: stats.total_cost_usd ?? 0,
852
+ totalSessions: stats.total_sessions ?? 0,
853
+ totalTasks: tasks.length ?? 0,
854
+ });
855
+ renderDailyCostChart(costSeries);
856
+ const sessions = Array.isArray(sessionsResp) ? sessionsResp : sessionsResp.sessions;
857
+ renderProjectBreakdown(sessions);
643
858
  } catch (e) {
644
- console.error('load more failed', e);
859
+ console.error('summary load failed', e);
645
860
  }
646
861
  }
647
862
 
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
- }
863
+ function renderDailyCostChart(costData) {
864
+ if (!costData || !costData.length) return;
865
+ const labels = costData.map(d => {
866
+ const dt = new Date(d.timestamp);
867
+ return dt.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
868
+ });
869
+ const data = costData.map(d => d.cost);
660
870
 
661
- const AUTO_REFRESH_INTERVAL = 10_000; // ms
871
+ const existing = chartInstances['chart-daily-cost'];
872
+ if (existing) {
873
+ existing.data.labels = labels;
874
+ existing.data.datasets[0].data = data;
875
+ existing.update('none');
876
+ return;
877
+ }
662
878
 
663
- function scheduleAutoRefresh() {
664
- autoRefreshTimer = setTimeout(async () => {
665
- await loadDashboard();
666
- scheduleAutoRefresh();
667
- }, AUTO_REFRESH_INTERVAL);
879
+ const ctx = document.getElementById('chart-daily-cost');
880
+ if (!ctx) return;
881
+ const chart = new Chart(ctx, {
882
+ type: 'bar',
883
+ data: {
884
+ labels,
885
+ datasets: [{
886
+ label: 'Cost (USD)',
887
+ data,
888
+ backgroundColor: 'rgba(255,152,0,0.3)',
889
+ borderColor: '#ff9800',
890
+ borderWidth: 1,
891
+ borderRadius: 3,
892
+ }]
893
+ },
894
+ options: {
895
+ responsive: true,
896
+ plugins: {
897
+ legend: { display: false },
898
+ tooltip: { callbacks: { label: ctx => '$' + ctx.parsed.y.toFixed(4) } },
899
+ },
900
+ scales: {
901
+ x: { ticks: { color: '#8b8fa3', font: { family: '"JetBrains Mono", monospace', size: 10 } } },
902
+ y: { ticks: { color: '#8b8fa3', font: { family: '"JetBrains Mono", monospace', size: 10 }, callback: v => '$' + v.toFixed(2) } },
903
+ }
904
+ }
905
+ });
906
+ chartInstances['chart-daily-cost'] = chart;
668
907
  }
669
908
 
670
- // ── Stats ──
909
+ function renderProjectBreakdown(sessions) {
910
+ const el = document.getElementById('project-breakdown');
911
+ if (!sessions || !sessions.length) {
912
+ el.innerHTML = '<li class="empty">No sessions.</li>';
913
+ return;
914
+ }
915
+ // Group by project
916
+ const byProject = {};
917
+ for (const s of sessions) {
918
+ const proj = s.project_path || 'Unknown';
919
+ if (!byProject[proj]) byProject[proj] = 0;
920
+ byProject[proj] += s.total_cost_usd || 0;
921
+ }
922
+ const sorted = Object.entries(byProject).sort((a, b) => b[1] - a[1]);
923
+ const maxCost = sorted[0]?.[1] || 1;
924
+
925
+ el.innerHTML = sorted.slice(0, 10).map(([proj, cost]) => {
926
+ const pct = Math.round(cost / maxCost * 100);
927
+ return '<li class="project-item">' +
928
+ '<span class="project-name" title="' + escHtml(proj) + '">' + escHtml(basename(proj)) + '</span>' +
929
+ '<div class="project-bar-wrap"><div class="project-bar" style="width:' + pct + '%"></div></div>' +
930
+ '<span class="project-cost">' + fmtCost(cost) + '</span>' +
931
+ '</li>';
932
+ }).join('');
933
+ }
671
934
 
672
- function renderStats(s) {
673
- const grid = document.getElementById('stats-grid');
935
+ function renderSummaryStats(s) {
936
+ const grid = document.getElementById('summary-stats');
674
937
  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) },
938
+ { label: 'Total Cost', value: fmtCost(s.totalCost), hero: true },
939
+ { label: 'Total Sessions', value: fmtNum(s.totalSessions), hero: true },
940
+ { label: 'Total Tasks', value: fmtNum(s.totalTasks), hero: true },
682
941
  ].map(c =>
683
- '<div class="stat-card"' + (c.title ? ' title="' + c.title + '"' : '') + '>' +
942
+ '<div class="stat-card' + (c.hero ? ' hero' : '') + '">' +
684
943
  '<div class="label">' + c.label + '</div>' +
685
944
  '<div class="value">' + c.value + '</div>' +
686
945
  '</div>'
687
946
  ).join('');
688
947
  }
689
948
 
690
- // ── Tasks ──
691
-
692
- let allTasks = [];
693
- let selectedTags = new Set();
694
- let taskTurnsOffset = 0;
695
- const TURNS_PAGE = 10;
949
+ // ── Task View ──
696
950
 
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
- }
951
+ async function loadTasks() {
952
+ try {
953
+ const [stats, tasks] = await Promise.all([
954
+ fetchJson('/api/stats'),
955
+ fetchJson('/api/tasks'),
956
+ ]);
957
+ if (!tasks.length) { allTaskGroups = []; renderTaskTable([]); return; }
704
958
 
705
- async function renderTasks(tasks, activeCtx) {
706
- const panel = document.getElementById('tasks-panel');
707
- const ctxEl = document.getElementById('active-context');
959
+ // Discover tag combinations by sampling one turn per tag
960
+ const samples = await Promise.all(
961
+ tasks.map(t => fetchJson('/api/tasks/turns?tags=' + encodeURIComponent(t.task) + '&mode=any&limit=1')
962
+ .then(turns => turns[0]?.tags || t.task)
963
+ .catch(() => t.task)
964
+ )
965
+ );
966
+ // Deduplicate: normalize each combo to sorted pipe-separated
967
+ const combos = [...new Set(samples.map(s => s.split(', ').sort().join('|')))];
968
+
969
+ // Fetch stats for each unique combo
970
+ const comboStats = await Promise.all(
971
+ combos.map(combo => {
972
+ const tags = combo.split('|');
973
+ const qs = 'tags=' + tags.map(encodeURIComponent).join(',') + '&mode=all';
974
+ return fetchJson('/api/tasks/stats?' + qs)
975
+ .then(s => ({ ...s, tags: combo }))
976
+ .catch(() => ({ tags: combo, total_turns: 0, user_turns: 0, total_duration_ms: 0, total_cost_usd: 0 }));
977
+ })
978
+ );
979
+
980
+ let taskGroups = comboStats.map(s => ({
981
+ tags: s.tags,
982
+ turn_count: s.total_turns ?? 0,
983
+ human_interventions: s.user_turns ?? 0,
984
+ total_duration_ms: s.total_duration_ms ?? 0,
985
+ total_cost_usd: s.total_cost_usd ?? 0,
986
+ last_seen: s.last_seen ?? null,
987
+ }));
988
+
989
+ // Add cost gap to the Untagged row so task costs sum to total
990
+ const attributedCost = taskGroups.reduce((s, g) => s + (g.total_cost_usd || 0), 0);
991
+ const totalCost = stats.total_cost_usd || 0;
992
+ const gap = totalCost - attributedCost;
993
+ if (gap > 0.01) {
994
+ const untagged = taskGroups.find(g => g.tags === 'Untagged');
995
+ if (untagged) {
996
+ untagged.total_cost_usd = (untagged.total_cost_usd || 0) + gap;
997
+ } else {
998
+ taskGroups.push({
999
+ tags: 'Untagged',
1000
+ turn_count: 0,
1001
+ human_interventions: 0,
1002
+ total_duration_ms: 0,
1003
+ total_cost_usd: gap,
1004
+ last_seen: null,
1005
+ });
1006
+ }
1007
+ }
708
1008
 
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 = '';
1009
+ allTaskGroups = taskGroups;
1010
+ renderTaskTable(sortData(taskGroups, sortState.tasks));
1011
+ } catch (e) {
1012
+ console.error('tasks load failed', e);
716
1013
  }
1014
+ }
717
1015
 
718
- if (!tasks.length) {
719
- panel.style.display = 'none';
1016
+ function renderTaskTable(groups) {
1017
+ const tbody = document.getElementById('task-table');
1018
+ if (!groups.length) {
1019
+ 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
1020
  return;
721
1021
  }
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>';
1022
+ tbody.innerHTML = groups.map(g => {
1023
+ const tags = g.tags.split('|');
1024
+ const pills = tags.map(t =>
1025
+ '<span class="tag-pill' + (t === 'Untagged' ? ' untagged' : '') + '">' + escHtml(t) + '</span>'
1026
+ ).join('');
1027
+ return '<tr class="clickable" onclick="showTaskDetail(\'' + escHtml(g.tags).replace(/'/g, "\\'") + '\')">' +
1028
+ '<td>' + pills + '</td>' +
1029
+ '<td>' + fmtDuration(g.total_duration_ms) + '</td>' +
1030
+ '<td><span class="badge badge-cost">' + fmtCost(g.total_cost_usd) + '</span></td>' +
1031
+ '<td>' + fmtNum(g.human_interventions) + '</td>' +
1032
+ '<td title="' + fmtAbsolute(g.last_seen) + '">' + fmtRelative(g.last_seen) + '</td>' +
1033
+ '</tr>';
737
1034
  }).join('');
738
1035
  }
739
1036
 
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();
1037
+ function filterTaskTable(q) {
1038
+ q = q.toLowerCase();
1039
+ const filtered = q
1040
+ ? allTaskGroups.filter(g => g.tags.toLowerCase().includes(q))
1041
+ : allTaskGroups;
1042
+ renderTaskTable(sortData(filtered, sortState.tasks));
752
1043
  }
753
1044
 
754
- function onTaskTimeRange() { loadTaskData(); }
1045
+ // ── Task Group Detail ──
755
1046
 
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
- }
1047
+ async function showTaskDetail(tagSet) {
1048
+ previousView = 'tasks';
1049
+ showView('task-detail');
765
1050
 
766
- async function loadTaskData() {
767
- taskTurnsOffset = 0;
768
- const { qs, tags, label } = taskQueryParams();
769
- if (tags.length === 0) return;
1051
+ const tags = tagSet.split('|');
1052
+ taskDetailTags = tags;
1053
+ taskDetailOffset = 0;
770
1054
 
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);
1055
+ const statsEl = document.getElementById('task-detail-stats');
1056
+ const pills = tags.map(t =>
1057
+ '<span class="tag-pill' + (t === 'Untagged' ? ' untagged' : '') + '">' + escHtml(t) + '</span>'
1058
+ ).join(' ');
1059
+ statsEl.innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading...</div></div>';
778
1060
 
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';
1061
+ if (tags.length === 1 && tags[0] === 'Untagged') {
1062
+ 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>';
1063
+ document.getElementById('task-turns-table').innerHTML = '<tr><td colspan="5" class="empty">Untagged turns cannot be drilled into.</td></tr>';
1064
+ document.getElementById('task-turns-pagination').innerHTML = '';
1065
+ return;
785
1066
  }
786
- }
787
1067
 
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('');
1068
+ const qs = 'tags=' + tags.map(encodeURIComponent).join(',') + '&mode=all';
1069
+
1070
+ try {
1071
+ const [stats, rawTurns] = await Promise.all([
1072
+ fetchJson('/api/tasks/stats?' + qs),
1073
+ fetchJson('/api/tasks/turns?' + qs + '&limit=' + PAGE_SIZE + '&offset=0'),
1074
+ ]);
1075
+ const turns = await enrichTurnsWithCost(rawTurns);
1076
+
1077
+ statsEl.innerHTML = [
1078
+ { label: 'Task', value: '<span style="font-size:14px">' + pills + '</span>' },
1079
+ { label: 'Total Turns', value: fmtNum(stats.total_turns) },
1080
+ { label: 'Human Interventions', value: fmtNum(stats.user_turns) },
1081
+ { label: 'Duration', value: fmtDuration(stats.total_duration_ms) },
1082
+ { label: 'Cost', value: fmtCost(stats.total_cost_usd) },
1083
+ ].map(c =>
1084
+ '<div class="stat-card"><div class="label">' + c.label + '</div><div class="value" style="font-size:16px">' + c.value + '</div></div>'
1085
+ ).join('');
1086
+
1087
+ renderTaskDetailTurns(turns);
1088
+ taskDetailOffset = turns.length;
1089
+ } catch (e) {
1090
+ console.error('task detail load failed', e);
1091
+ document.getElementById('task-detail-stats').innerHTML =
1092
+ '<div class="stat-card" style="grid-column:1/-1"><div class="label" style="color:var(--red)">Error: ' + escHtml(String(e)) + '</div></div>';
1093
+ }
804
1094
  }
805
1095
 
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');
1096
+ function renderTaskDetailTurns(turns) {
1097
+ const tbody = document.getElementById('task-turns-table');
1098
+ const paginationEl = document.getElementById('task-turns-pagination');
811
1099
 
812
- if (!turns.length && !append) {
813
- section.style.display = 'none';
1100
+ if (!turns.length && taskDetailOffset === 0) {
1101
+ tbody.innerHTML = '<tr><td colspan="5" class="empty">No turns found.</td></tr>';
1102
+ paginationEl.innerHTML = '';
814
1103
  return;
815
1104
  }
816
1105
 
817
- section.style.display = '';
818
- const page = Math.floor(taskTurnsOffset / TURNS_PAGE);
819
- const hasMore = turns.length >= TURNS_PAGE;
820
- countEl.textContent = '';
1106
+ const page = Math.floor(taskDetailOffset / PAGE_SIZE);
1107
+ const hasMore = turns.length >= PAGE_SIZE;
821
1108
  paginationEl.innerHTML =
822
- (page > 0 ? '<button class="range-btn" onclick="turnsPrevPage()">\u2190 Prev</button>' : '') +
1109
+ (page > 0 ? '<button class="time-btn" onclick="taskDetailPrev()">Prev</button>' : '') +
823
1110
  '<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>' : '');
1111
+ (hasMore ? '<button class="time-btn" onclick="taskDetailNext()">Next</button>' : '');
825
1112
 
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(' ') : '';
1113
+ tbody.innerHTML = turns.map(t => {
830
1114
  const preview = t.content_text ? escHtml(t.content_text.slice(0, 80)) + (t.content_text.length > 80 ? '\u2026' : '') : '\u2014';
831
1115
  const tokens = fmtNum((t.block_input_tokens || 0) + (t.block_output_tokens || 0));
832
-
833
- return '<tr class="clickable" onclick="showTurnBlock(' + t.id + ')">' +
1116
+ return '<tr class="clickable" onclick="showTurnBlock(' + t.id + ', \'task-detail\')">' +
834
1117
  '<td title="' + fmtAbsolute(t.timestamp) + '" style="white-space:nowrap">' + fmtRelative(t.timestamp) + '</td>' +
835
1118
  '<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
1119
  '<td>' + fmtDuration(t.duration_ms) + '</td>' +
838
1120
  '<td><span class="badge badge-tokens">' + tokens + '</span></td>' +
839
1121
  '<td><span class="badge badge-cost">' + fmtCost(t.block_cost_usd) + '</span></td>' +
840
1122
  '</tr>';
841
1123
  }).join('');
1124
+ }
842
1125
 
843
- if (append) {
844
- tbody.innerHTML += html;
845
- } else {
846
- tbody.innerHTML = html;
1126
+ async function taskDetailNext() {
1127
+ taskDetailOffset += PAGE_SIZE;
1128
+ const qs = 'tags=' + taskDetailTags.map(encodeURIComponent).join(',') + '&mode=all&limit=' + PAGE_SIZE + '&offset=' + taskDetailOffset;
1129
+ const turns = await enrichTurnsWithCost(await fetchJson('/api/tasks/turns?' + qs));
1130
+ renderTaskDetailTurns(turns);
1131
+ }
1132
+
1133
+ async function taskDetailPrev() {
1134
+ taskDetailOffset = Math.max(0, taskDetailOffset - PAGE_SIZE);
1135
+ const qs = 'tags=' + taskDetailTags.map(encodeURIComponent).join(',') + '&mode=all&limit=' + PAGE_SIZE + '&offset=' + taskDetailOffset;
1136
+ const turns = await enrichTurnsWithCost(await fetchJson('/api/tasks/turns?' + qs));
1137
+ renderTaskDetailTurns(turns);
1138
+ }
1139
+
1140
+ // ── Tag View ──
1141
+
1142
+ async function loadTags() {
1143
+ try {
1144
+ const tasks = await fetchJson('/api/tasks');
1145
+ if (!tasks.length) { allTagStats = []; renderTagTable([]); return; }
1146
+ const statsList = await Promise.all(
1147
+ tasks.map(t => fetchJson('/api/tasks/stats?tags=' + encodeURIComponent(t.task) + '&mode=any')
1148
+ .then(s => ({ ...s, tag: t.task, last_seen: t.last_tagged }))
1149
+ .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 }))
1150
+ )
1151
+ );
1152
+ allTagStats = statsList.map(s => ({
1153
+ tag: s.tag,
1154
+ turn_count: s.total_turns ?? s.turn_count ?? 0,
1155
+ human_interventions: s.user_turns ?? s.human_interventions ?? 0,
1156
+ total_duration_ms: s.total_duration_ms ?? 0,
1157
+ total_cost_usd: s.total_cost_usd ?? 0,
1158
+ last_seen: s.last_seen,
1159
+ }));
1160
+ renderTagTable(sortData(allTagStats, sortState.tags));
1161
+ } catch (e) {
1162
+ console.error('tags load failed', e);
1163
+ }
1164
+ }
1165
+
1166
+ function renderTagTable(stats) {
1167
+ const tbody = document.getElementById('tag-table');
1168
+ if (!stats.length) {
1169
+ tbody.innerHTML = '<tr><td colspan="5" class="empty">No tags yet. Tag turns with <code>zozul tag</code> to see them here.</td></tr>';
1170
+ return;
847
1171
  }
1172
+ tbody.innerHTML = stats.map(t =>
1173
+ '<tr class="clickable" onclick="showTagDetail(\'' + escHtml(t.tag).replace(/'/g, "\\'") + '\')">' +
1174
+ '<td><span class="tag-pill">' + escHtml(t.tag) + '</span></td>' +
1175
+ '<td>' + fmtDuration(t.total_duration_ms) + '</td>' +
1176
+ '<td><span class="badge badge-cost">' + fmtCost(t.total_cost_usd) + '</span></td>' +
1177
+ '<td>' + fmtNum(t.human_interventions) + '</td>' +
1178
+ '<td title="' + fmtAbsolute(t.last_seen) + '">' + fmtRelative(t.last_seen) + '</td>' +
1179
+ '</tr>'
1180
+ ).join('');
1181
+ }
1182
+
1183
+ // ── Tag Detail ──
1184
+
1185
+ async function showTagDetail(tagName) {
1186
+ previousView = 'tags';
1187
+ showView('tag-detail');
1188
+ tagDetailName = tagName;
1189
+ tagDetailOffset = 0;
1190
+
1191
+ const statsEl = document.getElementById('tag-detail-stats');
1192
+ statsEl.innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading...</div></div>';
1193
+
1194
+ try {
1195
+ const tagQs = 'tags=' + encodeURIComponent(tagName) + '&mode=any';
1196
+ const [stats, rawTurns] = await Promise.all([
1197
+ fetchJson('/api/tasks/stats?' + tagQs),
1198
+ fetchJson('/api/tasks/turns?' + tagQs + '&limit=' + PAGE_SIZE + '&offset=0'),
1199
+ ]);
1200
+ const turns = await enrichTurnsWithCost(rawTurns);
1201
+
1202
+ statsEl.innerHTML = [
1203
+ { label: 'Tag', value: '<span class="tag-pill" style="font-size:14px;padding:4px 12px">' + escHtml(tagName) + '</span>' },
1204
+ { label: 'Total Turns', value: fmtNum(stats.total_turns) },
1205
+ { label: 'Human Interventions', value: fmtNum(stats.user_turns) },
1206
+ { label: 'Duration', value: fmtDuration(stats.total_duration_ms) },
1207
+ { label: 'Cost', value: fmtCost(stats.total_cost_usd) },
1208
+ ].map(c =>
1209
+ '<div class="stat-card"><div class="label">' + c.label + '</div><div class="value" style="font-size:16px">' + c.value + '</div></div>'
1210
+ ).join('');
1211
+
1212
+ renderTagDetailTurns(turns);
1213
+ tagDetailOffset = turns.length;
1214
+ } catch (e) {
1215
+ console.error('tag detail load failed', e);
1216
+ document.getElementById('tag-detail-stats').innerHTML =
1217
+ '<div class="stat-card" style="grid-column:1/-1"><div class="label" style="color:var(--red)">Error: ' + escHtml(String(e)) + '</div></div>';
1218
+ }
1219
+ }
1220
+
1221
+ function renderTagDetailTurns(turns) {
1222
+ const tbody = document.getElementById('tag-turns-table');
1223
+ const paginationEl = document.getElementById('tag-turns-pagination');
1224
+
1225
+ if (!turns.length && tagDetailOffset === 0) {
1226
+ tbody.innerHTML = '<tr><td colspan="4" class="empty">No turns found.</td></tr>';
1227
+ paginationEl.innerHTML = '';
1228
+ return;
1229
+ }
1230
+
1231
+ const page = Math.floor(tagDetailOffset / PAGE_SIZE);
1232
+ const hasMore = turns.length >= PAGE_SIZE;
1233
+ paginationEl.innerHTML =
1234
+ (page > 0 ? '<button class="time-btn" onclick="tagDetailPrev()">Prev</button>' : '') +
1235
+ '<span style="font-size:11px;color:var(--text-dim);font-family:var(--mono)">Page ' + (page + 1) + '</span>' +
1236
+ (hasMore ? '<button class="time-btn" onclick="tagDetailNext()">Next</button>' : '');
1237
+
1238
+ tbody.innerHTML = turns.map(t => {
1239
+ const preview = t.content_text ? escHtml(t.content_text.slice(0, 100)) + (t.content_text.length > 100 ? '\u2026' : '') : '\u2014';
1240
+ return '<tr class="clickable" onclick="showTurnBlock(' + t.id + ', \'tag-detail\')">' +
1241
+ '<td title="' + fmtAbsolute(t.timestamp) + '" style="white-space:nowrap">' + fmtRelative(t.timestamp) + '</td>' +
1242
+ '<td style="font-family:var(--mono);font-size:12px;max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + preview + '</td>' +
1243
+ '<td><span class="session-id" onclick="event.stopPropagation();copyText(\'' + t.session_id + '\')" title="Click to copy">' +
1244
+ shortId(t.session_id) + '<span class="copy-icon">&#x2398;</span></span></td>' +
1245
+ '<td><span class="badge badge-cost">' + fmtCost(t.block_cost_usd ?? t.estimated_cost_usd ?? 0) + '</span></td>' +
1246
+ '</tr>';
1247
+ }).join('');
848
1248
  }
849
1249
 
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');
1250
+ async function tagDetailNext() {
1251
+ tagDetailOffset += PAGE_SIZE;
1252
+ const tagQs = 'tags=' + encodeURIComponent(tagDetailName) + '&mode=any';
1253
+ const turns = await enrichTurnsWithCost(await fetchJson('/api/tasks/turns?' + tagQs + '&limit=' + PAGE_SIZE + '&offset=' + tagDetailOffset));
1254
+ renderTagDetailTurns(turns);
1255
+ }
1256
+
1257
+ async function tagDetailPrev() {
1258
+ tagDetailOffset = Math.max(0, tagDetailOffset - PAGE_SIZE);
1259
+ const tagQs = 'tags=' + encodeURIComponent(tagDetailName) + '&mode=any';
1260
+ const turns = await enrichTurnsWithCost(await fetchJson('/api/tasks/turns?' + tagQs + '&limit=' + PAGE_SIZE + '&offset=' + tagDetailOffset));
1261
+ renderTagDetailTurns(turns);
1262
+ }
1263
+
1264
+ // ── Turn Block Detail ──
1265
+
1266
+ let turnDetailReturnView = 'tasks';
1267
+
1268
+ async function showTurnBlock(turnId, returnTo) {
1269
+ turnDetailReturnView = returnTo || 'tasks';
1270
+ showView('turn-detail');
855
1271
 
856
1272
  const statsEl = document.getElementById('turn-detail-stats');
857
1273
  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>';
1274
+ statsEl.innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading...</div></div>';
859
1275
  content.innerHTML = '';
860
1276
 
861
- const block = await fetchJson('/api/turns/' + turnId + '/block');
1277
+ const block = await enrichTurnsWithCost(await fetchJson('/api/turns/' + turnId + '/block'));
862
1278
  if (!block.length) { content.innerHTML = '<div class="empty">No data</div>'; return; }
863
1279
 
864
- // Aggregate block stats
865
1280
  const totalIn = block.reduce((s, t) => s + (t.input_tokens || 0), 0);
866
1281
  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);
1282
+ const totalCost = block.reduce((s, t) => s + (t.estimated_cost_usd || 0), 0);
868
1283
  const duration = block[0].duration_ms || 0;
869
1284
  const prompt = block[0].content_text ? block[0].content_text.slice(0, 100) : '\u2014';
870
1285
 
@@ -879,367 +1294,228 @@ async function showTurnBlock(turnId) {
879
1294
  '<div class="stat-card"><div class="label">' + c.label + '</div><div class="value" style="font-size:14px">' + c.value + '</div></div>'
880
1295
  ).join('');
881
1296
 
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');
1297
+ content.innerHTML = block.map((t, i) => renderTurnHtml(t, 'tb-' + turnId + '-' + i)).join('');
984
1298
  }
985
1299
 
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;
1300
+ function goBackFromTurn() {
1301
+ if (currentView === 'turn-detail') {
1302
+ showView(turnDetailReturnView);
1303
+ } else if (currentView === 'session-detail') {
1304
+ showView(previousView || 'tasks');
997
1305
  }
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
1306
  }
1004
1307
 
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
- }
1308
+ // ── Session Detail ──
1055
1309
 
1056
- // ── Session detail ──
1310
+ async function showSession(id, returnTo) {
1311
+ previousView = returnTo || 'tasks';
1312
+ showView('session-detail');
1057
1313
 
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 = '';
1314
+ document.getElementById('session-detail-stats').innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading...</div></div>';
1315
+ document.getElementById('session-detail-turns').innerHTML = '';
1064
1316
 
1065
1317
  const [session, turns] = await Promise.all([
1066
1318
  fetchJson('/api/sessions/' + id),
1067
1319
  fetchJson('/api/sessions/' + id + '/turns'),
1068
1320
  ]);
1069
1321
 
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) },
1322
+ document.getElementById('session-detail-stats').innerHTML = [
1323
+ { 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>' },
1324
+ { label: 'Project', value: '<span title="' + escHtml(session.project_path ?? '') + '">' + escHtml(basename(session.project_path)) + '</span>' },
1325
+ { label: 'Model', value: '<span style="font-family:var(--mono);font-size:13px">' + escHtml(session.model ?? '\u2014') + '</span>' },
1326
+ { label: 'Duration', value: fmtDuration(session.total_duration_ms) },
1327
+ { label: 'Turns', value: session.total_turns },
1328
+ { label: 'Input Tokens', value: fmtNum(session.total_input_tokens) },
1329
+ { label: 'Output Tokens', value: fmtNum(session.total_output_tokens) },
1330
+ { label: 'Cost', value: fmtCost(session.total_cost_usd) },
1080
1331
  ].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>'
1332
+ '<div class="stat-card"><div class="label">' + c.label + '</div><div class="value" style="font-size:14px">' + c.value + '</div></div>'
1085
1333
  ).join('');
1086
1334
 
1087
- const turnsDiv = document.getElementById('detail-turns');
1335
+ const turnsDiv = document.getElementById('session-detail-turns');
1088
1336
  if (!turns.length) {
1089
- turnsDiv.innerHTML = '<div class="empty">No turns recorded. Run <code>zozul ingest</code> to parse transcript files.</div>';
1337
+ turnsDiv.innerHTML = '<div class="empty">No turns recorded.</div>';
1090
1338
  return;
1091
1339
  }
1340
+ turnsDiv.innerHTML = turns.map((t, i) => renderTurnHtml(t, 'tc-' + i)).join('');
1341
+ }
1092
1342
 
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
- }
1343
+ // ── Shared turn rendering ──
1344
+
1345
+ function renderTurnHtml(t, idPrefix) {
1346
+ const roleClass = t.role === 'assistant' ? 'assistant' : 'user';
1347
+ const roleLabel = t.role === 'assistant' ? 'Assistant' : 'User';
1348
+ const meta = [];
1349
+ if (t.input_tokens) meta.push(fmtNum(t.input_tokens) + ' in');
1350
+ if (t.output_tokens) meta.push(fmtNum(t.output_tokens) + ' out');
1351
+ if (t.estimated_cost_usd || t.cost_usd) meta.push(fmtCost(t.estimated_cost_usd || t.cost_usd));
1352
+ if (t.duration_ms) meta.push(fmtDuration(t.duration_ms));
1353
+
1354
+ const text = t.content_text
1355
+ ? '<div class="turn-content">' + escHtml(t.content_text) + '</div>'
1356
+ : '';
1357
+
1358
+ let toolsHtml = '';
1359
+ if (t.tool_calls) {
1360
+ try {
1361
+ const calls = JSON.parse(t.tool_calls);
1362
+ toolsHtml = '<div class="tool-calls">' + calls.map((c, ci) => {
1363
+ const uid = idPrefix + '-' + ci;
1364
+ const inputJson = JSON.stringify(c.toolInput, null, 2);
1365
+ const resultHtml = c.toolResult
1366
+ ? '<div class="tool-section-label">Result</div><div class="tool-json">' + escHtml(c.toolResult) + '</div>'
1367
+ : '';
1368
+ return '<div class="tool-call" id="' + uid + '">' +
1369
+ '<div class="tool-call-header" onclick="toggleTool(\'' + uid + '\')">' +
1370
+ '<span class="tool-name">' + escHtml(c.toolName) + '</span>' +
1371
+ '<span class="tool-chevron">&#9658;</span>' +
1372
+ '</div>' +
1373
+ '<div class="tool-body">' +
1374
+ '<div class="tool-section-label">Input</div>' +
1375
+ '<div class="tool-json">' + escHtml(inputJson) + '</div>' +
1376
+ resultHtml +
1377
+ '</div>' +
1378
+ '</div>';
1379
+ }).join('') + '</div>';
1380
+ } catch {}
1381
+ }
1131
1382
 
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('');
1383
+ return '<div class="turn">' +
1384
+ '<div class="turn-header">' +
1385
+ '<span class="turn-role ' + roleClass + '">' + roleLabel + '</span>' +
1386
+ (meta.length ? '<span class="turn-meta">' + meta.join(' \u00b7 ') + '</span>' : '') +
1387
+ '<span class="turn-ts" title="' + fmtAbsolute(t.timestamp) + '">' + fmtRelative(t.timestamp) + '</span>' +
1388
+ '</div>' +
1389
+ text +
1390
+ toolsHtml +
1391
+ '</div>';
1142
1392
  }
1143
1393
 
1144
1394
  function toggleTool(uid) {
1145
1395
  document.getElementById(uid).classList.toggle('open');
1146
1396
  }
1147
1397
 
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
- }
1398
+ // ── Sessions View ──
1153
1399
 
1154
- // ── Keyboard ──
1400
+ async function loadSessions() {
1401
+ try {
1402
+ const resp = await fetchJson('/api/sessions?limit=' + SESSIONS_PAGE + '&offset=0');
1403
+ const sessions = Array.isArray(resp) ? resp : resp.sessions;
1404
+ allSessions = sessions;
1405
+ sessionsTotal = Array.isArray(resp) ? sessions.length : resp.total;
1406
+ sessionsOffset = sessions.length;
1407
+ renderSessionsTable(sortData(sessions, sortState.sessions));
1408
+ updateLoadMore();
1409
+ } catch (e) {
1410
+ console.error('sessions load failed', e);
1411
+ }
1412
+ }
1155
1413
 
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
- }
1414
+ async function loadMoreSessions() {
1415
+ try {
1416
+ const resp = await fetchJson('/api/sessions?limit=' + SESSIONS_PAGE + '&offset=' + sessionsOffset);
1417
+ const page = Array.isArray(resp) ? resp : resp.sessions;
1418
+ allSessions = allSessions.concat(page);
1419
+ sessionsTotal = Array.isArray(resp) ? allSessions.length : resp.total;
1420
+ sessionsOffset = allSessions.length;
1421
+ const q = document.getElementById('session-filter').value;
1422
+ const display = q ? allSessions.filter(s => sessionMatches(s, q)) : allSessions;
1423
+ renderSessionsTable(sortData(display, sortState.sessions));
1424
+ updateLoadMore();
1425
+ } catch (e) {
1426
+ console.error('load more failed', e);
1162
1427
  }
1163
- });
1428
+ }
1164
1429
 
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();
1430
+ function updateLoadMore() {
1431
+ const row = document.getElementById('load-more-row');
1432
+ const shown = document.getElementById('sessions-shown');
1433
+ const count = document.getElementById('sessions-count');
1434
+ count.textContent = '(' + sessionsTotal + ')';
1435
+ if (sessionsTotal > sessionsOffset) {
1436
+ row.style.display = '';
1437
+ shown.textContent = sessionsOffset + ' of ' + sessionsTotal + ' shown';
1438
+ } else {
1439
+ row.style.display = 'none';
1440
+ }
1177
1441
  }
1178
1442
 
1179
- function updateChartLabels() {
1180
- document.getElementById('tokens-label').textContent = 'Tokens & Cost (' + chartLabel() + ')';
1443
+ function sessionMatches(s, q) {
1444
+ q = q.toLowerCase();
1445
+ return (s.id && s.id.toLowerCase().includes(q)) ||
1446
+ (s.project_path && s.project_path.toLowerCase().includes(q)) ||
1447
+ (s.model && s.model.toLowerCase().includes(q));
1181
1448
  }
1182
1449
 
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
- });
1450
+ function filterSessions(q) {
1451
+ const filtered = q ? allSessions.filter(s => sessionMatches(s, q)) : allSessions;
1452
+ renderSessionsTable(sortData(filtered, sortState.sessions));
1453
+ document.getElementById('load-more-row').style.display = q ? 'none' : (sessionsTotal > sessionsOffset ? '' : 'none');
1454
+ }
1188
1455
 
1189
- // ── Custom range ──
1456
+ function renderSessionsTable(sessions) {
1457
+ const tbody = document.getElementById('sessions-table');
1458
+ if (!sessions.length) {
1459
+ tbody.innerHTML = '<tr><td colspan="7" class="empty">No sessions yet.</td></tr>';
1460
+ return;
1461
+ }
1462
+ tbody.innerHTML = sessions.map(s => {
1463
+ const proj = basename(s.project_path);
1464
+ const projAttr = s.project_path ? ' title="' + escHtml(s.project_path) + '"' : '';
1465
+ return '<tr class="clickable" onclick="showSession(\'' + s.id + '\', \'sessions\')">' +
1466
+ '<td><span class="session-id" onclick="event.stopPropagation();copyText(\'' + s.id + '\')" title="Click to copy full ID">' +
1467
+ shortId(s.id) + '<span class="copy-icon">&#x2398;</span></span></td>' +
1468
+ '<td' + projAttr + '>' + escHtml(proj) + '</td>' +
1469
+ '<td title="' + fmtAbsolute(s.started_at) + '">' + fmtRelative(s.started_at) + '</td>' +
1470
+ '<td>' + fmtDuration(s.total_duration_ms) + '</td>' +
1471
+ '<td>' + (s.total_turns ?? 0) + '</td>' +
1472
+ '<td><span class="badge badge-cost">' + fmtCost(s.total_cost_usd) + '</span></td>' +
1473
+ '<td style="font-family:var(--mono);font-size:11px;color:var(--text-dim)">' + escHtml(s.model ?? '\u2014') + '</td>' +
1474
+ '</tr>';
1475
+ }).join('');
1476
+ }
1190
1477
 
1191
- const customRangeEl = document.getElementById('custom-range');
1192
- const customToggle = document.getElementById('custom-range-toggle');
1478
+ // ── Loading orchestration ──
1193
1479
 
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);
1480
+ async function loadViewData(view) {
1481
+ if (view === 'summary') await loadSummary();
1482
+ else if (view === 'sessions') await loadSessions();
1483
+ else if (view === 'tasks') await loadTasks();
1484
+ else if (view === 'tags') await loadTags();
1203
1485
  }
1204
1486
 
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');
1487
+ async function loadDashboard() {
1488
+ const btn = document.querySelector('.auto-btn');
1489
+ btn?.classList.add('loading');
1490
+ const minSpinner = new Promise(r => setTimeout(r, 400));
1491
+ try {
1492
+ await loadViewData(currentView);
1493
+ } catch (e) {
1494
+ console.error('load failed', e);
1495
+ } finally {
1496
+ await minSpinner;
1497
+ btn?.classList.remove('loading');
1218
1498
  }
1219
- });
1499
+ }
1220
1500
 
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
- });
1501
+ function manualRefresh() {
1502
+ if (autoRefreshTimer) { clearTimeout(autoRefreshTimer); autoRefreshTimer = null; }
1503
+ loadDashboard().then(scheduleAutoRefresh);
1504
+ }
1232
1505
 
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);
1506
+ function scheduleAutoRefresh() {
1507
+ autoRefreshTimer = setTimeout(async () => {
1508
+ // Only auto-refresh main views, not detail views
1509
+ if (['summary', 'sessions', 'tasks', 'tags'].includes(currentView)) {
1510
+ await loadDashboard();
1511
+ }
1512
+ scheduleAutoRefresh();
1513
+ }, AUTO_REFRESH_INTERVAL);
1240
1514
  }
1241
1515
 
1242
- loadDashboard().then(scheduleAutoRefresh);
1516
+ // ── Init ──
1517
+
1518
+ detectDataSource().then(() => loadDashboard()).then(scheduleAutoRefresh);
1243
1519
  </script>
1244
1520
  </body>
1245
1521
  </html>