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 +1 -1
- package/src/data.js +111 -0
- package/src/frontend/app.js +259 -2
- package/src/frontend/index.html +4 -0
- package/src/frontend/styles.css +290 -37
- package/src/server.js +16 -1
package/package.json
CHANGED
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,
|
package/src/frontend/app.js
CHANGED
|
@@ -209,19 +209,46 @@ async function pollActiveSessions() {
|
|
|
209
209
|
activeSessions[a.sessionId] = a;
|
|
210
210
|
}
|
|
211
211
|
});
|
|
212
|
-
// Update badges
|
|
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
|
-
|
|
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 + '\')">★ ' + (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()">▶</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 = '▶';
|
|
1512
|
+
} else {
|
|
1513
|
+
window._replayPlaying = true;
|
|
1514
|
+
if (btn) btn.innerHTML = '▮▮';
|
|
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() {
|
package/src/frontend/index.html
CHANGED
|
@@ -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
|
package/src/frontend/styles.css
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1443
|
+
border-radius: 12px;
|
|
1444
|
+
padding: 2px;
|
|
1446
1445
|
}
|
|
1447
1446
|
|
|
1448
|
-
.card
|
|
1449
|
-
|
|
1450
|
-
|
|
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:
|
|
1455
|
+
inset: 0;
|
|
1453
1456
|
border-radius: 12px;
|
|
1454
|
-
z-index:
|
|
1457
|
+
z-index: 0;
|
|
1455
1458
|
background: conic-gradient(
|
|
1456
1459
|
from var(--border-angle, 0deg),
|
|
1457
|
-
transparent
|
|
1458
|
-
var(--live-color) 50%,
|
|
1459
|
-
transparent
|
|
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
|
|
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');
|