codedash-app 2.1.0 → 3.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codedash-app",
3
- "version": "2.1.0",
3
+ "version": "3.0.1",
4
4
  "description": "Termius-style browser dashboard for Claude Code sessions. View, search, resume, and delete sessions with a dark-themed UI.",
5
5
  "bin": {
6
6
  "codedash": "./bin/cli.js"
package/src/data.js CHANGED
@@ -539,6 +539,115 @@ function searchFullText(query, sessions) {
539
539
 
540
540
  // ── Exports ────────────────────────────────────────────────
541
541
 
542
+ // ── Session replay data (with timestamps) ─────────────────
543
+
544
+ function getSessionReplay(sessionId, project) {
545
+ const found = findSessionFile(sessionId, project);
546
+ if (!found) return { messages: [], duration: 0 };
547
+
548
+ const messages = [];
549
+ const lines = fs.readFileSync(found.file, 'utf8').split('\n').filter(Boolean);
550
+
551
+ for (const line of lines) {
552
+ try {
553
+ const entry = JSON.parse(line);
554
+ let role, content, ts;
555
+
556
+ if (found.format === 'claude') {
557
+ if (entry.type !== 'user' && entry.type !== 'assistant') continue;
558
+ role = entry.type;
559
+ content = extractContent((entry.message || {}).content);
560
+ ts = entry.timestamp || '';
561
+ } else {
562
+ if (entry.type !== 'response_item' || !entry.payload) continue;
563
+ role = entry.payload.role;
564
+ if (role !== 'user' && role !== 'assistant') continue;
565
+ content = extractContent(entry.payload.content);
566
+ ts = entry.timestamp || '';
567
+ }
568
+
569
+ if (!content || isSystemMessage(content)) continue;
570
+
571
+ messages.push({
572
+ role,
573
+ content: content.slice(0, 3000),
574
+ timestamp: ts,
575
+ ms: ts ? new Date(ts).getTime() : 0,
576
+ });
577
+ } catch {}
578
+ }
579
+
580
+ // Calculate duration
581
+ const startMs = messages.length > 0 ? messages[0].ms : 0;
582
+ const endMs = messages.length > 0 ? messages[messages.length - 1].ms : 0;
583
+
584
+ return {
585
+ messages,
586
+ startMs,
587
+ endMs,
588
+ duration: endMs - startMs,
589
+ };
590
+ }
591
+
592
+ // ── Cost analytics ────────────────────────────────────────
593
+
594
+ function getCostAnalytics(sessions) {
595
+ const byDay = {};
596
+ const byProject = {};
597
+ const byWeek = {};
598
+ let totalCost = 0;
599
+ let totalTokens = 0;
600
+ const sessionCosts = [];
601
+
602
+ for (const s of sessions) {
603
+ if (!s.file_size) continue;
604
+ const tokens = s.file_size / 4;
605
+ const cost = tokens * 0.000015 * 0.3 + tokens * 0.000075 * 0.7;
606
+ totalCost += cost;
607
+ totalTokens += tokens;
608
+
609
+ // By day
610
+ const day = s.date || 'unknown';
611
+ if (!byDay[day]) byDay[day] = { cost: 0, sessions: 0, tokens: 0 };
612
+ byDay[day].cost += cost;
613
+ byDay[day].sessions++;
614
+ byDay[day].tokens += tokens;
615
+
616
+ // By week
617
+ if (s.date) {
618
+ const d = new Date(s.date);
619
+ const weekStart = new Date(d);
620
+ weekStart.setDate(d.getDate() - d.getDay());
621
+ const weekKey = weekStart.toISOString().slice(0, 10);
622
+ if (!byWeek[weekKey]) byWeek[weekKey] = { cost: 0, sessions: 0 };
623
+ byWeek[weekKey].cost += cost;
624
+ byWeek[weekKey].sessions++;
625
+ }
626
+
627
+ // By project
628
+ const proj = s.project_short || s.project || 'unknown';
629
+ if (!byProject[proj]) byProject[proj] = { cost: 0, sessions: 0, tokens: 0 };
630
+ byProject[proj].cost += cost;
631
+ byProject[proj].sessions++;
632
+ byProject[proj].tokens += tokens;
633
+
634
+ sessionCosts.push({ id: s.id, cost, project: proj, date: s.date });
635
+ }
636
+
637
+ // Sort top sessions by cost
638
+ sessionCosts.sort((a, b) => b.cost - a.cost);
639
+
640
+ return {
641
+ totalCost,
642
+ totalTokens,
643
+ totalSessions: sessions.length,
644
+ byDay,
645
+ byWeek,
646
+ byProject,
647
+ topSessions: sessionCosts.slice(0, 10),
648
+ };
649
+ }
650
+
542
651
  // ── Active sessions detection ─────────────────────────────
543
652
 
544
653
  function getActiveSessions() {
@@ -635,6 +744,8 @@ module.exports = {
635
744
  getSessionPreview,
636
745
  searchFullText,
637
746
  getActiveSessions,
747
+ getSessionReplay,
748
+ getCostAnalytics,
638
749
  CLAUDE_DIR,
639
750
  CODEX_DIR,
640
751
  HISTORY_FILE,
@@ -209,19 +209,46 @@ async function pollActiveSessions() {
209
209
  activeSessions[a.sessionId] = a;
210
210
  }
211
211
  });
212
- // Update badges without full re-render
212
+ // Update badges + animated border wrappers
213
213
  document.querySelectorAll('.card').forEach(function(card) {
214
214
  var id = card.getAttribute('data-id');
215
+
216
+ // Remove old badge
215
217
  var existing = card.querySelector('.live-badge');
216
218
  if (existing) existing.remove();
219
+
220
+ // Remove old wrapper if session no longer active
221
+ var parent = card.parentElement;
222
+ if (parent && parent.classList.contains('card-live-wrap') && !activeSessions[id]) {
223
+ parent.replaceWith(card);
224
+ card.style.border = '';
225
+ return;
226
+ }
227
+
217
228
  if (activeSessions[id]) {
218
229
  var a = activeSessions[id];
230
+
231
+ // Add badge
219
232
  var badge = document.createElement('span');
220
233
  badge.className = 'live-badge live-' + a.status;
221
234
  badge.textContent = a.status === 'waiting' ? 'WAITING' : 'LIVE';
222
235
  badge.title = 'PID ' + a.pid + ' | CPU ' + a.cpu.toFixed(1) + '% | ' + a.memoryMB + 'MB';
223
236
  var top = card.querySelector('.card-top');
224
237
  if (top) top.insertBefore(badge, top.firstChild);
238
+
239
+ // Add animated border wrapper if not already wrapped
240
+ if (!parent || !parent.classList.contains('card-live-wrap')) {
241
+ var wrap = document.createElement('div');
242
+ wrap.className = 'card-live-wrap' + (a.status === 'waiting' ? ' live-waiting' : '');
243
+ wrap.style.setProperty('--live-color', a.status === 'waiting'
244
+ ? 'rgba(251, 191, 36, 0.5)'
245
+ : 'rgba(74, 222, 128, 0.7)');
246
+ var borderDiv = document.createElement('div');
247
+ borderDiv.className = 'live-border';
248
+ card.parentNode.insertBefore(wrap, card);
249
+ wrap.appendChild(borderDiv);
250
+ wrap.appendChild(card);
251
+ }
225
252
  }
226
253
  });
227
254
  } catch {}
@@ -686,6 +713,11 @@ function render() {
686
713
  return;
687
714
  }
688
715
 
716
+ if (currentView === 'analytics') {
717
+ renderAnalytics(content);
718
+ return;
719
+ }
720
+
689
721
  if (currentView === 'starred') {
690
722
  var starredSessions = sessions.filter(function(s) { return stars.indexOf(s.id) >= 0; });
691
723
  if (starredSessions.length === 0) {
@@ -1019,9 +1051,15 @@ async function openDetail(s) {
1019
1051
 
1020
1052
  // Action buttons
1021
1053
  infoHtml += '<div class="detail-actions">';
1022
- infoHtml += '<button class="launch-btn" onclick="launchSession(\'' + s.id + '\',\'' + escHtml(s.tool) + '\',\'' + escHtml(s.project || '') + '\')">Resume in Terminal</button>';
1054
+ // Show Focus button for active sessions
1055
+ if (activeSessions[s.id]) {
1056
+ infoHtml += '<button class="launch-btn" style="background:var(--accent-green);color:#000" onclick="focusSession(\'' + s.id + '\')">Focus Terminal</button>';
1057
+ } else {
1058
+ infoHtml += '<button class="launch-btn" onclick="launchSession(\'' + s.id + '\',\'' + escHtml(s.tool) + '\',\'' + escHtml(s.project || '') + '\')">Resume in Terminal</button>';
1059
+ }
1023
1060
  infoHtml += '<button class="launch-btn btn-secondary" onclick="copyResume(\'' + s.id + '\',\'' + escHtml(s.tool) + '\')">Copy Command</button>';
1024
1061
  if (s.has_detail) {
1062
+ infoHtml += '<button class="launch-btn btn-secondary" onclick="closeDetail();openReplay(\'' + s.id + '\',\'' + escHtml(s.project || '') + '\')">Replay</button>';
1025
1063
  infoHtml += '<button class="launch-btn btn-secondary" onclick="exportMd(\'' + s.id + '\',\'' + escHtml(s.project || '') + '\')">Export MD</button>';
1026
1064
  }
1027
1065
  infoHtml += '<button class="star-btn detail-star' + (isStarred ? ' active' : '') + '" onclick="toggleStar(\'' + s.id + '\')">&#9733; ' + (isStarred ? 'Starred' : 'Star') + '</button>';
@@ -1388,6 +1426,225 @@ document.addEventListener('keydown', function(e) {
1388
1426
  }
1389
1427
  });
1390
1428
 
1429
+ // ── Session Replay ────────────────────────────────────────────
1430
+
1431
+ async function openReplay(sessionId, project) {
1432
+ var content = document.getElementById('content');
1433
+ content.innerHTML = '<div class="loading">Loading replay...</div>';
1434
+
1435
+ try {
1436
+ var resp = await fetch('/api/replay/' + sessionId + '?project=' + encodeURIComponent(project));
1437
+ var data = await resp.json();
1438
+
1439
+ if (!data.messages || data.messages.length === 0) {
1440
+ content.innerHTML = '<div class="empty-state">No messages to replay.</div>';
1441
+ return;
1442
+ }
1443
+
1444
+ var msgs = data.messages;
1445
+ var html = '<div class="replay-container">';
1446
+ html += '<div class="replay-header">';
1447
+ html += '<button class="launch-btn btn-secondary" onclick="setView(\'sessions\')">Back</button>';
1448
+ html += '<span class="replay-title">Session Replay — ' + sessionId.slice(0, 12) + '</span>';
1449
+ html += '<span class="replay-duration">' + formatDuration(data.duration) + '</span>';
1450
+ html += '</div>';
1451
+
1452
+ // Timeline slider
1453
+ html += '<div class="replay-controls">';
1454
+ html += '<button class="replay-play-btn" id="replayPlayBtn" onclick="toggleReplayPlay()">&#9654;</button>';
1455
+ html += '<input type="range" class="replay-slider" id="replaySlider" min="0" max="' + (msgs.length - 1) + '" value="0" oninput="seekReplay(this.value)">';
1456
+ html += '<span class="replay-counter" id="replayCounter">1 / ' + msgs.length + '</span>';
1457
+ html += '</div>';
1458
+
1459
+ // Messages area
1460
+ html += '<div class="replay-messages" id="replayMessages"></div>';
1461
+ html += '</div>';
1462
+
1463
+ content.innerHTML = html;
1464
+
1465
+ // Store messages for replay
1466
+ window._replayMsgs = msgs;
1467
+ window._replayPos = 0;
1468
+ window._replayPlaying = false;
1469
+ window._replayTimer = null;
1470
+ seekReplay(0);
1471
+ } catch (e) {
1472
+ content.innerHTML = '<div class="empty-state">Failed to load replay.</div>';
1473
+ }
1474
+ }
1475
+
1476
+ function seekReplay(pos) {
1477
+ pos = parseInt(pos);
1478
+ var msgs = window._replayMsgs;
1479
+ if (!msgs) return;
1480
+ window._replayPos = pos;
1481
+
1482
+ var container = document.getElementById('replayMessages');
1483
+ var slider = document.getElementById('replaySlider');
1484
+ var counter = document.getElementById('replayCounter');
1485
+ if (!container) return;
1486
+
1487
+ var html = '';
1488
+ for (var i = 0; i <= pos && i < msgs.length; i++) {
1489
+ var m = msgs[i];
1490
+ var cls = m.role === 'user' ? 'preview-user' : 'preview-assistant';
1491
+ var label = m.role === 'user' ? 'You' : 'AI';
1492
+ var time = m.timestamp ? new Date(m.timestamp).toLocaleTimeString() : '';
1493
+ var isLatest = i === pos;
1494
+ html += '<div class="replay-msg ' + cls + (isLatest ? ' replay-latest' : '') + '">';
1495
+ html += '<div class="replay-msg-header"><span class="preview-role">' + label + '</span><span class="replay-time">' + time + '</span></div>';
1496
+ html += '<div class="replay-msg-content">' + escHtml(m.content) + '</div>';
1497
+ html += '</div>';
1498
+ }
1499
+ container.innerHTML = html;
1500
+ container.scrollTop = container.scrollHeight;
1501
+
1502
+ if (slider) slider.value = pos;
1503
+ if (counter) counter.textContent = (pos + 1) + ' / ' + msgs.length;
1504
+ }
1505
+
1506
+ function toggleReplayPlay() {
1507
+ var btn = document.getElementById('replayPlayBtn');
1508
+ if (window._replayPlaying) {
1509
+ window._replayPlaying = false;
1510
+ clearInterval(window._replayTimer);
1511
+ if (btn) btn.innerHTML = '&#9654;';
1512
+ } else {
1513
+ window._replayPlaying = true;
1514
+ if (btn) btn.innerHTML = '&#9646;&#9646;';
1515
+ window._replayTimer = setInterval(function() {
1516
+ var next = window._replayPos + 1;
1517
+ if (next >= window._replayMsgs.length) {
1518
+ toggleReplayPlay();
1519
+ return;
1520
+ }
1521
+ seekReplay(next);
1522
+ }, 1500);
1523
+ }
1524
+ }
1525
+
1526
+ function formatDuration(ms) {
1527
+ if (!ms) return '';
1528
+ var s = Math.floor(ms / 1000);
1529
+ var m = Math.floor(s / 60);
1530
+ var h = Math.floor(m / 60);
1531
+ if (h > 0) return h + 'h ' + (m % 60) + 'm';
1532
+ if (m > 0) return m + 'm ' + (s % 60) + 's';
1533
+ return s + 's';
1534
+ }
1535
+
1536
+ // ── Cost Analytics ────────────────────────────────────────────
1537
+
1538
+ async function renderAnalytics(container) {
1539
+ container.innerHTML = '<div class="loading">Loading analytics...</div>';
1540
+
1541
+ try {
1542
+ var resp = await fetch('/api/analytics/cost');
1543
+ var data = await resp.json();
1544
+
1545
+ var html = '<div class="analytics-container">';
1546
+ html += '<h2 class="heatmap-title">Cost Analytics</h2>';
1547
+
1548
+ // Summary cards
1549
+ html += '<div class="analytics-summary">';
1550
+ html += '<div class="analytics-card"><span class="analytics-val">~$' + data.totalCost.toFixed(2) + '</span><span class="analytics-label">Total estimated cost</span></div>';
1551
+ html += '<div class="analytics-card"><span class="analytics-val">' + formatTokens(data.totalTokens) + '</span><span class="analytics-label">Total tokens</span></div>';
1552
+ html += '<div class="analytics-card"><span class="analytics-val">' + data.totalSessions + '</span><span class="analytics-label">Sessions</span></div>';
1553
+ html += '<div class="analytics-card"><span class="analytics-val">~$' + (data.totalCost / Math.max(data.totalSessions, 1)).toFixed(2) + '</span><span class="analytics-label">Avg per session</span></div>';
1554
+ html += '</div>';
1555
+
1556
+ // Cost by day chart (bar chart)
1557
+ var days = Object.keys(data.byDay).sort();
1558
+ var last30 = days.slice(-30);
1559
+ if (last30.length > 0) {
1560
+ var maxCost = Math.max.apply(null, last30.map(function(d) { return data.byDay[d].cost; }));
1561
+ html += '<div class="chart-section"><h3>Daily Cost (last 30 days)</h3>';
1562
+ html += '<div class="bar-chart">';
1563
+ last30.forEach(function(d) {
1564
+ var c = data.byDay[d];
1565
+ var pct = maxCost > 0 ? (c.cost / maxCost * 100) : 0;
1566
+ var label = d.slice(5); // MM-DD
1567
+ html += '<div class="bar-col" title="' + d + ': ~$' + c.cost.toFixed(2) + ' (' + c.sessions + ' sessions)">';
1568
+ html += '<div class="bar-fill" style="height:' + pct + '%"></div>';
1569
+ html += '<div class="bar-label">' + label + '</div>';
1570
+ html += '</div>';
1571
+ });
1572
+ html += '</div></div>';
1573
+ }
1574
+
1575
+ // Cost by project (horizontal bars)
1576
+ var projects = Object.entries(data.byProject).sort(function(a, b) { return b[1].cost - a[1].cost; });
1577
+ var topProjects = projects.slice(0, 10);
1578
+ if (topProjects.length > 0) {
1579
+ var maxProjCost = topProjects[0][1].cost;
1580
+ html += '<div class="chart-section"><h3>Cost by Project</h3>';
1581
+ html += '<div class="hbar-chart">';
1582
+ topProjects.forEach(function(entry) {
1583
+ var name = entry[0];
1584
+ var info = entry[1];
1585
+ var pct = maxProjCost > 0 ? (info.cost / maxProjCost * 100) : 0;
1586
+ html += '<div class="hbar-row">';
1587
+ html += '<span class="hbar-name">' + escHtml(name) + '</span>';
1588
+ html += '<div class="hbar-track"><div class="hbar-fill" style="width:' + pct + '%"></div></div>';
1589
+ html += '<span class="hbar-val">~$' + info.cost.toFixed(2) + '</span>';
1590
+ html += '</div>';
1591
+ });
1592
+ html += '</div></div>';
1593
+ }
1594
+
1595
+ // Top expensive sessions
1596
+ if (data.topSessions && data.topSessions.length > 0) {
1597
+ html += '<div class="chart-section"><h3>Most Expensive Sessions</h3>';
1598
+ html += '<div class="top-sessions">';
1599
+ data.topSessions.forEach(function(s) {
1600
+ html += '<div class="top-session-row" onclick="onCardClick(\'' + s.id + '\', event)">';
1601
+ html += '<span class="top-session-cost">~$' + s.cost.toFixed(2) + '</span>';
1602
+ html += '<span class="top-session-project">' + escHtml(s.project) + '</span>';
1603
+ html += '<span class="top-session-date">' + (s.date || '') + '</span>';
1604
+ html += '<span class="top-session-id">' + s.id.slice(0, 8) + '</span>';
1605
+ html += '</div>';
1606
+ });
1607
+ html += '</div></div>';
1608
+ }
1609
+
1610
+ html += '</div>';
1611
+ container.innerHTML = html;
1612
+ } catch (e) {
1613
+ container.innerHTML = '<div class="empty-state">Failed to load analytics.</div>';
1614
+ }
1615
+ }
1616
+
1617
+ function formatTokens(n) {
1618
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
1619
+ if (n >= 1000) return (n / 1000).toFixed(0) + 'K';
1620
+ return String(n);
1621
+ }
1622
+
1623
+ // ── Focus active session (switch to terminal) ─────────────────
1624
+
1625
+ function focusSession(sessionId) {
1626
+ var a = activeSessions[sessionId];
1627
+ if (!a) { showToast('Session not active'); return; }
1628
+
1629
+ // Use osascript via the launch API to focus the terminal window
1630
+ var terminal = localStorage.getItem('codedash-terminal') || '';
1631
+ fetch('/api/launch', {
1632
+ method: 'POST',
1633
+ headers: { 'Content-Type': 'application/json' },
1634
+ body: JSON.stringify({
1635
+ sessionId: sessionId,
1636
+ tool: a.kind === 'codex' ? 'codex' : 'claude',
1637
+ flags: ['focus'],
1638
+ project: a.cwd || '',
1639
+ terminal: terminal,
1640
+ })
1641
+ }).then(function() {
1642
+ showToast('Focused terminal');
1643
+ }).catch(function() {
1644
+ showToast('Could not focus terminal');
1645
+ });
1646
+ }
1647
+
1391
1648
  // ── Export/Import dialog ──────────────────────────────────────
1392
1649
 
1393
1650
  function showExportDialog() {
@@ -30,6 +30,10 @@
30
30
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><rect x="7" y="7" width="3" height="3"/><rect x="14" y="7" width="3" height="3"/><rect x="7" y="14" width="3" height="3"/><rect x="14" y="14" width="3" height="3"/></svg>
31
31
  Activity
32
32
  </div>
33
+ <div class="sidebar-item" data-view="analytics">
34
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
35
+ Analytics
36
+ </div>
33
37
  <div class="sidebar-item" data-view="starred">
34
38
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
35
39
  Starred
@@ -1437,50 +1437,41 @@ body {
1437
1437
  50% { opacity: 0.3; transform: scale(0.6); }
1438
1438
  }
1439
1439
 
1440
- /* Animated border for live sessions */
1441
- .card:has(.live-active),
1442
- .card:has(.live-waiting) {
1443
- border-color: transparent;
1440
+ /* Animated border wrapper for live sessions */
1441
+ .card-live-wrap {
1444
1442
  position: relative;
1445
- overflow: hidden;
1443
+ border-radius: 12px;
1444
+ padding: 2px;
1446
1445
  }
1447
1446
 
1448
- .card:has(.live-active)::before,
1449
- .card:has(.live-waiting)::before {
1450
- content: '';
1447
+ .card-live-wrap > .card {
1448
+ border: none;
1449
+ position: relative;
1450
+ z-index: 1;
1451
+ }
1452
+
1453
+ .card-live-wrap .live-border {
1451
1454
  position: absolute;
1452
- inset: -2px;
1455
+ inset: 0;
1453
1456
  border-radius: 12px;
1454
- z-index: -1;
1457
+ z-index: 0;
1455
1458
  background: conic-gradient(
1456
1459
  from var(--border-angle, 0deg),
1457
- transparent 40%,
1458
- var(--live-color) 50%,
1459
- transparent 60%
1460
+ transparent 35%,
1461
+ var(--live-color, rgba(74, 222, 128, 0.6)) 50%,
1462
+ transparent 65%
1460
1463
  );
1461
1464
  animation: border-spin 3s linear infinite;
1462
1465
  }
1463
1466
 
1464
- .card:has(.live-active)::after,
1465
- .card:has(.live-waiting)::after {
1466
- content: '';
1467
- position: absolute;
1468
- inset: 1px;
1469
- border-radius: 9px;
1470
- background: var(--bg-card);
1471
- z-index: -1;
1472
- }
1473
-
1474
- .card:has(.live-active) {
1475
- --live-color: rgba(74, 222, 128, 0.6);
1476
- }
1477
-
1478
- .card:has(.live-waiting) {
1479
- --live-color: rgba(251, 191, 36, 0.4);
1480
- }
1481
-
1482
- .card:has(.live-waiting)::before {
1467
+ .card-live-wrap.live-waiting .live-border {
1483
1468
  animation: none;
1469
+ background: conic-gradient(
1470
+ from 90deg,
1471
+ transparent 35%,
1472
+ var(--live-color, rgba(251, 191, 36, 0.4)) 50%,
1473
+ transparent 65%
1474
+ );
1484
1475
  }
1485
1476
 
1486
1477
  @keyframes border-spin {
@@ -1493,11 +1484,6 @@ body {
1493
1484
  inherits: false;
1494
1485
  }
1495
1486
 
1496
- [data-theme="light"] .card:has(.live-active)::after,
1497
- [data-theme="light"] .card:has(.live-waiting)::after {
1498
- background: #ffffff;
1499
- }
1500
-
1501
1487
  /* ── Card expand preview ────────────────────────────────────── */
1502
1488
 
1503
1489
  .card-expand-btn {
@@ -1596,6 +1582,273 @@ body {
1596
1582
  box-shadow: 0 8px 32px rgba(0,0,0,0.12);
1597
1583
  }
1598
1584
 
1585
+ /* ── Session Replay ─────────────────────────────────────────── */
1586
+
1587
+ .replay-container { padding: 20px; }
1588
+
1589
+ .replay-header {
1590
+ display: flex;
1591
+ align-items: center;
1592
+ gap: 16px;
1593
+ margin-bottom: 16px;
1594
+ }
1595
+
1596
+ .replay-title {
1597
+ font-size: 16px;
1598
+ font-weight: 600;
1599
+ flex: 1;
1600
+ }
1601
+
1602
+ .replay-duration {
1603
+ color: var(--text-muted);
1604
+ font-size: 13px;
1605
+ }
1606
+
1607
+ .replay-controls {
1608
+ display: flex;
1609
+ align-items: center;
1610
+ gap: 12px;
1611
+ margin-bottom: 20px;
1612
+ padding: 12px 16px;
1613
+ background: var(--bg-card);
1614
+ border-radius: 10px;
1615
+ border: 1px solid var(--border);
1616
+ }
1617
+
1618
+ .replay-play-btn {
1619
+ width: 36px;
1620
+ height: 36px;
1621
+ border-radius: 50%;
1622
+ border: none;
1623
+ background: var(--accent-blue);
1624
+ color: #fff;
1625
+ font-size: 14px;
1626
+ cursor: pointer;
1627
+ display: flex;
1628
+ align-items: center;
1629
+ justify-content: center;
1630
+ flex-shrink: 0;
1631
+ }
1632
+ .replay-play-btn:hover { opacity: 0.85; }
1633
+
1634
+ .replay-slider {
1635
+ flex: 1;
1636
+ height: 6px;
1637
+ -webkit-appearance: none;
1638
+ appearance: none;
1639
+ background: var(--border);
1640
+ border-radius: 3px;
1641
+ outline: none;
1642
+ cursor: pointer;
1643
+ }
1644
+ .replay-slider::-webkit-slider-thumb {
1645
+ -webkit-appearance: none;
1646
+ width: 16px;
1647
+ height: 16px;
1648
+ border-radius: 50%;
1649
+ background: var(--accent-blue);
1650
+ cursor: pointer;
1651
+ }
1652
+
1653
+ .replay-counter {
1654
+ font-size: 12px;
1655
+ color: var(--text-muted);
1656
+ white-space: nowrap;
1657
+ min-width: 60px;
1658
+ text-align: right;
1659
+ }
1660
+
1661
+ .replay-messages {
1662
+ max-height: calc(100vh - 200px);
1663
+ overflow-y: auto;
1664
+ }
1665
+
1666
+ .replay-msg {
1667
+ padding: 12px 16px;
1668
+ margin-bottom: 8px;
1669
+ border-radius: 10px;
1670
+ animation: fadeIn 0.3s ease;
1671
+ }
1672
+
1673
+ .replay-latest {
1674
+ box-shadow: 0 0 0 2px var(--accent-blue);
1675
+ }
1676
+
1677
+ .replay-msg-header {
1678
+ display: flex;
1679
+ justify-content: space-between;
1680
+ margin-bottom: 4px;
1681
+ }
1682
+
1683
+ .replay-time {
1684
+ font-size: 11px;
1685
+ color: var(--text-muted);
1686
+ }
1687
+
1688
+ .replay-msg-content {
1689
+ font-size: 13px;
1690
+ line-height: 1.6;
1691
+ white-space: pre-wrap;
1692
+ word-break: break-word;
1693
+ }
1694
+
1695
+ /* ── Cost Analytics ─────────────────────────────────────────── */
1696
+
1697
+ .analytics-container { padding: 20px; }
1698
+
1699
+ .analytics-summary {
1700
+ display: grid;
1701
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
1702
+ gap: 12px;
1703
+ margin-bottom: 24px;
1704
+ }
1705
+
1706
+ .analytics-card {
1707
+ background: var(--bg-card);
1708
+ border: 1px solid var(--border);
1709
+ border-radius: 10px;
1710
+ padding: 16px;
1711
+ display: flex;
1712
+ flex-direction: column;
1713
+ gap: 4px;
1714
+ }
1715
+
1716
+ .analytics-val {
1717
+ font-size: 24px;
1718
+ font-weight: 700;
1719
+ color: var(--accent-green);
1720
+ }
1721
+
1722
+ .analytics-label {
1723
+ font-size: 12px;
1724
+ color: var(--text-muted);
1725
+ }
1726
+
1727
+ .chart-section {
1728
+ margin-bottom: 28px;
1729
+ }
1730
+
1731
+ .chart-section h3 {
1732
+ font-size: 14px;
1733
+ font-weight: 600;
1734
+ color: var(--text-secondary);
1735
+ margin-bottom: 12px;
1736
+ }
1737
+
1738
+ /* Bar chart (vertical) */
1739
+ .bar-chart {
1740
+ display: flex;
1741
+ align-items: flex-end;
1742
+ gap: 3px;
1743
+ height: 160px;
1744
+ padding: 0 4px;
1745
+ border-bottom: 1px solid var(--border);
1746
+ }
1747
+
1748
+ .bar-col {
1749
+ flex: 1;
1750
+ display: flex;
1751
+ flex-direction: column;
1752
+ align-items: center;
1753
+ height: 100%;
1754
+ justify-content: flex-end;
1755
+ min-width: 0;
1756
+ }
1757
+
1758
+ .bar-fill {
1759
+ width: 100%;
1760
+ background: linear-gradient(to top, var(--accent-blue), var(--accent-purple));
1761
+ border-radius: 3px 3px 0 0;
1762
+ min-height: 2px;
1763
+ transition: height 0.3s ease;
1764
+ }
1765
+
1766
+ .bar-label {
1767
+ font-size: 9px;
1768
+ color: var(--text-muted);
1769
+ margin-top: 6px;
1770
+ transform: rotate(-45deg);
1771
+ white-space: nowrap;
1772
+ }
1773
+
1774
+ /* Horizontal bar chart */
1775
+ .hbar-chart {
1776
+ display: flex;
1777
+ flex-direction: column;
1778
+ gap: 8px;
1779
+ }
1780
+
1781
+ .hbar-row {
1782
+ display: flex;
1783
+ align-items: center;
1784
+ gap: 12px;
1785
+ }
1786
+
1787
+ .hbar-name {
1788
+ width: 140px;
1789
+ font-size: 13px;
1790
+ overflow: hidden;
1791
+ text-overflow: ellipsis;
1792
+ white-space: nowrap;
1793
+ flex-shrink: 0;
1794
+ }
1795
+
1796
+ .hbar-track {
1797
+ flex: 1;
1798
+ height: 24px;
1799
+ background: var(--bg-card);
1800
+ border-radius: 6px;
1801
+ overflow: hidden;
1802
+ }
1803
+
1804
+ .hbar-fill {
1805
+ height: 100%;
1806
+ background: linear-gradient(to right, var(--accent-blue), var(--accent-green));
1807
+ border-radius: 6px;
1808
+ transition: width 0.5s ease;
1809
+ }
1810
+
1811
+ .hbar-val {
1812
+ font-size: 13px;
1813
+ font-weight: 600;
1814
+ color: var(--accent-green);
1815
+ min-width: 70px;
1816
+ text-align: right;
1817
+ }
1818
+
1819
+ /* Top sessions list */
1820
+ .top-sessions { display: flex; flex-direction: column; gap: 4px; }
1821
+
1822
+ .top-session-row {
1823
+ display: flex;
1824
+ align-items: center;
1825
+ gap: 12px;
1826
+ padding: 8px 12px;
1827
+ background: var(--bg-card);
1828
+ border: 1px solid var(--border);
1829
+ border-radius: 8px;
1830
+ cursor: pointer;
1831
+ font-size: 13px;
1832
+ transition: background 0.15s;
1833
+ }
1834
+ .top-session-row:hover { background: var(--bg-card-hover); }
1835
+
1836
+ .top-session-cost {
1837
+ font-weight: 700;
1838
+ color: var(--accent-green);
1839
+ min-width: 70px;
1840
+ }
1841
+
1842
+ .top-session-project {
1843
+ flex: 1;
1844
+ overflow: hidden;
1845
+ text-overflow: ellipsis;
1846
+ white-space: nowrap;
1847
+ }
1848
+
1849
+ .top-session-date { color: var(--text-muted); font-size: 12px; }
1850
+ .top-session-id { font-family: monospace; font-size: 11px; color: var(--text-muted); }
1851
+
1599
1852
  /* ── Update banner ──────────────────────────────────────────── */
1600
1853
 
1601
1854
  .update-banner {
package/src/server.js CHANGED
@@ -3,7 +3,7 @@ const http = require('http');
3
3
  const https = require('https');
4
4
  const { URL } = require('url');
5
5
  const { exec } = require('child_process');
6
- const { loadSessions, loadSessionDetail, deleteSession, getGitCommits, exportSessionMarkdown, getSessionPreview, searchFullText, getActiveSessions } = require('./data');
6
+ const { loadSessions, loadSessionDetail, deleteSession, getGitCommits, exportSessionMarkdown, getSessionPreview, searchFullText, getActiveSessions, getSessionReplay, getCostAnalytics } = require('./data');
7
7
  const { detectTerminals, openInTerminal } = require('./terminals');
8
8
  const { getHTML } = require('./html');
9
9
 
@@ -127,6 +127,21 @@ function startServer(port, openBrowser = true) {
127
127
  json(res, results);
128
128
  }
129
129
 
130
+ // ── Session replay ─────────────────────
131
+ else if (req.method === 'GET' && pathname.startsWith('/api/replay/')) {
132
+ const sessionId = pathname.split('/').pop();
133
+ const project = parsed.searchParams.get('project') || '';
134
+ const data = getSessionReplay(sessionId, project);
135
+ json(res, data);
136
+ }
137
+
138
+ // ── Cost analytics ──────────────────────
139
+ else if (req.method === 'GET' && pathname === '/api/analytics/cost') {
140
+ const sessions = loadSessions();
141
+ const data = getCostAnalytics(sessions);
142
+ json(res, data);
143
+ }
144
+
130
145
  // ── Version check ────────────────────────
131
146
  else if (req.method === 'GET' && pathname === '/api/version') {
132
147
  const pkg = require('../package.json');