Skip to content

Commit ee7ff35

Browse files
vakovalskiiclaude
andcommitted
v3.0.0: Session Replay, Cost Analytics, Focus Terminal
Session Replay: - Timeline slider to scrub through conversation - Play/pause auto-advance (1.5s per message) - Messages appear progressively with timestamps - Latest message highlighted with blue ring - Duration shown, back button to return Cost Analytics: - Summary cards: total cost, tokens, sessions, avg per session - Daily cost bar chart (last 30 days, gradient bars) - Cost by project horizontal bars (top 10) - Most expensive sessions list (clickable) - Analytics view in sidebar Focus Terminal: - Green "Focus Terminal" button for active sessions - Replaces "Resume" when session is LIVE Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dd06b34 commit ee7ff35

6 files changed

Lines changed: 630 additions & 3 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codedash-app",
3-
"version": "2.1.0",
3+
"version": "3.0.0",
44
"description": "Termius-style browser dashboard for Claude Code sessions. View, search, resume, and delete sessions with a dark-themed UI.",
55
"bin": {
66
"codedash": "./bin/cli.js"

src/data.js

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,115 @@ function searchFullText(query, sessions) {
539539

540540
// ── Exports ────────────────────────────────────────────────
541541

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+
542651
// ── Active sessions detection ─────────────────────────────
543652

544653
function getActiveSessions() {
@@ -635,6 +744,8 @@ module.exports = {
635744
getSessionPreview,
636745
searchFullText,
637746
getActiveSessions,
747+
getSessionReplay,
748+
getCostAnalytics,
638749
CLAUDE_DIR,
639750
CODEX_DIR,
640751
HISTORY_FILE,

src/frontend/app.js

Lines changed: 231 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,11 @@ function render() {
686686
return;
687687
}
688688

689+
if (currentView === 'analytics') {
690+
renderAnalytics(content);
691+
return;
692+
}
693+
689694
if (currentView === 'starred') {
690695
var starredSessions = sessions.filter(function(s) { return stars.indexOf(s.id) >= 0; });
691696
if (starredSessions.length === 0) {
@@ -1019,9 +1024,15 @@ async function openDetail(s) {
10191024

10201025
// Action buttons
10211026
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+
}
10231033
infoHtml += '<button class="launch-btn btn-secondary" onclick="copyResume(\'' + s.id + '\',\'' + escHtml(s.tool) + '\')">Copy Command</button>';
10241034
if (s.has_detail) {
1035+
infoHtml += '<button class="launch-btn btn-secondary" onclick="closeDetail();openReplay(\'' + s.id + '\',\'' + escHtml(s.project || '') + '\')">Replay</button>';
10251036
infoHtml += '<button class="launch-btn btn-secondary" onclick="exportMd(\'' + s.id + '\',\'' + escHtml(s.project || '') + '\')">Export MD</button>';
10261037
}
10271038
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) {
13881399
}
13891400
});
13901401

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+
13911621
// ── Export/Import dialog ──────────────────────────────────────
13921622

13931623
function showExportDialog() {

src/frontend/index.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
<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>
3131
Activity
3232
</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>
3337
<div class="sidebar-item" data-view="starred">
3438
<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>
3539
Starred

0 commit comments

Comments
 (0)