@monoes/monomindcli 1.10.39 → 1.10.40
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/dist/src/browser/batch.d.ts +0 -2
- package/dist/src/browser/batch.d.ts.map +1 -1
- package/dist/src/browser/batch.js +1 -10
- package/dist/src/browser/batch.js.map +1 -1
- package/dist/src/browser/cdp.d.ts.map +1 -1
- package/dist/src/browser/cdp.js +1 -0
- package/dist/src/browser/cdp.js.map +1 -1
- package/dist/src/browser/dialog.d.ts.map +1 -1
- package/dist/src/browser/dialog.js +4 -1
- package/dist/src/browser/dialog.js.map +1 -1
- package/dist/src/browser/network.js +1 -1
- package/dist/src/browser/network.js.map +1 -1
- package/dist/src/browser/screenshot.d.ts.map +1 -1
- package/dist/src/browser/screenshot.js +1 -0
- package/dist/src/browser/screenshot.js.map +1 -1
- package/dist/src/browser/tabs.d.ts +2 -2
- package/dist/src/browser/tabs.d.ts.map +1 -1
- package/dist/src/browser/tabs.js +6 -9
- package/dist/src/browser/tabs.js.map +1 -1
- package/dist/src/browser/trace.d.ts.map +1 -1
- package/dist/src/browser/trace.js +10 -4
- package/dist/src/browser/trace.js.map +1 -1
- package/dist/src/browser/wait.js +1 -1
- package/dist/src/browser/wait.js.map +1 -1
- package/dist/src/commands/browse.d.ts.map +1 -1
- package/dist/src/commands/browse.js +38 -5
- package/dist/src/commands/browse.js.map +1 -1
- package/dist/src/ui/.monomind/loops/loop-1779095996252-mdjpp.json +11 -0
- package/dist/src/ui/dashboard-v2.html +612 -18
- package/dist/src/ui/server.mjs +47 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
|
@@ -714,6 +714,70 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
|
|
|
714
714
|
|
|
715
715
|
/* ── streak badge ─────────────────────────────────────────── */
|
|
716
716
|
.streak-chip { display:inline-flex; align-items:center; gap:3px; font-size:11px; padding:2px 7px; border-radius:8px; background:oklch(65% 0.18 75 / 0.12); color:oklch(78% 0.18 75); white-space:nowrap; }
|
|
717
|
+
|
|
718
|
+
/* ── f47: 30-day daily cost trend ────────────────────────── */
|
|
719
|
+
#daily-trend-chart { display:flex; align-items:flex-end; gap:2px; height:40px; margin-top:6px; }
|
|
720
|
+
.dt-bar { flex:1; min-width:4px; border-radius:2px 2px 0 0; background:oklch(72% 0.18 75 / 0.5); cursor:pointer; transition:background 0.15s; }
|
|
721
|
+
.dt-bar:hover,.dt-bar.active { background:oklch(72% 0.18 75); }
|
|
722
|
+
.dt-bar.has-filter { background:oklch(65% 0.15 150 / 0.7); }
|
|
723
|
+
|
|
724
|
+
/* ── f48: live cost ticker ────────────────────────────────── */
|
|
725
|
+
#live-cost-ticker { font-size:11px; font-family:var(--mono); font-weight:600; color:oklch(78% 0.18 75); opacity:0; transition:opacity 0.3s; white-space:nowrap; }
|
|
726
|
+
#live-cost-ticker.show { opacity:1; }
|
|
727
|
+
#live-cost-ticker .lct-change { font-size:10px; color:var(--green); margin-left:3px; }
|
|
728
|
+
|
|
729
|
+
/* ── f49: hourly productivity heatmap ────────────────────── */
|
|
730
|
+
#hourly-heatmap-grid { display:grid; grid-template-columns:20px repeat(24,1fr); grid-template-rows:auto; gap:2px; margin-top:6px; font-size:9px; }
|
|
731
|
+
.hh-hour-lbl { color:var(--text-xs); text-align:center; padding-bottom:1px; }
|
|
732
|
+
.hh-day-lbl { color:var(--text-xs); line-height:12px; }
|
|
733
|
+
.hh-cell { height:10px; border-radius:2px; background:var(--surface-hi); cursor:default; }
|
|
734
|
+
.hh-cell.hh-1 { background:oklch(72% 0.18 75 / 0.25); }
|
|
735
|
+
.hh-cell.hh-2 { background:oklch(72% 0.18 75 / 0.5); }
|
|
736
|
+
.hh-cell.hh-3 { background:oklch(72% 0.18 75 / 0.75); }
|
|
737
|
+
.hh-cell.hh-4 { background:oklch(72% 0.18 75); }
|
|
738
|
+
|
|
739
|
+
/* ── f50: custom tag editor ──────────────────────────────── */
|
|
740
|
+
.sr-custom-tags { display:flex; flex-wrap:wrap; gap:4px; align-items:center; margin-top:4px; }
|
|
741
|
+
.sr-ctag { display:inline-flex; align-items:center; gap:3px; font-size:10px; padding:1px 6px; border-radius:8px; background:oklch(65% 0.15 200 / 0.15); color:oklch(72% 0.15 200); }
|
|
742
|
+
.sr-ctag .ctag-del { cursor:pointer; opacity:0.6; font-size:9px; line-height:1; }
|
|
743
|
+
.sr-ctag .ctag-del:hover { opacity:1; }
|
|
744
|
+
.ctag-add-btn { font-size:10px; color:var(--text-xs); background:none; border:1px dashed var(--border); border-radius:8px; padding:1px 6px; cursor:pointer; }
|
|
745
|
+
.ctag-add-btn:hover { color:var(--text-lo); border-color:var(--text-xs); }
|
|
746
|
+
.ctag-input-wrap { display:flex; gap:4px; margin-top:4px; }
|
|
747
|
+
.ctag-input { font-size:11px; background:var(--surface-hi); border:1px solid var(--border); border-radius:var(--r); padding:2px 6px; color:var(--text-hi); outline:none; width:100px; }
|
|
748
|
+
.ctag-input:focus { border-color:oklch(72% 0.18 75 / 0.5); }
|
|
749
|
+
.ctag-ok { font-size:10px; background:none; border:1px solid var(--border); border-radius:var(--r); padding:2px 6px; cursor:pointer; color:var(--text-lo); }
|
|
750
|
+
.ctag-ok:hover { color:var(--text-hi); }
|
|
751
|
+
.sr-autotag.ctag { background:oklch(65% 0.15 200 / 0.12); color:oklch(72% 0.15 200); }
|
|
752
|
+
|
|
753
|
+
/* ── f51: tool error drawer ──────────────────────────────── */
|
|
754
|
+
.sess-anomaly.anom-err.clickable { cursor:pointer; }
|
|
755
|
+
.sess-anomaly.anom-err.clickable:hover { opacity:0.8; }
|
|
756
|
+
.err-drawer { display:none; margin-top:6px; border:1px solid oklch(60% 0.18 25 / 0.3); border-radius:var(--r); overflow:hidden; }
|
|
757
|
+
.err-drawer.open { display:block; }
|
|
758
|
+
.err-drawer-head { display:flex; align-items:center; gap:8px; padding:6px 10px; background:oklch(60% 0.18 25 / 0.08); font-size:11px; font-weight:600; color:oklch(70% 0.18 25); }
|
|
759
|
+
.err-drawer-head .err-close { margin-left:auto; cursor:pointer; font-size:12px; background:none; border:none; color:var(--text-xs); }
|
|
760
|
+
.err-drawer-body { max-height:160px; overflow-y:auto; padding:8px 10px; }
|
|
761
|
+
.err-item { font-size:11px; font-family:var(--mono); color:oklch(80% 0.1 25); border-bottom:1px solid var(--border); padding:4px 0; white-space:pre-wrap; word-break:break-word; }
|
|
762
|
+
.err-item:last-child { border:none; }
|
|
763
|
+
|
|
764
|
+
/* ── f52: prompt copy button ─────────────────────────────── */
|
|
765
|
+
.sr-copy-btn { font-size:10px; background:none; border:none; cursor:pointer; color:var(--text-xs); padding:0 4px; opacity:0; transition:opacity 0.15s; line-height:1; }
|
|
766
|
+
.sess-row:hover .sr-copy-btn { opacity:1; }
|
|
767
|
+
.sr-copy-btn:hover { color:var(--text-hi); }
|
|
768
|
+
.sr-copy-btn.copied { color:var(--green); opacity:1; }
|
|
769
|
+
|
|
770
|
+
/* ── f53: session cost histogram ─────────────────────────── */
|
|
771
|
+
#cost-histogram-panel { display:none; margin-bottom:16px; padding:12px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r); }
|
|
772
|
+
.ch-title { font-size:11px; font-weight:600; color:var(--text-lo); letter-spacing:0.06em; text-transform:uppercase; margin-bottom:8px; }
|
|
773
|
+
.ch-bars { display:flex; align-items:flex-end; gap:4px; height:50px; }
|
|
774
|
+
.ch-bar-wrap { flex:1; display:flex; flex-direction:column; align-items:center; gap:2px; }
|
|
775
|
+
.ch-bar { width:100%; border-radius:2px 2px 0 0; background:oklch(72% 0.18 75 / 0.4); min-height:2px; }
|
|
776
|
+
.ch-lbl { font-size:8px; color:var(--text-xs); white-space:nowrap; }
|
|
777
|
+
.ch-cnt { font-size:9px; color:var(--text-lo); }
|
|
778
|
+
|
|
779
|
+
/* ── f54: URL param indicator ────────────────────────────── */
|
|
780
|
+
.url-param-active { background:oklch(72% 0.18 75 / 0.08); }
|
|
717
781
|
</style>
|
|
718
782
|
</head>
|
|
719
783
|
<body>
|
|
@@ -771,6 +835,7 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
|
|
|
771
835
|
<span id="view-title">Now</span>
|
|
772
836
|
<span class="pill"><span class="live-dot"></span> live</span>
|
|
773
837
|
<span id="topbar-cost"></span>
|
|
838
|
+
<span id="live-cost-ticker" title="Current session accumulated cost"></span>
|
|
774
839
|
<span id="topbar-activity"></span>
|
|
775
840
|
<div id="tb-right">
|
|
776
841
|
<button class="btn" id="btn-budget" onclick="openBudgetModal()" title="Set daily/monthly cost budget">⚑ Budget</button>
|
|
@@ -915,6 +980,14 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
|
|
|
915
980
|
<div class="m-group-title">Token Velocity</div>
|
|
916
981
|
<div class="loading-txt">—</div>
|
|
917
982
|
</div>
|
|
983
|
+
<div id="m-daily-trend">
|
|
984
|
+
<div class="m-group-title">30-Day Cost Trend</div>
|
|
985
|
+
<div class="loading-txt">—</div>
|
|
986
|
+
</div>
|
|
987
|
+
<div id="m-hourly-heatmap">
|
|
988
|
+
<div class="m-group-title">Peak Work Hours</div>
|
|
989
|
+
<div class="loading-txt">—</div>
|
|
990
|
+
</div>
|
|
918
991
|
</div>
|
|
919
992
|
</div>
|
|
920
993
|
|
|
@@ -956,6 +1029,7 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
|
|
|
956
1029
|
<th class="lb-rank">#</th><th>Session</th><th class="lb-cost">Cost</th><th class="lb-dur">Duration</th>
|
|
957
1030
|
</tr></thead><tbody id="lb-body"></tbody></table>
|
|
958
1031
|
</div>
|
|
1032
|
+
<div id="cost-histogram-panel"></div>
|
|
959
1033
|
<div id="model-mix-panel" style="display:none;margin-bottom:16px">
|
|
960
1034
|
<div id="model-mix-body"></div>
|
|
961
1035
|
</div>
|
|
@@ -1186,11 +1260,12 @@ async function init() {
|
|
|
1186
1260
|
document.getElementById('sb-proj').textContent = DIR.split('/').filter(Boolean).pop() || '—';
|
|
1187
1261
|
document.getElementById('sb-path').textContent = DIR;
|
|
1188
1262
|
}
|
|
1189
|
-
|
|
1263
|
+
restoreURLParams();
|
|
1264
|
+
viewRendered['now'] = true;
|
|
1190
1265
|
updateBudgetBtnStyle();
|
|
1191
1266
|
await refreshNow();
|
|
1192
1267
|
if (sessParam) setTimeout(() => jumpToSession(sessParam), 300);
|
|
1193
|
-
|
|
1268
|
+
initSSE();
|
|
1194
1269
|
}
|
|
1195
1270
|
|
|
1196
1271
|
function startPolling() {
|
|
@@ -1198,6 +1273,20 @@ function startPolling() {
|
|
|
1198
1273
|
pollTimer = setInterval(() => { if (currentView === 'now') refreshNowSilent(); }, 30000);
|
|
1199
1274
|
}
|
|
1200
1275
|
|
|
1276
|
+
let _sseSource = null;
|
|
1277
|
+
function initSSE() {
|
|
1278
|
+
if (_sseSource) { try { _sseSource.close(); } catch {} _sseSource = null; }
|
|
1279
|
+
if (!DIR || !window.EventSource) { startPolling(); return; }
|
|
1280
|
+
try {
|
|
1281
|
+
const src = new EventSource('/api/events-stream?dir=' + enc(DIR));
|
|
1282
|
+
src.addEventListener('update', () => { if (currentView === 'now') refreshNowSilent(); });
|
|
1283
|
+
src.addEventListener('connected', () => {});
|
|
1284
|
+
src.onerror = () => { src.close(); _sseSource = null; startPolling(); };
|
|
1285
|
+
_sseSource = src;
|
|
1286
|
+
clearInterval(pollTimer); // SSE replaces polling
|
|
1287
|
+
} catch { startPolling(); }
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1201
1290
|
async function apiFetch(url) {
|
|
1202
1291
|
const r = await fetch(url);
|
|
1203
1292
|
if (!r.ok) throw new Error(r.status);
|
|
@@ -1239,7 +1328,7 @@ function toggleLiveTail() {
|
|
|
1239
1328
|
if (sessionIdx !== 0 && allSessions.length) { sessionIdx = 0; userScrolled = false; loadFeedForSession(allSessions[0]); }
|
|
1240
1329
|
liveTailTimer = setInterval(() => { if (currentView === 'now') refreshNowSilent(); }, 5000);
|
|
1241
1330
|
} else {
|
|
1242
|
-
|
|
1331
|
+
initSSE();
|
|
1243
1332
|
}
|
|
1244
1333
|
}
|
|
1245
1334
|
|
|
@@ -1293,6 +1382,9 @@ async function loadFeedForSession(sess) {
|
|
|
1293
1382
|
document.getElementById('btn-prev-sess').style.opacity = sessionIdx < allSessions.length - 1 ? '1' : '0.3';
|
|
1294
1383
|
document.getElementById('btn-next-sess').style.opacity = sessionIdx > 0 ? '1' : '0.3';
|
|
1295
1384
|
showSessCtx(sess);
|
|
1385
|
+
// f48: live cost ticker
|
|
1386
|
+
const sessCost = typeof sess.totalCost === 'number' ? sess.totalCost : (typeof sess.cost === 'number' ? sess.cost : null);
|
|
1387
|
+
updateLiveTicker(sessCost);
|
|
1296
1388
|
if (!sess.file) { setFeedContent('<div class="feed-empty">Session file path unavailable.</div>'); return; }
|
|
1297
1389
|
try {
|
|
1298
1390
|
const data = await apiFetch('/api/session?dir=' + enc(DIR) + '&file=' + enc(sess.file) + '&limit=120');
|
|
@@ -1778,21 +1870,42 @@ async function renderSessions() {
|
|
|
1778
1870
|
}
|
|
1779
1871
|
let toShow = showStarredOnly ? sessions.filter(s => bookmarks.has(s.id)) : sessions;
|
|
1780
1872
|
if (activeTagFilter) toShow = toShow.filter(s => (allTags.sessionTags.get(s.id) || []).includes(activeTagFilter));
|
|
1873
|
+
if (heatmapDateFilter) toShow = toShow.filter(s => {
|
|
1874
|
+
const t = s.lastTs || s.mtime; if (!t) return false;
|
|
1875
|
+
return new Date(typeof t === 'number' ? t : t).toDateString() === heatmapDateFilter;
|
|
1876
|
+
});
|
|
1781
1877
|
if (!toShow.length) {
|
|
1782
1878
|
el.innerHTML = '<div class="empty"><div class="empty-ico">☆</div><div>No bookmarked sessions</div></div>';
|
|
1879
|
+
buildSessionHeatmap(sessions);
|
|
1783
1880
|
return;
|
|
1784
1881
|
}
|
|
1785
1882
|
// compute median cost for anomaly detection
|
|
1786
1883
|
const costsForMedian = sessions.map(s => s.totalCost || 0).filter(c => c > 0).sort((a, b) => a - b);
|
|
1787
1884
|
const medianCost = costsForMedian.length ? costsForMedian[Math.floor(costsForMedian.length / 2)] : 0;
|
|
1788
|
-
|
|
1789
|
-
|
|
1885
|
+
|
|
1886
|
+
// group by date
|
|
1887
|
+
const now = Date.now(); const DAY = 86400000;
|
|
1888
|
+
function sessDateGroup(s) {
|
|
1889
|
+
const t = s.lastTs || s.mtime; if (!t) return 'Older';
|
|
1890
|
+
const age = now - (typeof t === 'number' ? t : new Date(t).getTime());
|
|
1891
|
+
if (age < DAY) return 'Today';
|
|
1892
|
+
if (age < 2 * DAY) return 'Yesterday';
|
|
1893
|
+
if (age < 8 * DAY) return 'This week';
|
|
1894
|
+
return 'Older';
|
|
1895
|
+
}
|
|
1896
|
+
const GROUP_ORDER = ['Today', 'Yesterday', 'This week', 'Older'];
|
|
1897
|
+
const groups = {};
|
|
1898
|
+
for (const s of toShow) {
|
|
1899
|
+
const g = sessDateGroup(s); if (!groups[g]) groups[g] = [];
|
|
1900
|
+
groups[g].push(s);
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
function renderSessRow(s, idx) {
|
|
1790
1904
|
const dur = s.totalDurationMs ? fmtDur(s.totalDurationMs) : '';
|
|
1791
1905
|
const msgs = s.totalMessages ? s.totalMessages + ' msg' : '';
|
|
1792
1906
|
const cost = typeof s.totalCost === 'number' ? '$' + s.totalCost.toFixed(2)
|
|
1793
1907
|
: typeof s.cost === 'number' ? '$' + s.cost.toFixed(2) : '';
|
|
1794
1908
|
const meta = [dur, msgs, cost].filter(Boolean).join(' · ') || s.id.slice(0, 16);
|
|
1795
|
-
// anomaly badge
|
|
1796
1909
|
const sCost = s.totalCost || 0;
|
|
1797
1910
|
let anomBadge = '';
|
|
1798
1911
|
if (medianCost > 0.05 && sCost > medianCost * 3 && sCost > 0.5) {
|
|
@@ -1811,16 +1924,29 @@ async function renderSessions() {
|
|
|
1811
1924
|
const sData = JSON.stringify(s).replace(/'/g, ''');
|
|
1812
1925
|
const note = getSessNote(s.id);
|
|
1813
1926
|
const hasNote = !!note;
|
|
1814
|
-
|
|
1927
|
+
const files = (s.filesTouched || []).slice(0, 5);
|
|
1928
|
+
const filesHtml = files.length ? `<div class="sr-files">${files.map(f => `<span class="sr-file-chip">${esc(f)}</span>`).join('')}${(s.filesTouched||[]).length > 5 ? `<span class="sr-file-chip">+${(s.filesTouched||[]).length-5}</span>` : ''}</div>` : '';
|
|
1929
|
+
// f51: error badge — clickable if errors exist
|
|
1930
|
+
const errBadge = (s.errorCount > 0 && s.toolCalls > 0 && (s.errorCount / s.toolCalls) > 0.3)
|
|
1931
|
+
? `<span class="sess-anomaly anom-err clickable" onclick="toggleErrDrawer('${esc(s.id)}',event)" title="Click to see ${s.errorCount} tool errors">${s.errorCount} err</span>`
|
|
1932
|
+
: anomBadge;
|
|
1933
|
+
// f50: custom tags
|
|
1934
|
+
const ctags = getCustomTags(s.id);
|
|
1935
|
+
const ctagsHtml = renderCustomTagsInline(s.id, ctags);
|
|
1936
|
+
return `<div class="sess-row" data-sess-idx="${idx}" data-sess-id="${esc(s.id)}" onclick="handleSessRowClick(event,this,'${esc(s.id)}')" data-sess-data='${sData}'>
|
|
1815
1937
|
<div class="sr-top">
|
|
1816
1938
|
<div class="sr-prompt">${esc(s.lastPrompt || s.id)}</div>
|
|
1817
1939
|
<div class="sr-time">${relTime(s.lastTs || s.mtime)}</div>
|
|
1940
|
+
<button class="sr-copy-btn" onclick="copyPrompt(${JSON.stringify(s.lastPrompt || s.id)},event)" title="Copy prompt to clipboard">⎘</button>
|
|
1818
1941
|
<button class="sess-star${isStarred ? ' on' : ''}" data-sid="${esc(s.id)}" onclick="toggleBookmark('${esc(s.id)}',event)" title="${isStarred ? 'Remove bookmark' : 'Bookmark session'}">${isStarred ? '★' : '☆'}</button>
|
|
1819
1942
|
<span class="sr-view">→ view</span>
|
|
1820
1943
|
</div>
|
|
1821
|
-
<div class="sr-meta">${esc(meta)}${
|
|
1944
|
+
<div class="sr-meta">${esc(meta)}${errBadge}</div>
|
|
1822
1945
|
${(summaries || autoTags) ? `<div class="sr-tags">${summaries}${autoTags}</div>` : ''}
|
|
1946
|
+
${ctagsHtml}
|
|
1947
|
+
${filesHtml}
|
|
1823
1948
|
${satBar}
|
|
1949
|
+
<div class="err-drawer" id="err-drawer-${esc(s.id)}"></div>
|
|
1824
1950
|
<div class="sess-notes-wrap" onclick="event.stopPropagation()">
|
|
1825
1951
|
<button class="sess-notes-toggle${hasNote ? ' has-note' : ''}" onclick="toggleSessNote('${esc(s.id)}',this)">✎ ${hasNote ? 'Note' : 'Add note'}</button>
|
|
1826
1952
|
<div class="sess-notes-area" id="snote-${esc(s.id)}">
|
|
@@ -1829,17 +1955,36 @@ async function renderSessions() {
|
|
|
1829
1955
|
</div>
|
|
1830
1956
|
</div>
|
|
1831
1957
|
</div>`;
|
|
1832
|
-
}
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
let html = '';
|
|
1961
|
+
let flatIdx = 0;
|
|
1962
|
+
for (const grp of GROUP_ORDER) {
|
|
1963
|
+
const items = groups[grp];
|
|
1964
|
+
if (!items || !items.length) continue;
|
|
1965
|
+
const gid = 'sg-' + grp.replace(/\s+/g, '-').toLowerCase();
|
|
1966
|
+
html += `<div class="sg-section"><div class="sg-header" onclick="toggleSessGroup('${gid}')">
|
|
1967
|
+
<span class="sg-title">${grp}</span><span class="sg-count">${items.length}</span><span class="sg-toggle">▾</span>
|
|
1968
|
+
</div><div class="sg-body" id="${gid}">`;
|
|
1969
|
+
for (const s of items) { html += renderSessRow(s, flatIdx++); }
|
|
1970
|
+
html += '</div></div>';
|
|
1971
|
+
}
|
|
1972
|
+
el.innerHTML = html;
|
|
1833
1973
|
// prepend tag filter bar if there are common tags
|
|
1834
1974
|
if (allTags.common.size > 1) {
|
|
1835
1975
|
el.innerHTML = buildTagFilterBar(toShow) + el.innerHTML;
|
|
1836
1976
|
}
|
|
1977
|
+
buildSessionHeatmap(sessions);
|
|
1837
1978
|
buildDigest();
|
|
1838
1979
|
buildWeeklyRecap();
|
|
1839
1980
|
buildEfficiencyPanel();
|
|
1840
1981
|
buildActivityHeatmap();
|
|
1841
1982
|
buildTokenVelocity();
|
|
1983
|
+
buildDailyCostTrend();
|
|
1984
|
+
buildHourlyHeatmap();
|
|
1985
|
+
buildCostHistogram();
|
|
1842
1986
|
if (leaderboardOpen) renderLeaderboard();
|
|
1987
|
+
syncURLParams();
|
|
1843
1988
|
// check budget thresholds and fire toasts
|
|
1844
1989
|
const todayCost = allSessions.filter(s => {
|
|
1845
1990
|
const t = s.firstTs || s.mtime;
|
|
@@ -1888,10 +2033,10 @@ function toggleSessStarFilter() {
|
|
|
1888
2033
|
showStarredOnly = !showStarredOnly;
|
|
1889
2034
|
const btn = document.getElementById('sess-star-filter');
|
|
1890
2035
|
btn.classList.toggle('on', showStarredOnly);
|
|
1891
|
-
// re-render with filter
|
|
1892
2036
|
viewRendered['sessions'] = false;
|
|
1893
2037
|
renderSessions();
|
|
1894
2038
|
viewRendered['sessions'] = true;
|
|
2039
|
+
syncURLParams();
|
|
1895
2040
|
}
|
|
1896
2041
|
|
|
1897
2042
|
// ── feature 1: auto-tags ───────────────────────────────────
|
|
@@ -1939,6 +2084,7 @@ function setTagFilter(tag) {
|
|
|
1939
2084
|
viewRendered['sessions'] = false;
|
|
1940
2085
|
renderSessions();
|
|
1941
2086
|
viewRendered['sessions'] = true;
|
|
2087
|
+
syncURLParams();
|
|
1942
2088
|
}
|
|
1943
2089
|
|
|
1944
2090
|
// ── feature 2: session recap ───────────────────────────────
|
|
@@ -2680,6 +2826,42 @@ function toggleLoop(row) {
|
|
|
2680
2826
|
row.classList.toggle('open');
|
|
2681
2827
|
}
|
|
2682
2828
|
|
|
2829
|
+
function showLoopForm() {
|
|
2830
|
+
document.getElementById('loop-create-form').style.display = 'block';
|
|
2831
|
+
document.getElementById('btn-new-loop').style.display = 'none';
|
|
2832
|
+
document.getElementById('lcf-prompt').focus();
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
function hideLoopForm() {
|
|
2836
|
+
document.getElementById('loop-create-form').style.display = 'none';
|
|
2837
|
+
document.getElementById('btn-new-loop').style.display = '';
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
async function createLoop() {
|
|
2841
|
+
const prompt = document.getElementById('lcf-prompt').value.trim();
|
|
2842
|
+
if (!prompt) { showToast('Required', 'Prompt is required', 'warn'); return; }
|
|
2843
|
+
const name = document.getElementById('lcf-name').value.trim();
|
|
2844
|
+
const interval = document.getElementById('lcf-interval').value.trim() || '1h';
|
|
2845
|
+
const maxRepsVal = document.getElementById('lcf-maxreps').value;
|
|
2846
|
+
const maxReps = maxRepsVal ? parseInt(maxRepsVal) : null;
|
|
2847
|
+
try {
|
|
2848
|
+
const r = await fetch('/api/loops/create?dir=' + enc(DIR), {
|
|
2849
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2850
|
+
body: JSON.stringify({ name, prompt, interval, maxReps }),
|
|
2851
|
+
});
|
|
2852
|
+
const d = await r.json();
|
|
2853
|
+
if (!d.ok) { showToast('Error', d.error || 'Failed to create loop', 'err'); return; }
|
|
2854
|
+
showToast('Created', `Loop created: ${name || prompt.slice(0, 30)}`, 'ok');
|
|
2855
|
+
hideLoopForm();
|
|
2856
|
+
document.getElementById('lcf-prompt').value = '';
|
|
2857
|
+
document.getElementById('lcf-name').value = '';
|
|
2858
|
+
document.getElementById('lcf-interval').value = '1h';
|
|
2859
|
+
document.getElementById('lcf-maxreps').value = '';
|
|
2860
|
+
viewRendered['loops'] = false;
|
|
2861
|
+
renderLoops();
|
|
2862
|
+
} catch (err) { showToast('Error', err.message, 'err'); }
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2683
2865
|
// ── feature 19: cache efficiency panel ────────────────────
|
|
2684
2866
|
function buildEfficiencyPanel() {
|
|
2685
2867
|
const el = document.getElementById('m-efficiency');
|
|
@@ -2887,12 +3069,21 @@ async function renderMemory() {
|
|
|
2887
3069
|
).join('');
|
|
2888
3070
|
}
|
|
2889
3071
|
|
|
2890
|
-
const
|
|
2891
|
-
|
|
3072
|
+
const tsValues = allDrawers.map(d => d.timestamp ? new Date(d.timestamp).getTime() : 0).filter(Boolean);
|
|
3073
|
+
const oldestTs = tsValues.length ? Math.min(...tsValues) : 0;
|
|
3074
|
+
const newestTs = tsValues.length ? Math.max(...tsValues) : 0;
|
|
3075
|
+
const tsRange = newestTs - oldestTs || 1;
|
|
3076
|
+
const items = allDrawers.map((d, i) => {
|
|
3077
|
+
const ts = d.timestamp ? new Date(d.timestamp).getTime() : 0;
|
|
3078
|
+
const agePct = ts && oldestTs ? Math.round(((ts - oldestTs) / tsRange) * 100) : 0;
|
|
3079
|
+
const ageBar = ts ? `<div class="dr-age-bar"><div class="dr-age-bar-fill" style="width:${agePct}%"></div></div>` : '';
|
|
3080
|
+
return `<div class="drawer-item" data-idx="${i}" data-ns="${esc(d.namespace || 'default')}">
|
|
2892
3081
|
<div class="dr-key">${esc(d.key || d.namespace || '—')}</div>
|
|
2893
3082
|
<div class="dr-val">${esc(String(d.value || d.text || '').slice(0, 300))}</div>
|
|
2894
3083
|
${d.timestamp ? `<div class="dr-ts">${relTime(d.timestamp)}</div>` : ''}
|
|
2895
|
-
|
|
3084
|
+
${ageBar}
|
|
3085
|
+
</div>`;
|
|
3086
|
+
}).join('');
|
|
2896
3087
|
html += `<div class="mem-section" id="drawers-section">
|
|
2897
3088
|
<div class="mem-title">Drawers (${allDrawers.length})</div>
|
|
2898
3089
|
<div id="drawers-list">${items}</div>
|
|
@@ -3357,7 +3548,6 @@ function updateCurrentActivity(events) {
|
|
|
3357
3548
|
}
|
|
3358
3549
|
|
|
3359
3550
|
// ── feature 34: prompt pattern analysis ───────────────────
|
|
3360
|
-
const STOP_WORDS = new Set(['the','a','an','and','or','but','in','on','at','to','for','of','with','by','from','is','it','this','that','be','as','i','my','me','we','you','your','can','do','did','have','had','has','was','were','are','will','would','should','could','not','no','if','so','then','what','how','when','where','which','who','all','get','make','add','new','old','use','its','into','out','up','any','just','let','set','also','more','using','used','fix','run','file','please']);
|
|
3361
3551
|
let patternsOpen = false;
|
|
3362
3552
|
function togglePatterns() {
|
|
3363
3553
|
patternsOpen = !patternsOpen;
|
|
@@ -3454,14 +3644,169 @@ function checkBudgetToast(todayCost, monthCost) {
|
|
|
3454
3644
|
}
|
|
3455
3645
|
}
|
|
3456
3646
|
|
|
3647
|
+
// ── feature 39: cost period toggle ────────────────────────
|
|
3648
|
+
let activePeriod = 'day';
|
|
3649
|
+
let heatmapDateFilter = null;
|
|
3650
|
+
|
|
3651
|
+
function setPeriod(p) {
|
|
3652
|
+
activePeriod = p;
|
|
3653
|
+
document.querySelectorAll('.period-btn').forEach(b => b.classList.toggle('active', b.dataset.period === p));
|
|
3654
|
+
buildTokenVelocity();
|
|
3655
|
+
syncURLParams();
|
|
3656
|
+
}
|
|
3657
|
+
|
|
3658
|
+
function periodFilteredSessions() {
|
|
3659
|
+
const now = Date.now();
|
|
3660
|
+
const DAY = 86400000;
|
|
3661
|
+
const windows = { day: DAY, week: 7 * DAY, month: 30 * DAY, all: Infinity };
|
|
3662
|
+
const w = windows[activePeriod] || DAY;
|
|
3663
|
+
if (w === Infinity) return allSessions;
|
|
3664
|
+
return allSessions.filter(s => {
|
|
3665
|
+
const t = s.firstTs || s.mtime; if (!t) return false;
|
|
3666
|
+
const ts = typeof t === 'number' ? t : new Date(t).getTime();
|
|
3667
|
+
return (now - ts) <= w;
|
|
3668
|
+
});
|
|
3669
|
+
}
|
|
3670
|
+
|
|
3671
|
+
// ── feature 40: session heatmap ────────────────────────────
|
|
3672
|
+
function buildSessionHeatmap(sessions) {
|
|
3673
|
+
const el = document.getElementById('shm-grid');
|
|
3674
|
+
const wrap = document.getElementById('sess-heatmap');
|
|
3675
|
+
if (!el || !wrap || !sessions.length) return;
|
|
3676
|
+
wrap.style.display = 'block';
|
|
3677
|
+
const DAY = 86400000;
|
|
3678
|
+
const now = Date.now();
|
|
3679
|
+
const WEEKS = 12; const DAYS = WEEKS * 7;
|
|
3680
|
+
const buckets = new Array(DAYS).fill(null).map(() => ({ count:0, date:null }));
|
|
3681
|
+
for (let i = 0; i < DAYS; i++) {
|
|
3682
|
+
buckets[i].date = new Date(now - (DAYS - 1 - i) * DAY).toDateString();
|
|
3683
|
+
}
|
|
3684
|
+
for (const s of sessions) {
|
|
3685
|
+
const ts = s.lastTs || s.mtime; if (!ts) continue;
|
|
3686
|
+
const age = now - (typeof ts === 'number' ? ts : new Date(ts).getTime());
|
|
3687
|
+
const idx = DAYS - 1 - Math.floor(age / DAY);
|
|
3688
|
+
if (idx >= 0 && idx < DAYS) buckets[idx].count++;
|
|
3689
|
+
}
|
|
3690
|
+
const max = Math.max(...buckets.map(b => b.count), 1);
|
|
3691
|
+
el.innerHTML = buckets.map(b => {
|
|
3692
|
+
const level = b.count === 0 ? 0 : Math.min(4, Math.ceil(b.count / max * 4));
|
|
3693
|
+
const isActive = b.date === heatmapDateFilter;
|
|
3694
|
+
return `<div class="shm-cell shm-${level}${isActive ? ' shm-active' : ''}" title="${b.date}: ${b.count} session${b.count !== 1 ? 's' : ''}" onclick="setHeatmapFilter('${b.date}',${b.count})"></div>`;
|
|
3695
|
+
}).join('');
|
|
3696
|
+
}
|
|
3697
|
+
|
|
3698
|
+
function setHeatmapFilter(dateStr, count) {
|
|
3699
|
+
if (!count) return;
|
|
3700
|
+
heatmapDateFilter = heatmapDateFilter === dateStr ? null : dateStr;
|
|
3701
|
+
const clearBtn = document.getElementById('shm-clear');
|
|
3702
|
+
if (clearBtn) clearBtn.classList.toggle('show', !!heatmapDateFilter);
|
|
3703
|
+
viewRendered['sessions'] = false;
|
|
3704
|
+
renderSessions();
|
|
3705
|
+
syncURLParams();
|
|
3706
|
+
}
|
|
3707
|
+
|
|
3708
|
+
function clearHeatmapFilter() {
|
|
3709
|
+
heatmapDateFilter = null;
|
|
3710
|
+
const clearBtn = document.getElementById('shm-clear');
|
|
3711
|
+
if (clearBtn) clearBtn.classList.remove('show');
|
|
3712
|
+
viewRendered['sessions'] = false;
|
|
3713
|
+
renderSessions();
|
|
3714
|
+
}
|
|
3715
|
+
|
|
3716
|
+
function toggleSessGroup(id) {
|
|
3717
|
+
const body = document.getElementById(id);
|
|
3718
|
+
if (!body) return;
|
|
3719
|
+
body.classList.toggle('collapsed');
|
|
3720
|
+
const hdr = body.previousElementSibling;
|
|
3721
|
+
if (hdr) { const tog = hdr.querySelector('.sg-toggle'); if (tog) tog.textContent = body.classList.contains('collapsed') ? '▸' : '▾'; }
|
|
3722
|
+
}
|
|
3723
|
+
|
|
3724
|
+
// ── feature 41: bulk session actions ───────────────────────
|
|
3725
|
+
let bulkSelected = new Set();
|
|
3726
|
+
let lastClickedSessIdx = null;
|
|
3727
|
+
|
|
3728
|
+
function handleSessRowClick(evt, row, sessId) {
|
|
3729
|
+
if (diffMode) { diffSelectSession(JSON.parse(row.dataset.sessData || '{}'), row); return; }
|
|
3730
|
+
if (evt.shiftKey && lastClickedSessIdx !== null) {
|
|
3731
|
+
// range-select
|
|
3732
|
+
const rows = [...document.querySelectorAll('#sess-content .sess-row')];
|
|
3733
|
+
const curIdx = rows.indexOf(row);
|
|
3734
|
+
const lo = Math.min(lastClickedSessIdx, curIdx);
|
|
3735
|
+
const hi = Math.max(lastClickedSessIdx, curIdx);
|
|
3736
|
+
for (let i = lo; i <= hi; i++) {
|
|
3737
|
+
const r = rows[i]; if (!r) continue;
|
|
3738
|
+
const id = r.dataset.sessId;
|
|
3739
|
+
if (id) { bulkSelected.add(id); r.classList.add('bulk-sel'); }
|
|
3740
|
+
}
|
|
3741
|
+
updateBulkToolbar();
|
|
3742
|
+
return;
|
|
3743
|
+
}
|
|
3744
|
+
if (bulkSelected.size > 0) {
|
|
3745
|
+
// toggle this row in bulk selection
|
|
3746
|
+
if (bulkSelected.has(sessId)) { bulkSelected.delete(sessId); row.classList.remove('bulk-sel'); }
|
|
3747
|
+
else { bulkSelected.add(sessId); row.classList.add('bulk-sel'); }
|
|
3748
|
+
const rows = [...document.querySelectorAll('#sess-content .sess-row')];
|
|
3749
|
+
lastClickedSessIdx = rows.indexOf(row);
|
|
3750
|
+
updateBulkToolbar();
|
|
3751
|
+
return;
|
|
3752
|
+
}
|
|
3753
|
+
lastClickedSessIdx = [...document.querySelectorAll('#sess-content .sess-row')].indexOf(row);
|
|
3754
|
+
jumpToSession(sessId);
|
|
3755
|
+
}
|
|
3756
|
+
|
|
3757
|
+
function updateBulkToolbar() {
|
|
3758
|
+
const tb = document.getElementById('bulk-toolbar');
|
|
3759
|
+
const cnt = document.getElementById('bulk-count');
|
|
3760
|
+
if (!tb) return;
|
|
3761
|
+
tb.classList.toggle('show', bulkSelected.size > 0);
|
|
3762
|
+
if (cnt) cnt.textContent = bulkSelected.size + ' selected';
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
function clearBulkSelection() {
|
|
3766
|
+
bulkSelected.clear();
|
|
3767
|
+
document.querySelectorAll('#sess-content .sess-row.bulk-sel').forEach(r => r.classList.remove('bulk-sel'));
|
|
3768
|
+
updateBulkToolbar();
|
|
3769
|
+
}
|
|
3770
|
+
|
|
3771
|
+
function bulkBookmark() {
|
|
3772
|
+
for (const id of bulkSelected) bookmarks.add(id);
|
|
3773
|
+
localStorage.setItem('mm-bookmarks', JSON.stringify([...bookmarks]));
|
|
3774
|
+
clearBulkSelection();
|
|
3775
|
+
showToast('Bookmarked', `${bulkSelected.size || 'Selected'} sessions bookmarked`, 'ok');
|
|
3776
|
+
viewRendered['sessions'] = false;
|
|
3777
|
+
renderSessions();
|
|
3778
|
+
}
|
|
3779
|
+
|
|
3780
|
+
function bulkExport() {
|
|
3781
|
+
const toExport = allSessions.filter(s => bulkSelected.has(s.id));
|
|
3782
|
+
if (!toExport.length) return;
|
|
3783
|
+
const headers = ['Date', 'Session ID', 'Prompt', 'Cost ($)', 'Duration (s)', 'Tool Calls', 'Files Touched'];
|
|
3784
|
+
const rows = toExport.map(s => {
|
|
3785
|
+
const dt = new Date(s.firstTs || s.mtime || 0).toISOString().slice(0, 19).replace('T', ' ');
|
|
3786
|
+
const cost = typeof s.totalCost === 'number' ? s.totalCost.toFixed(4) : '';
|
|
3787
|
+
const dur = s.totalDurationMs ? Math.round(s.totalDurationMs / 1000) : '';
|
|
3788
|
+
const prompt = (s.lastPrompt || '').replace(/"/g, '""');
|
|
3789
|
+
const files = (s.filesTouched || []).join(';');
|
|
3790
|
+
return [dt, s.id, prompt, cost, dur, s.toolCalls || '', files];
|
|
3791
|
+
});
|
|
3792
|
+
const csv = [headers, ...rows].map(r => r.map(c => `"${c}"`).join(',')).join('\n');
|
|
3793
|
+
const blob = new Blob([csv], { type: 'text/csv' });
|
|
3794
|
+
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
|
3795
|
+
a.download = `sessions-bulk-${new Date().toISOString().slice(0,10)}.csv`; a.click();
|
|
3796
|
+
URL.revokeObjectURL(a.href);
|
|
3797
|
+
showToast('Exported', `${toExport.length} sessions saved`, 'ok');
|
|
3798
|
+
clearBulkSelection();
|
|
3799
|
+
}
|
|
3800
|
+
|
|
3457
3801
|
// ── feature 26: token velocity sparkline ──────────────────
|
|
3458
3802
|
function buildTokenVelocity() {
|
|
3459
3803
|
const el = document.getElementById('m-velocity');
|
|
3460
3804
|
if (!el || !allSessions.length) return;
|
|
3461
3805
|
const now = Date.now();
|
|
3462
3806
|
const HOUR = 3600000;
|
|
3807
|
+
const filtered = periodFilteredSessions();
|
|
3463
3808
|
const buckets = new Array(24).fill(0);
|
|
3464
|
-
for (const s of
|
|
3809
|
+
for (const s of filtered) {
|
|
3465
3810
|
const t = s.firstTs || s.mtime;
|
|
3466
3811
|
if (!t) continue;
|
|
3467
3812
|
const ts = typeof t === 'number' ? t : new Date(t).getTime();
|
|
@@ -3479,10 +3824,11 @@ function buildTokenVelocity() {
|
|
|
3479
3824
|
const hrsAgo = 23 - i;
|
|
3480
3825
|
return `<div class="vel-bar ${cls}" style="height:${h}px" title="${fmt(v)} tokens — ${hrsAgo}h ago"></div>`;
|
|
3481
3826
|
}).join('');
|
|
3482
|
-
const totalCost =
|
|
3483
|
-
|
|
3827
|
+
const totalCost = filtered.reduce((a, s) => a + (s.totalCost || 0), 0);
|
|
3828
|
+
const periodLabel = { day:'24h', week:'7d', month:'30d', all:'all time' }[activePeriod] || '24h';
|
|
3829
|
+
el.innerHTML = `<div class="m-group-title">Token Velocity <span style="font-size:10px;color:var(--text-xs);font-weight:400">${periodLabel} · ${fmt(totalTok)}</span></div>
|
|
3484
3830
|
<div id="vel-chart">${bars}</div>
|
|
3485
|
-
<div style="font-size:10px;color:var(--text-xs);margin-top:4px">
|
|
3831
|
+
<div style="font-size:10px;color:var(--text-xs);margin-top:4px">Cost <span style="color:oklch(78% 0.18 75)">$${totalCost.toFixed(2)}</span> <span style="color:var(--text-xs)">· ${filtered.length} sessions</span></div>`;
|
|
3486
3832
|
}
|
|
3487
3833
|
|
|
3488
3834
|
// ── feature 27: export sessions CSV ───────────────────────
|
|
@@ -3584,6 +3930,254 @@ async function loadProjCosts() {
|
|
|
3584
3930
|
}
|
|
3585
3931
|
}
|
|
3586
3932
|
|
|
3933
|
+
// ── feature 47: 30-day daily cost trend ───────────────────
|
|
3934
|
+
function buildDailyCostTrend() {
|
|
3935
|
+
const el = document.getElementById('m-daily-trend');
|
|
3936
|
+
if (!el || !allSessions.length) return;
|
|
3937
|
+
const now = Date.now(); const DAY = 86400000;
|
|
3938
|
+
const buckets = new Array(30).fill(0);
|
|
3939
|
+
const dates = new Array(30).fill(null).map((_, i) => new Date(now - (29 - i) * DAY).toDateString());
|
|
3940
|
+
for (const s of allSessions) {
|
|
3941
|
+
const t = s.firstTs || s.mtime; if (!t) continue;
|
|
3942
|
+
const ts = typeof t === 'number' ? t : new Date(t).getTime();
|
|
3943
|
+
const daysAgo = Math.floor((now - ts) / DAY);
|
|
3944
|
+
const idx = 29 - daysAgo;
|
|
3945
|
+
if (idx >= 0 && idx < 30) buckets[idx] += s.totalCost || 0;
|
|
3946
|
+
}
|
|
3947
|
+
const maxCost = Math.max(0.001, ...buckets);
|
|
3948
|
+
const totalCost = buckets.reduce((a, b) => a + b, 0);
|
|
3949
|
+
const bars = buckets.map((v, i) => {
|
|
3950
|
+
const h = Math.max(2, Math.round((v / maxCost) * 38));
|
|
3951
|
+
const isActive = dates[i] === heatmapDateFilter;
|
|
3952
|
+
const label = i === 29 ? 'Today' : (i === 28 ? 'Yday' : '');
|
|
3953
|
+
return `<div class="dt-bar${isActive ? ' active' : ''}" style="height:${h}px" title="${dates[i]}: $${v.toFixed(3)}" onclick="setDailyTrendFilter('${dates[i]}',${v})"></div>`;
|
|
3954
|
+
}).join('');
|
|
3955
|
+
el.innerHTML = `<div class="m-group-title">30-Day Cost <span style="font-size:10px;color:var(--text-xs);font-weight:400">$${totalCost.toFixed(2)} total</span></div>
|
|
3956
|
+
<div id="daily-trend-chart">${bars}</div>
|
|
3957
|
+
<div style="display:flex;justify-content:space-between;font-size:9px;color:var(--text-xs);margin-top:2px"><span>30d ago</span><span>Today</span></div>`;
|
|
3958
|
+
}
|
|
3959
|
+
|
|
3960
|
+
function setDailyTrendFilter(dateStr, cost) {
|
|
3961
|
+
if (!cost) return;
|
|
3962
|
+
heatmapDateFilter = heatmapDateFilter === dateStr ? null : dateStr;
|
|
3963
|
+
const clearBtn = document.getElementById('shm-clear');
|
|
3964
|
+
if (clearBtn) clearBtn.classList.toggle('show', !!heatmapDateFilter);
|
|
3965
|
+
viewRendered['sessions'] = false;
|
|
3966
|
+
renderSessions();
|
|
3967
|
+
syncURLParams();
|
|
3968
|
+
}
|
|
3969
|
+
|
|
3970
|
+
// ── feature 48: live cost ticker ──────────────────────────
|
|
3971
|
+
let _liveTickerCost = 0;
|
|
3972
|
+
let _liveTickerPrev = 0;
|
|
3973
|
+
|
|
3974
|
+
function updateLiveTicker(cost) {
|
|
3975
|
+
const el = document.getElementById('live-cost-ticker');
|
|
3976
|
+
if (!el) return;
|
|
3977
|
+
if (cost == null || cost === 0) { el.classList.remove('show'); return; }
|
|
3978
|
+
_liveTickerPrev = _liveTickerCost;
|
|
3979
|
+
_liveTickerCost = cost;
|
|
3980
|
+
const delta = _liveTickerCost - _liveTickerPrev;
|
|
3981
|
+
const deltaHtml = delta > 0.0001 ? `<span class="lct-change">+$${delta.toFixed(4)}</span>` : '';
|
|
3982
|
+
el.innerHTML = `$${cost.toFixed(3)}${deltaHtml}`;
|
|
3983
|
+
el.classList.add('show');
|
|
3984
|
+
}
|
|
3985
|
+
|
|
3986
|
+
// ── feature 49: hourly productivity heatmap ───────────────
|
|
3987
|
+
function buildHourlyHeatmap() {
|
|
3988
|
+
const el = document.getElementById('m-hourly-heatmap');
|
|
3989
|
+
if (!el || !allSessions.length) return;
|
|
3990
|
+
// 24 hours × 7 days-of-week matrix
|
|
3991
|
+
const grid = Array.from({ length: 7 }, () => new Array(24).fill(0));
|
|
3992
|
+
for (const s of allSessions) {
|
|
3993
|
+
const t = s.firstTs || s.mtime; if (!t) continue;
|
|
3994
|
+
const d = new Date(typeof t === 'number' ? t : t);
|
|
3995
|
+
const dow = d.getDay(); // 0=Sun
|
|
3996
|
+
const hour = d.getHours();
|
|
3997
|
+
grid[dow][hour]++;
|
|
3998
|
+
}
|
|
3999
|
+
const maxVal = Math.max(1, ...grid.flatMap(r => r));
|
|
4000
|
+
const dayNames = ['Su','Mo','Tu','We','Th','Fr','Sa'];
|
|
4001
|
+
let html = '<div id="hourly-heatmap-grid">';
|
|
4002
|
+
// Header row: empty corner + hour labels (0,6,12,18,23)
|
|
4003
|
+
html += '<div></div>';
|
|
4004
|
+
for (let h = 0; h < 24; h++) {
|
|
4005
|
+
const lbl = h % 6 === 0 ? String(h) : '';
|
|
4006
|
+
html += `<div class="hh-hour-lbl">${lbl}</div>`;
|
|
4007
|
+
}
|
|
4008
|
+
// Data rows
|
|
4009
|
+
for (let d = 0; d < 7; d++) {
|
|
4010
|
+
html += `<div class="hh-day-lbl">${dayNames[d]}</div>`;
|
|
4011
|
+
for (let h = 0; h < 24; h++) {
|
|
4012
|
+
const v = grid[d][h];
|
|
4013
|
+
const level = v === 0 ? 0 : Math.min(4, Math.ceil(v / maxVal * 4));
|
|
4014
|
+
html += `<div class="hh-cell hh-${level}" title="${dayNames[d]} ${h}:00 — ${v} session${v !== 1 ? 's' : ''}"></div>`;
|
|
4015
|
+
}
|
|
4016
|
+
}
|
|
4017
|
+
html += '</div>';
|
|
4018
|
+
const peakHour = grid.flatMap((r, d) => r.map((v, h) => ({ d, h, v }))).sort((a, b) => b.v - a.v)[0];
|
|
4019
|
+
const peakLabel = peakHour ? `${dayNames[peakHour.d]} ${peakHour.h}:00` : '';
|
|
4020
|
+
el.innerHTML = `<div class="m-group-title">Peak Work Hours${peakLabel ? `<span style="font-size:10px;color:var(--text-xs);font-weight:400;margin-left:4px">peak: ${peakLabel}</span>` : ''}</div>${html}`;
|
|
4021
|
+
}
|
|
4022
|
+
|
|
4023
|
+
// ── feature 50: custom tag editor ─────────────────────────
|
|
4024
|
+
const _customTagsKey = 'mm-custom-tags';
|
|
4025
|
+
let _customTagsMap = new Map(Object.entries(JSON.parse(localStorage.getItem(_customTagsKey) || '{}')));
|
|
4026
|
+
|
|
4027
|
+
function getCustomTags(sessId) {
|
|
4028
|
+
return _customTagsMap.get(sessId) || [];
|
|
4029
|
+
}
|
|
4030
|
+
|
|
4031
|
+
function saveCustomTags(sessId, tags) {
|
|
4032
|
+
if (tags.length === 0) _customTagsMap.delete(sessId);
|
|
4033
|
+
else _customTagsMap.set(sessId, tags);
|
|
4034
|
+
localStorage.setItem(_customTagsKey, JSON.stringify(Object.fromEntries(_customTagsMap)));
|
|
4035
|
+
}
|
|
4036
|
+
|
|
4037
|
+
function addCustomTag(sessId, tag, event) {
|
|
4038
|
+
if (event) event.stopPropagation();
|
|
4039
|
+
const t = tag.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
|
4040
|
+
if (!t) return;
|
|
4041
|
+
const tags = getCustomTags(sessId);
|
|
4042
|
+
if (!tags.includes(t)) { tags.push(t); saveCustomTags(sessId, tags); }
|
|
4043
|
+
const wrap = document.querySelector(`.sr-custom-tags[data-sess="${CSS.escape(sessId)}"]`);
|
|
4044
|
+
if (wrap) wrap.outerHTML = renderCustomTagsInline(sessId, tags);
|
|
4045
|
+
// rebuild tag filter if this tag is new globally
|
|
4046
|
+
initTags(); buildTagFilterBar(allSessions);
|
|
4047
|
+
}
|
|
4048
|
+
|
|
4049
|
+
function removeCustomTag(sessId, tag, event) {
|
|
4050
|
+
if (event) event.stopPropagation();
|
|
4051
|
+
const tags = getCustomTags(sessId).filter(t => t !== tag);
|
|
4052
|
+
saveCustomTags(sessId, tags);
|
|
4053
|
+
const wrap = document.querySelector(`.sr-custom-tags[data-sess="${CSS.escape(sessId)}"]`);
|
|
4054
|
+
if (wrap) wrap.outerHTML = renderCustomTagsInline(sessId, tags);
|
|
4055
|
+
}
|
|
4056
|
+
|
|
4057
|
+
function showCustomTagInput(sessId, event) {
|
|
4058
|
+
if (event) event.stopPropagation();
|
|
4059
|
+
const wrap = document.querySelector(`.sr-custom-tags[data-sess="${CSS.escape(sessId)}"]`);
|
|
4060
|
+
if (!wrap) return;
|
|
4061
|
+
const existing = wrap.querySelector('.ctag-input-wrap');
|
|
4062
|
+
if (existing) { existing.remove(); return; }
|
|
4063
|
+
const iw = document.createElement('div');
|
|
4064
|
+
iw.className = 'ctag-input-wrap';
|
|
4065
|
+
iw.onclick = e => e.stopPropagation();
|
|
4066
|
+
iw.innerHTML = `<input class="ctag-input" type="text" placeholder="tag name…" maxlength="20" autofocus>
|
|
4067
|
+
<button class="ctag-ok" onclick="(()=>{const inp=this.previousSibling;addCustomTag('${esc(sessId)}',inp.value,event);inp.value='';})()">Add</button>`;
|
|
4068
|
+
wrap.appendChild(iw);
|
|
4069
|
+
const inp = iw.querySelector('input');
|
|
4070
|
+
inp.focus();
|
|
4071
|
+
inp.addEventListener('keydown', e => {
|
|
4072
|
+
if (e.key === 'Enter') { addCustomTag(sessId, inp.value, e); inp.value = ''; }
|
|
4073
|
+
if (e.key === 'Escape') iw.remove();
|
|
4074
|
+
});
|
|
4075
|
+
}
|
|
4076
|
+
|
|
4077
|
+
function renderCustomTagsInline(sessId, tags) {
|
|
4078
|
+
const tagHtml = tags.map(t =>
|
|
4079
|
+
`<span class="sr-ctag">${esc(t)}<span class="ctag-del" onclick="removeCustomTag('${esc(sessId)}','${esc(t)}',event)" title="Remove tag">✕</span></span>`
|
|
4080
|
+
).join('');
|
|
4081
|
+
return `<div class="sr-custom-tags" data-sess="${esc(sessId)}" onclick="event.stopPropagation()">
|
|
4082
|
+
${tagHtml}
|
|
4083
|
+
<button class="ctag-add-btn" onclick="showCustomTagInput('${esc(sessId)}',event)" title="Add tag">+ tag</button>
|
|
4084
|
+
</div>`;
|
|
4085
|
+
}
|
|
4086
|
+
|
|
4087
|
+
// ── feature 51: tool error drawer ─────────────────────────
|
|
4088
|
+
async function toggleErrDrawer(sessId, event) {
|
|
4089
|
+
if (event) event.stopPropagation();
|
|
4090
|
+
const drawer = document.getElementById('err-drawer-' + sessId);
|
|
4091
|
+
if (!drawer) return;
|
|
4092
|
+
if (drawer.classList.contains('open')) { drawer.classList.remove('open'); drawer.innerHTML = ''; return; }
|
|
4093
|
+
drawer.classList.add('open');
|
|
4094
|
+
drawer.innerHTML = '<div class="err-drawer-head">Loading errors…</div>';
|
|
4095
|
+
try {
|
|
4096
|
+
const data = await apiFetch('/api/session-errors?dir=' + enc(DIR) + '&id=' + enc(sessId));
|
|
4097
|
+
if (!data.errors?.length) {
|
|
4098
|
+
drawer.innerHTML = `<div class="err-drawer-head"><span>No error details found</span><button class="err-close" onclick="toggleErrDrawer('${esc(sessId)}',event)">✕</button></div>`;
|
|
4099
|
+
return;
|
|
4100
|
+
}
|
|
4101
|
+
const items = data.errors.map(e => `<div class="err-item">${esc(e.text)}</div>`).join('');
|
|
4102
|
+
drawer.innerHTML = `<div class="err-drawer-head"><span>${data.errors.length} error${data.errors.length !== 1 ? 's' : ''}</span><button class="err-close" onclick="toggleErrDrawer('${esc(sessId)}',event)">✕</button></div>
|
|
4103
|
+
<div class="err-drawer-body">${items}</div>`;
|
|
4104
|
+
} catch (err) {
|
|
4105
|
+
drawer.innerHTML = `<div class="err-drawer-head"><span>Could not load: ${esc(err.message)}</span><button class="err-close" onclick="toggleErrDrawer('${esc(sessId)}',event)">✕</button></div>`;
|
|
4106
|
+
}
|
|
4107
|
+
}
|
|
4108
|
+
|
|
4109
|
+
// ── feature 52: prompt copy button ────────────────────────
|
|
4110
|
+
function copyPrompt(text, event) {
|
|
4111
|
+
if (event) event.stopPropagation();
|
|
4112
|
+
const btn = event?.currentTarget || event?.target;
|
|
4113
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
4114
|
+
if (btn) { btn.textContent = '✓'; btn.classList.add('copied'); setTimeout(() => { btn.textContent = '⎘'; btn.classList.remove('copied'); }, 1500); }
|
|
4115
|
+
}).catch(() => {});
|
|
4116
|
+
}
|
|
4117
|
+
|
|
4118
|
+
// ── feature 53: session cost histogram ────────────────────
|
|
4119
|
+
function buildCostHistogram() {
|
|
4120
|
+
const el = document.getElementById('cost-histogram-panel');
|
|
4121
|
+
if (!el) return;
|
|
4122
|
+
const costs = allSessions.map(s => s.totalCost || 0).filter(c => c > 0);
|
|
4123
|
+
if (costs.length < 2) { el.style.display = 'none'; return; }
|
|
4124
|
+
el.style.display = 'block';
|
|
4125
|
+
const minC = Math.min(...costs); const maxC = Math.max(...costs);
|
|
4126
|
+
const BUCKETS = 10;
|
|
4127
|
+
const range = maxC - minC || 0.01;
|
|
4128
|
+
const bucketSize = range / BUCKETS;
|
|
4129
|
+
const counts = new Array(BUCKETS).fill(0);
|
|
4130
|
+
for (const c of costs) { const i = Math.min(BUCKETS - 1, Math.floor((c - minC) / bucketSize)); counts[i]++; }
|
|
4131
|
+
const maxCount = Math.max(1, ...counts);
|
|
4132
|
+
const fmt = v => v < 0.01 ? v.toFixed(4) : v < 1 ? '$' + v.toFixed(2) : '$' + v.toFixed(1);
|
|
4133
|
+
const bars = counts.map((n, i) => {
|
|
4134
|
+
const h = Math.max(2, Math.round((n / maxCount) * 46));
|
|
4135
|
+
const lo = minC + i * bucketSize; const hi = lo + bucketSize;
|
|
4136
|
+
return `<div class="ch-bar-wrap" title="${fmt(lo)}–${fmt(hi)}: ${n} session${n !== 1 ? 's' : ''}">
|
|
4137
|
+
<div class="ch-cnt">${n || ''}</div>
|
|
4138
|
+
<div class="ch-bar" style="height:${h}px"></div>
|
|
4139
|
+
<div class="ch-lbl">${i === 0 ? fmt(lo) : i === BUCKETS - 1 ? fmt(hi) : ''}</div>
|
|
4140
|
+
</div>`;
|
|
4141
|
+
}).join('');
|
|
4142
|
+
el.innerHTML = `<div class="ch-title">Cost Distribution — ${costs.length} sessions</div>
|
|
4143
|
+
<div class="ch-bars">${bars}</div>`;
|
|
4144
|
+
}
|
|
4145
|
+
|
|
4146
|
+
// ── feature 54: persistent filter URL params ──────────────
|
|
4147
|
+
function syncURLParams() {
|
|
4148
|
+
const p = new URLSearchParams(window.location.search);
|
|
4149
|
+
if (DIR) p.set('proj', DIR); else p.delete('proj');
|
|
4150
|
+
if (activePeriod && activePeriod !== 'day') p.set('period', activePeriod); else p.delete('period');
|
|
4151
|
+
if (activeTagFilter) p.set('tag', activeTagFilter); else p.delete('tag');
|
|
4152
|
+
if (heatmapDateFilter) p.set('date', heatmapDateFilter); else p.delete('date');
|
|
4153
|
+
if (showStarredOnly) p.set('starred', '1'); else p.delete('starred');
|
|
4154
|
+
const newUrl = window.location.pathname + (p.toString() ? '?' + p.toString() : '');
|
|
4155
|
+
history.replaceState(null, '', newUrl);
|
|
4156
|
+
}
|
|
4157
|
+
|
|
4158
|
+
function restoreURLParams() {
|
|
4159
|
+
const p = new URLSearchParams(window.location.search);
|
|
4160
|
+
const period = p.get('period');
|
|
4161
|
+
if (period && ['day','week','month','all'].includes(period)) {
|
|
4162
|
+
activePeriod = period;
|
|
4163
|
+
document.querySelectorAll('.period-btn').forEach(b => b.classList.toggle('active', b.dataset.period === period));
|
|
4164
|
+
}
|
|
4165
|
+
const tag = p.get('tag');
|
|
4166
|
+
if (tag) activeTagFilter = tag;
|
|
4167
|
+
const date = p.get('date');
|
|
4168
|
+
if (date) {
|
|
4169
|
+
heatmapDateFilter = date;
|
|
4170
|
+
const clearBtn = document.getElementById('shm-clear');
|
|
4171
|
+
if (clearBtn) clearBtn.classList.add('show');
|
|
4172
|
+
}
|
|
4173
|
+
const starred = p.get('starred');
|
|
4174
|
+
if (starred === '1') {
|
|
4175
|
+
showStarredOnly = true;
|
|
4176
|
+
const btn = document.getElementById('sess-star-filter');
|
|
4177
|
+
if (btn) btn.classList.add('on');
|
|
4178
|
+
}
|
|
4179
|
+
}
|
|
4180
|
+
|
|
3587
4181
|
// ── helpers ────────────────────────────────────────────────
|
|
3588
4182
|
function enc(s) { return encodeURIComponent(s); }
|
|
3589
4183
|
function esc(s) {
|