codedash-app 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codedash-app",
3
- "version": "2.1.0",
3
+ "version": "3.0.0",
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,
@@ -686,6 +686,11 @@ function render() {
686
686
  return;
687
687
  }
688
688
 
689
+ if (currentView === 'analytics') {
690
+ renderAnalytics(content);
691
+ return;
692
+ }
693
+
689
694
  if (currentView === 'starred') {
690
695
  var starredSessions = sessions.filter(function(s) { return stars.indexOf(s.id) >= 0; });
691
696
  if (starredSessions.length === 0) {
@@ -1019,9 +1024,15 @@ async function openDetail(s) {
1019
1024
 
1020
1025
  // Action buttons
1021
1026
  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>';
1027
+ // Show Focus button for active sessions
1028
+ if (activeSessions[s.id]) {
1029
+ infoHtml += '<button class="launch-btn" style="background:var(--accent-green);color:#000" onclick="focusSession(\'' + s.id + '\')">Focus Terminal</button>';
1030
+ } else {
1031
+ infoHtml += '<button class="launch-btn" onclick="launchSession(\'' + s.id + '\',\'' + escHtml(s.tool) + '\',\'' + escHtml(s.project || '') + '\')">Resume in Terminal</button>';
1032
+ }
1023
1033
  infoHtml += '<button class="launch-btn btn-secondary" onclick="copyResume(\'' + s.id + '\',\'' + escHtml(s.tool) + '\')">Copy Command</button>';
1024
1034
  if (s.has_detail) {
1035
+ infoHtml += '<button class="launch-btn btn-secondary" onclick="closeDetail();openReplay(\'' + s.id + '\',\'' + escHtml(s.project || '') + '\')">Replay</button>';
1025
1036
  infoHtml += '<button class="launch-btn btn-secondary" onclick="exportMd(\'' + s.id + '\',\'' + escHtml(s.project || '') + '\')">Export MD</button>';
1026
1037
  }
1027
1038
  infoHtml += '<button class="star-btn detail-star' + (isStarred ? ' active' : '') + '" onclick="toggleStar(\'' + s.id + '\')">&#9733; ' + (isStarred ? 'Starred' : 'Star') + '</button>';
@@ -1388,6 +1399,225 @@ document.addEventListener('keydown', function(e) {
1388
1399
  }
1389
1400
  });
1390
1401
 
1402
+ // ── Session Replay ────────────────────────────────────────────
1403
+
1404
+ async function openReplay(sessionId, project) {
1405
+ var content = document.getElementById('content');
1406
+ content.innerHTML = '<div class="loading">Loading replay...</div>';
1407
+
1408
+ try {
1409
+ var resp = await fetch('/api/replay/' + sessionId + '?project=' + encodeURIComponent(project));
1410
+ var data = await resp.json();
1411
+
1412
+ if (!data.messages || data.messages.length === 0) {
1413
+ content.innerHTML = '<div class="empty-state">No messages to replay.</div>';
1414
+ return;
1415
+ }
1416
+
1417
+ var msgs = data.messages;
1418
+ var html = '<div class="replay-container">';
1419
+ html += '<div class="replay-header">';
1420
+ html += '<button class="launch-btn btn-secondary" onclick="setView(\'sessions\')">Back</button>';
1421
+ html += '<span class="replay-title">Session Replay — ' + sessionId.slice(0, 12) + '</span>';
1422
+ html += '<span class="replay-duration">' + formatDuration(data.duration) + '</span>';
1423
+ html += '</div>';
1424
+
1425
+ // Timeline slider
1426
+ html += '<div class="replay-controls">';
1427
+ html += '<button class="replay-play-btn" id="replayPlayBtn" onclick="toggleReplayPlay()">&#9654;</button>';
1428
+ html += '<input type="range" class="replay-slider" id="replaySlider" min="0" max="' + (msgs.length - 1) + '" value="0" oninput="seekReplay(this.value)">';
1429
+ html += '<span class="replay-counter" id="replayCounter">1 / ' + msgs.length + '</span>';
1430
+ html += '</div>';
1431
+
1432
+ // Messages area
1433
+ html += '<div class="replay-messages" id="replayMessages"></div>';
1434
+ html += '</div>';
1435
+
1436
+ content.innerHTML = html;
1437
+
1438
+ // Store messages for replay
1439
+ window._replayMsgs = msgs;
1440
+ window._replayPos = 0;
1441
+ window._replayPlaying = false;
1442
+ window._replayTimer = null;
1443
+ seekReplay(0);
1444
+ } catch (e) {
1445
+ content.innerHTML = '<div class="empty-state">Failed to load replay.</div>';
1446
+ }
1447
+ }
1448
+
1449
+ function seekReplay(pos) {
1450
+ pos = parseInt(pos);
1451
+ var msgs = window._replayMsgs;
1452
+ if (!msgs) return;
1453
+ window._replayPos = pos;
1454
+
1455
+ var container = document.getElementById('replayMessages');
1456
+ var slider = document.getElementById('replaySlider');
1457
+ var counter = document.getElementById('replayCounter');
1458
+ if (!container) return;
1459
+
1460
+ var html = '';
1461
+ for (var i = 0; i <= pos && i < msgs.length; i++) {
1462
+ var m = msgs[i];
1463
+ var cls = m.role === 'user' ? 'preview-user' : 'preview-assistant';
1464
+ var label = m.role === 'user' ? 'You' : 'AI';
1465
+ var time = m.timestamp ? new Date(m.timestamp).toLocaleTimeString() : '';
1466
+ var isLatest = i === pos;
1467
+ html += '<div class="replay-msg ' + cls + (isLatest ? ' replay-latest' : '') + '">';
1468
+ html += '<div class="replay-msg-header"><span class="preview-role">' + label + '</span><span class="replay-time">' + time + '</span></div>';
1469
+ html += '<div class="replay-msg-content">' + escHtml(m.content) + '</div>';
1470
+ html += '</div>';
1471
+ }
1472
+ container.innerHTML = html;
1473
+ container.scrollTop = container.scrollHeight;
1474
+
1475
+ if (slider) slider.value = pos;
1476
+ if (counter) counter.textContent = (pos + 1) + ' / ' + msgs.length;
1477
+ }
1478
+
1479
+ function toggleReplayPlay() {
1480
+ var btn = document.getElementById('replayPlayBtn');
1481
+ if (window._replayPlaying) {
1482
+ window._replayPlaying = false;
1483
+ clearInterval(window._replayTimer);
1484
+ if (btn) btn.innerHTML = '&#9654;';
1485
+ } else {
1486
+ window._replayPlaying = true;
1487
+ if (btn) btn.innerHTML = '&#9646;&#9646;';
1488
+ window._replayTimer = setInterval(function() {
1489
+ var next = window._replayPos + 1;
1490
+ if (next >= window._replayMsgs.length) {
1491
+ toggleReplayPlay();
1492
+ return;
1493
+ }
1494
+ seekReplay(next);
1495
+ }, 1500);
1496
+ }
1497
+ }
1498
+
1499
+ function formatDuration(ms) {
1500
+ if (!ms) return '';
1501
+ var s = Math.floor(ms / 1000);
1502
+ var m = Math.floor(s / 60);
1503
+ var h = Math.floor(m / 60);
1504
+ if (h > 0) return h + 'h ' + (m % 60) + 'm';
1505
+ if (m > 0) return m + 'm ' + (s % 60) + 's';
1506
+ return s + 's';
1507
+ }
1508
+
1509
+ // ── Cost Analytics ────────────────────────────────────────────
1510
+
1511
+ async function renderAnalytics(container) {
1512
+ container.innerHTML = '<div class="loading">Loading analytics...</div>';
1513
+
1514
+ try {
1515
+ var resp = await fetch('/api/analytics/cost');
1516
+ var data = await resp.json();
1517
+
1518
+ var html = '<div class="analytics-container">';
1519
+ html += '<h2 class="heatmap-title">Cost Analytics</h2>';
1520
+
1521
+ // Summary cards
1522
+ html += '<div class="analytics-summary">';
1523
+ html += '<div class="analytics-card"><span class="analytics-val">~$' + data.totalCost.toFixed(2) + '</span><span class="analytics-label">Total estimated cost</span></div>';
1524
+ html += '<div class="analytics-card"><span class="analytics-val">' + formatTokens(data.totalTokens) + '</span><span class="analytics-label">Total tokens</span></div>';
1525
+ html += '<div class="analytics-card"><span class="analytics-val">' + data.totalSessions + '</span><span class="analytics-label">Sessions</span></div>';
1526
+ 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>';
1527
+ html += '</div>';
1528
+
1529
+ // Cost by day chart (bar chart)
1530
+ var days = Object.keys(data.byDay).sort();
1531
+ var last30 = days.slice(-30);
1532
+ if (last30.length > 0) {
1533
+ var maxCost = Math.max.apply(null, last30.map(function(d) { return data.byDay[d].cost; }));
1534
+ html += '<div class="chart-section"><h3>Daily Cost (last 30 days)</h3>';
1535
+ html += '<div class="bar-chart">';
1536
+ last30.forEach(function(d) {
1537
+ var c = data.byDay[d];
1538
+ var pct = maxCost > 0 ? (c.cost / maxCost * 100) : 0;
1539
+ var label = d.slice(5); // MM-DD
1540
+ html += '<div class="bar-col" title="' + d + ': ~$' + c.cost.toFixed(2) + ' (' + c.sessions + ' sessions)">';
1541
+ html += '<div class="bar-fill" style="height:' + pct + '%"></div>';
1542
+ html += '<div class="bar-label">' + label + '</div>';
1543
+ html += '</div>';
1544
+ });
1545
+ html += '</div></div>';
1546
+ }
1547
+
1548
+ // Cost by project (horizontal bars)
1549
+ var projects = Object.entries(data.byProject).sort(function(a, b) { return b[1].cost - a[1].cost; });
1550
+ var topProjects = projects.slice(0, 10);
1551
+ if (topProjects.length > 0) {
1552
+ var maxProjCost = topProjects[0][1].cost;
1553
+ html += '<div class="chart-section"><h3>Cost by Project</h3>';
1554
+ html += '<div class="hbar-chart">';
1555
+ topProjects.forEach(function(entry) {
1556
+ var name = entry[0];
1557
+ var info = entry[1];
1558
+ var pct = maxProjCost > 0 ? (info.cost / maxProjCost * 100) : 0;
1559
+ html += '<div class="hbar-row">';
1560
+ html += '<span class="hbar-name">' + escHtml(name) + '</span>';
1561
+ html += '<div class="hbar-track"><div class="hbar-fill" style="width:' + pct + '%"></div></div>';
1562
+ html += '<span class="hbar-val">~$' + info.cost.toFixed(2) + '</span>';
1563
+ html += '</div>';
1564
+ });
1565
+ html += '</div></div>';
1566
+ }
1567
+
1568
+ // Top expensive sessions
1569
+ if (data.topSessions && data.topSessions.length > 0) {
1570
+ html += '<div class="chart-section"><h3>Most Expensive Sessions</h3>';
1571
+ html += '<div class="top-sessions">';
1572
+ data.topSessions.forEach(function(s) {
1573
+ html += '<div class="top-session-row" onclick="onCardClick(\'' + s.id + '\', event)">';
1574
+ html += '<span class="top-session-cost">~$' + s.cost.toFixed(2) + '</span>';
1575
+ html += '<span class="top-session-project">' + escHtml(s.project) + '</span>';
1576
+ html += '<span class="top-session-date">' + (s.date || '') + '</span>';
1577
+ html += '<span class="top-session-id">' + s.id.slice(0, 8) + '</span>';
1578
+ html += '</div>';
1579
+ });
1580
+ html += '</div></div>';
1581
+ }
1582
+
1583
+ html += '</div>';
1584
+ container.innerHTML = html;
1585
+ } catch (e) {
1586
+ container.innerHTML = '<div class="empty-state">Failed to load analytics.</div>';
1587
+ }
1588
+ }
1589
+
1590
+ function formatTokens(n) {
1591
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
1592
+ if (n >= 1000) return (n / 1000).toFixed(0) + 'K';
1593
+ return String(n);
1594
+ }
1595
+
1596
+ // ── Focus active session (switch to terminal) ─────────────────
1597
+
1598
+ function focusSession(sessionId) {
1599
+ var a = activeSessions[sessionId];
1600
+ if (!a) { showToast('Session not active'); return; }
1601
+
1602
+ // Use osascript via the launch API to focus the terminal window
1603
+ var terminal = localStorage.getItem('codedash-terminal') || '';
1604
+ fetch('/api/launch', {
1605
+ method: 'POST',
1606
+ headers: { 'Content-Type': 'application/json' },
1607
+ body: JSON.stringify({
1608
+ sessionId: sessionId,
1609
+ tool: a.kind === 'codex' ? 'codex' : 'claude',
1610
+ flags: ['focus'],
1611
+ project: a.cwd || '',
1612
+ terminal: terminal,
1613
+ })
1614
+ }).then(function() {
1615
+ showToast('Focused terminal');
1616
+ }).catch(function() {
1617
+ showToast('Could not focus terminal');
1618
+ });
1619
+ }
1620
+
1391
1621
  // ── Export/Import dialog ──────────────────────────────────────
1392
1622
 
1393
1623
  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
@@ -1596,6 +1596,273 @@ body {
1596
1596
  box-shadow: 0 8px 32px rgba(0,0,0,0.12);
1597
1597
  }
1598
1598
 
1599
+ /* ── Session Replay ─────────────────────────────────────────── */
1600
+
1601
+ .replay-container { padding: 20px; }
1602
+
1603
+ .replay-header {
1604
+ display: flex;
1605
+ align-items: center;
1606
+ gap: 16px;
1607
+ margin-bottom: 16px;
1608
+ }
1609
+
1610
+ .replay-title {
1611
+ font-size: 16px;
1612
+ font-weight: 600;
1613
+ flex: 1;
1614
+ }
1615
+
1616
+ .replay-duration {
1617
+ color: var(--text-muted);
1618
+ font-size: 13px;
1619
+ }
1620
+
1621
+ .replay-controls {
1622
+ display: flex;
1623
+ align-items: center;
1624
+ gap: 12px;
1625
+ margin-bottom: 20px;
1626
+ padding: 12px 16px;
1627
+ background: var(--bg-card);
1628
+ border-radius: 10px;
1629
+ border: 1px solid var(--border);
1630
+ }
1631
+
1632
+ .replay-play-btn {
1633
+ width: 36px;
1634
+ height: 36px;
1635
+ border-radius: 50%;
1636
+ border: none;
1637
+ background: var(--accent-blue);
1638
+ color: #fff;
1639
+ font-size: 14px;
1640
+ cursor: pointer;
1641
+ display: flex;
1642
+ align-items: center;
1643
+ justify-content: center;
1644
+ flex-shrink: 0;
1645
+ }
1646
+ .replay-play-btn:hover { opacity: 0.85; }
1647
+
1648
+ .replay-slider {
1649
+ flex: 1;
1650
+ height: 6px;
1651
+ -webkit-appearance: none;
1652
+ appearance: none;
1653
+ background: var(--border);
1654
+ border-radius: 3px;
1655
+ outline: none;
1656
+ cursor: pointer;
1657
+ }
1658
+ .replay-slider::-webkit-slider-thumb {
1659
+ -webkit-appearance: none;
1660
+ width: 16px;
1661
+ height: 16px;
1662
+ border-radius: 50%;
1663
+ background: var(--accent-blue);
1664
+ cursor: pointer;
1665
+ }
1666
+
1667
+ .replay-counter {
1668
+ font-size: 12px;
1669
+ color: var(--text-muted);
1670
+ white-space: nowrap;
1671
+ min-width: 60px;
1672
+ text-align: right;
1673
+ }
1674
+
1675
+ .replay-messages {
1676
+ max-height: calc(100vh - 200px);
1677
+ overflow-y: auto;
1678
+ }
1679
+
1680
+ .replay-msg {
1681
+ padding: 12px 16px;
1682
+ margin-bottom: 8px;
1683
+ border-radius: 10px;
1684
+ animation: fadeIn 0.3s ease;
1685
+ }
1686
+
1687
+ .replay-latest {
1688
+ box-shadow: 0 0 0 2px var(--accent-blue);
1689
+ }
1690
+
1691
+ .replay-msg-header {
1692
+ display: flex;
1693
+ justify-content: space-between;
1694
+ margin-bottom: 4px;
1695
+ }
1696
+
1697
+ .replay-time {
1698
+ font-size: 11px;
1699
+ color: var(--text-muted);
1700
+ }
1701
+
1702
+ .replay-msg-content {
1703
+ font-size: 13px;
1704
+ line-height: 1.6;
1705
+ white-space: pre-wrap;
1706
+ word-break: break-word;
1707
+ }
1708
+
1709
+ /* ── Cost Analytics ─────────────────────────────────────────── */
1710
+
1711
+ .analytics-container { padding: 20px; }
1712
+
1713
+ .analytics-summary {
1714
+ display: grid;
1715
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
1716
+ gap: 12px;
1717
+ margin-bottom: 24px;
1718
+ }
1719
+
1720
+ .analytics-card {
1721
+ background: var(--bg-card);
1722
+ border: 1px solid var(--border);
1723
+ border-radius: 10px;
1724
+ padding: 16px;
1725
+ display: flex;
1726
+ flex-direction: column;
1727
+ gap: 4px;
1728
+ }
1729
+
1730
+ .analytics-val {
1731
+ font-size: 24px;
1732
+ font-weight: 700;
1733
+ color: var(--accent-green);
1734
+ }
1735
+
1736
+ .analytics-label {
1737
+ font-size: 12px;
1738
+ color: var(--text-muted);
1739
+ }
1740
+
1741
+ .chart-section {
1742
+ margin-bottom: 28px;
1743
+ }
1744
+
1745
+ .chart-section h3 {
1746
+ font-size: 14px;
1747
+ font-weight: 600;
1748
+ color: var(--text-secondary);
1749
+ margin-bottom: 12px;
1750
+ }
1751
+
1752
+ /* Bar chart (vertical) */
1753
+ .bar-chart {
1754
+ display: flex;
1755
+ align-items: flex-end;
1756
+ gap: 3px;
1757
+ height: 160px;
1758
+ padding: 0 4px;
1759
+ border-bottom: 1px solid var(--border);
1760
+ }
1761
+
1762
+ .bar-col {
1763
+ flex: 1;
1764
+ display: flex;
1765
+ flex-direction: column;
1766
+ align-items: center;
1767
+ height: 100%;
1768
+ justify-content: flex-end;
1769
+ min-width: 0;
1770
+ }
1771
+
1772
+ .bar-fill {
1773
+ width: 100%;
1774
+ background: linear-gradient(to top, var(--accent-blue), var(--accent-purple));
1775
+ border-radius: 3px 3px 0 0;
1776
+ min-height: 2px;
1777
+ transition: height 0.3s ease;
1778
+ }
1779
+
1780
+ .bar-label {
1781
+ font-size: 9px;
1782
+ color: var(--text-muted);
1783
+ margin-top: 6px;
1784
+ transform: rotate(-45deg);
1785
+ white-space: nowrap;
1786
+ }
1787
+
1788
+ /* Horizontal bar chart */
1789
+ .hbar-chart {
1790
+ display: flex;
1791
+ flex-direction: column;
1792
+ gap: 8px;
1793
+ }
1794
+
1795
+ .hbar-row {
1796
+ display: flex;
1797
+ align-items: center;
1798
+ gap: 12px;
1799
+ }
1800
+
1801
+ .hbar-name {
1802
+ width: 140px;
1803
+ font-size: 13px;
1804
+ overflow: hidden;
1805
+ text-overflow: ellipsis;
1806
+ white-space: nowrap;
1807
+ flex-shrink: 0;
1808
+ }
1809
+
1810
+ .hbar-track {
1811
+ flex: 1;
1812
+ height: 24px;
1813
+ background: var(--bg-card);
1814
+ border-radius: 6px;
1815
+ overflow: hidden;
1816
+ }
1817
+
1818
+ .hbar-fill {
1819
+ height: 100%;
1820
+ background: linear-gradient(to right, var(--accent-blue), var(--accent-green));
1821
+ border-radius: 6px;
1822
+ transition: width 0.5s ease;
1823
+ }
1824
+
1825
+ .hbar-val {
1826
+ font-size: 13px;
1827
+ font-weight: 600;
1828
+ color: var(--accent-green);
1829
+ min-width: 70px;
1830
+ text-align: right;
1831
+ }
1832
+
1833
+ /* Top sessions list */
1834
+ .top-sessions { display: flex; flex-direction: column; gap: 4px; }
1835
+
1836
+ .top-session-row {
1837
+ display: flex;
1838
+ align-items: center;
1839
+ gap: 12px;
1840
+ padding: 8px 12px;
1841
+ background: var(--bg-card);
1842
+ border: 1px solid var(--border);
1843
+ border-radius: 8px;
1844
+ cursor: pointer;
1845
+ font-size: 13px;
1846
+ transition: background 0.15s;
1847
+ }
1848
+ .top-session-row:hover { background: var(--bg-card-hover); }
1849
+
1850
+ .top-session-cost {
1851
+ font-weight: 700;
1852
+ color: var(--accent-green);
1853
+ min-width: 70px;
1854
+ }
1855
+
1856
+ .top-session-project {
1857
+ flex: 1;
1858
+ overflow: hidden;
1859
+ text-overflow: ellipsis;
1860
+ white-space: nowrap;
1861
+ }
1862
+
1863
+ .top-session-date { color: var(--text-muted); font-size: 12px; }
1864
+ .top-session-id { font-family: monospace; font-size: 11px; color: var(--text-muted); }
1865
+
1599
1866
  /* ── Update banner ──────────────────────────────────────────── */
1600
1867
 
1601
1868
  .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');