@@ -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 + '\')">★ ' + ( 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()">▶</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 = '▶' ;
1485+ } else {
1486+ window . _replayPlaying = true ;
1487+ if ( btn ) btn . innerHTML = '▮▮' ;
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
13931623function showExportDialog ( ) {
0 commit comments