agentacta 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -3,25 +3,68 @@ const $$ = (s, p = document) => [...p.querySelectorAll(s)];
3
3
  const content = $('#content');
4
4
  const API = '/api';
5
5
 
6
- const THEME_KEY = 'agentacta-theme';
6
+ const THEME_KEY = 'agentacta-theme'; // legacy
7
+ const THEME_MODE_KEY = 'agentacta-theme-mode'; // system | light | dark
8
+ const THEME_DARK_VARIANT_KEY = 'agentacta-dark-variant'; // default | trueblack
9
+
10
+ function lsGet(key) {
11
+ try { return localStorage.getItem(key); } catch { return null; }
12
+ }
13
+
14
+ function lsSet(key, value) {
15
+ try { localStorage.setItem(key, value); } catch {}
16
+ }
17
+
18
+ function resolveTheme(mode, darkVariant) {
19
+ if (mode === 'light') return 'light';
20
+ if (mode === 'dark') return darkVariant === 'trueblack' ? 'oled' : 'dark';
21
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
22
+ return prefersDark ? (darkVariant === 'trueblack' ? 'oled' : 'dark') : 'light';
23
+ }
7
24
 
8
25
  function applyTheme(theme) {
9
26
  document.documentElement.setAttribute('data-theme', theme);
10
27
  const meta = document.querySelector('meta[name="theme-color"]');
11
- if (meta) meta.setAttribute('content', theme === 'light' ? '#f5f7fb' : '#0a0e1a');
28
+ if (meta) {
29
+ const color = theme === 'light' ? '#f5f7fb' : (theme === 'oled' ? '#000000' : '#0a0e1a');
30
+ meta.setAttribute('content', color);
31
+ }
32
+ }
33
+
34
+ function applyThemeFromPrefs() {
35
+ const mode = lsGet(THEME_MODE_KEY) || 'light';
36
+ const darkVariant = lsGet(THEME_DARK_VARIANT_KEY) || 'default';
37
+ window._themeMode = mode;
38
+ window._themeDarkVariant = darkVariant;
39
+ applyTheme(resolveTheme(mode, darkVariant));
12
40
  }
13
41
 
14
42
  function initTheme() {
15
- const saved = localStorage.getItem(THEME_KEY);
16
- const theme = saved === 'dark' || saved === 'light' ? saved : 'light';
17
- applyTheme(theme);
43
+ // Migrate legacy key if present.
44
+ const legacy = lsGet(THEME_KEY);
45
+ if (!lsGet(THEME_MODE_KEY) && (legacy === 'light' || legacy === 'dark')) {
46
+ lsSet(THEME_MODE_KEY, legacy);
47
+ }
48
+ if (!lsGet(THEME_DARK_VARIANT_KEY)) {
49
+ lsSet(THEME_DARK_VARIANT_KEY, 'default');
50
+ }
51
+ applyThemeFromPrefs();
52
+
53
+ if (!window._themeMediaBound) {
54
+ const media = window.matchMedia('(prefers-color-scheme: dark)');
55
+ media.addEventListener?.('change', () => {
56
+ if ((lsGet(THEME_MODE_KEY) || 'light') === 'system') applyThemeFromPrefs();
57
+ });
58
+ window._themeMediaBound = true;
59
+ }
18
60
  }
19
61
 
20
62
  function toggleTheme() {
21
- const current = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
22
- const next = current === 'light' ? 'dark' : 'light';
23
- localStorage.setItem(THEME_KEY, next);
24
- applyTheme(next);
63
+ const currentApplied = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
64
+ const nextMode = currentApplied === 'light' ? 'dark' : 'light';
65
+ lsSet(THEME_MODE_KEY, nextMode);
66
+ window._themeMode = nextMode;
67
+ applyThemeFromPrefs();
25
68
  }
26
69
 
27
70
  async function api(path, options = {}) {
@@ -199,7 +242,8 @@ function updateNavActive(view) {
199
242
  }
200
243
 
201
244
  function handleRoute() {
202
- const raw = (window.location.hash || '').slice(1) || 'search';
245
+ clearJumpUi();
246
+ const raw = (window.location.hash || '').slice(1) || 'overview';
203
247
  if (window._sseCleanup) { window._sseCleanup(); window._sseCleanup = null; }
204
248
  if (window._timelineScrollHandler) { window.removeEventListener('scroll', window._timelineScrollHandler); window._timelineScrollHandler = null; }
205
249
 
@@ -208,14 +252,15 @@ function handleRoute() {
208
252
  if (id) { viewSession(id); return; }
209
253
  }
210
254
 
211
- const view = raw === 'sessions' || raw === 'timeline' || raw === 'files' || raw === 'stats' ? raw : 'search';
255
+ const normalized = raw === 'search' ? 'overview' : raw;
256
+ const view = normalized === 'overview' || normalized === 'sessions' || normalized === 'timeline' || normalized === 'files' || normalized === 'stats' ? normalized : 'overview';
212
257
  window._lastView = view;
213
258
  updateNavActive(view);
214
- if (view === 'sessions') viewSessions();
259
+ if (view === 'overview') viewOverview();
260
+ else if (view === 'sessions') viewSessions();
215
261
  else if (view === 'files') viewFiles();
216
262
  else if (view === 'timeline') viewTimeline();
217
- else if (view === 'stats') viewStats();
218
- else viewSearch(window._lastSearchQuery || '');
263
+ else viewStats();
219
264
  }
220
265
 
221
266
  window.addEventListener('popstate', () => {
@@ -310,6 +355,56 @@ function normalizeAgentLabel(a) {
310
355
  return a;
311
356
  }
312
357
 
358
+ function clearJumpUi() {
359
+ const indicator = document.getElementById('jumpIndicator');
360
+ if (indicator) indicator.remove();
361
+ const returnBtn = document.getElementById('returnJumpBtn');
362
+ if (returnBtn) returnBtn.remove();
363
+ }
364
+
365
+ function cleanSessionSummary(text, fallbackText = '') {
366
+ const pick = (input) => {
367
+ const raw = (input || '').trim();
368
+ if (!raw) return '';
369
+
370
+ const jsonFence = /```json[\s\S]*?```/gi;
371
+ const fences = [...raw.matchAll(jsonFence)];
372
+ if (fences.length >= 2) {
373
+ const second = fences[1];
374
+ const after = raw.slice(second.index + second[0].length).trim();
375
+ if (after) {
376
+ const line = after.split(/\r?\n/).map(l => l.trim()).find(Boolean);
377
+ if (line) return line;
378
+ }
379
+ }
380
+
381
+ const lines = raw.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
382
+ const skip = [
383
+ /^conversation info/i,
384
+ /^sender/i,
385
+ /^```/,
386
+ /^\{/, /^\}/,
387
+ /^"(message_id|sender_id|sender|timestamp|label|id|name)"/i,
388
+ /^untrusted metadata/i
389
+ ];
390
+
391
+ let candidate = lines.find(l => !skip.some(rx => rx.test(l)));
392
+ if (candidate) {
393
+ candidate = candidate
394
+ .replace(/^\[cron:[^\]]+\]\s*/i, '')
395
+ .replace(/^\[heartbeat:[^\]]+\]\s*/i, '')
396
+ .trim();
397
+ }
398
+ if (!candidate || skip.some(rx => rx.test(candidate))) {
399
+ if (/heartbeat session/i.test(raw)) return 'Heartbeat session';
400
+ return '';
401
+ }
402
+ return candidate;
403
+ };
404
+
405
+ return pick(text) || pick(fallbackText) || 'Session activity';
406
+ }
407
+
313
408
  function isInternalProjectTag(tag) {
314
409
  if (!tag) return true;
315
410
  if (tag.startsWith('agent:')) return true;
@@ -352,7 +447,7 @@ function renderSessionItem(s) {
352
447
  ${renderModelTags(s)}
353
448
  </span>
354
449
  </div>
355
- <div class="session-summary">${escHtml(truncate(s.summary || 'No summary', 120))}</div>
450
+ <div class="session-summary">${escHtml(truncate(cleanSessionSummary(s.summary, s.initial_prompt), 120))}</div>
356
451
  <div class="session-meta">
357
452
  <span><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> ${s.message_count}</span>
358
453
  <span><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> ${s.tool_count}</span>
@@ -364,6 +459,7 @@ function renderSessionItem(s) {
364
459
  // --- Views ---
365
460
 
366
461
  async function viewSearch(query = '') {
462
+ clearJumpUi();
367
463
  const typeFilter = window._searchType || '';
368
464
  const roleFilter = window._searchRole || '';
369
465
 
@@ -414,10 +510,12 @@ async function viewSearch(query = '') {
414
510
 
415
511
  async function showSearchHome() {
416
512
  const el = $('#results');
513
+ const reqId = (window._searchReqSeq = (window._searchReqSeq || 0) + 1);
417
514
  el.innerHTML = `${skeletonRows(4, 'session')}`;
418
515
 
419
516
  const stats = await api('/stats');
420
517
  const sessions = await api('/sessions?limit=5');
518
+ if (reqId !== window._searchReqSeq) return;
421
519
  if (stats._error || sessions._error) {
422
520
  el.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
423
521
  return;
@@ -425,6 +523,7 @@ async function showSearchHome() {
425
523
 
426
524
  let suggestions = [];
427
525
  try { const r = await fetch('/api/suggestions'); const d = await r.json(); suggestions = d.suggestions || []; } catch(e) { suggestions = []; }
526
+ if (reqId !== window._searchReqSeq) return;
428
527
 
429
528
  let html = `
430
529
  <div class="search-stats" style="margin-top:8px">
@@ -463,6 +562,7 @@ async function showSearchHome() {
463
562
 
464
563
  async function doSearch(q) {
465
564
  const el = $('#results');
565
+ const reqId = (window._searchReqSeq = (window._searchReqSeq || 0) + 1);
466
566
  if (!q.trim()) { el.innerHTML = '<div class="empty"><h2>Type to search</h2><p>Search across all sessions, messages, and tool calls</p></div>'; return; }
467
567
 
468
568
  el.innerHTML = `${skeletonRows(6, 'event')}`;
@@ -474,6 +574,7 @@ async function doSearch(q) {
474
574
  if (role) url += `&role=${role}`;
475
575
 
476
576
  const data = await api(url);
577
+ if (reqId !== window._searchReqSeq) return;
477
578
 
478
579
  if (data._error || data.error) { el.innerHTML = `<div class="empty"><p>${escHtml(data.error || 'Server error')}</p></div>`; return; }
479
580
  if (!data.results.length) { el.innerHTML = '<div class="empty"><h2>No results</h2><p>Try a different search term or adjust filters</p></div>'; return; }
@@ -508,6 +609,7 @@ async function doSearch(q) {
508
609
  }
509
610
 
510
611
  async function viewSessions() {
612
+ clearJumpUi();
511
613
  window._currentSessionId = null;
512
614
  content.innerHTML = `<div class="page-title">Sessions</div>${skeletonRows(4, 'session')}`;
513
615
  transitionView();
@@ -528,6 +630,8 @@ async function viewSessions() {
528
630
  }
529
631
 
530
632
  async function viewSession(id) {
633
+ clearJumpUi();
634
+ window._recentSessionIds = [id, ...(window._recentSessionIds || []).filter(x => x !== id)].slice(0, 5);
531
635
  if (window._sseCleanup) { window._sseCleanup(); window._sseCleanup = null; }
532
636
  window._currentSessionId = id;
533
637
  setHash('session/' + encodeURIComponent(id));
@@ -621,6 +725,7 @@ async function viewSession(id) {
621
725
  }
622
726
 
623
727
  $('#backBtn').addEventListener('click', () => {
728
+ clearJumpUi();
624
729
  if (window._navDepth > 0) {
625
730
  history.back();
626
731
  } else {
@@ -671,19 +776,65 @@ async function viewSession(id) {
671
776
 
672
777
  const jumpBtn = $('#jumpToStartBtn');
673
778
  if (jumpBtn) {
674
- jumpBtn.addEventListener('click', () => {
675
- // Load all remaining events to find the first message
779
+ jumpBtn.addEventListener('click', async () => {
780
+ const fromY = window.scrollY || window.pageYOffset || 0;
781
+
782
+ jumpBtn.classList.add('jumping');
783
+ jumpBtn.disabled = true;
784
+
785
+ // Let button state paint before heavy DOM work.
786
+ await new Promise(requestAnimationFrame);
787
+
788
+ // Load remaining events in chunks so UI stays responsive.
789
+ let loops = 0;
676
790
  while (rendered < allEvents.length) {
677
791
  renderBatch();
792
+ loops += 1;
793
+ if (loops % 2 === 0) await new Promise(requestAnimationFrame);
678
794
  }
795
+
679
796
  const firstMessage = document.querySelector(`[data-event-id="${s.first_message_id}"]`);
680
- if (firstMessage) {
681
- firstMessage.scrollIntoView({ behavior: 'smooth', block: 'center' });
797
+ if (!firstMessage) {
798
+ jumpBtn.classList.remove('jumping');
799
+ jumpBtn.disabled = false;
800
+ return;
801
+ }
802
+
803
+ const targetY = Math.max(0, firstMessage.getBoundingClientRect().top + fromY - (window.innerHeight * 0.35));
804
+ const distance = Math.abs(targetY - fromY);
805
+ const useSmooth = distance < 1800;
806
+
807
+ window.scrollTo({ top: targetY, behavior: useSmooth ? 'smooth' : 'auto' });
808
+
809
+ const doneDelay = useSmooth ? 500 : 120;
810
+ setTimeout(() => {
682
811
  firstMessage.classList.add('event-highlight');
812
+ setTimeout(() => firstMessage.classList.remove('event-highlight'), 2000);
813
+
814
+ jumpBtn.classList.remove('jumping');
815
+ jumpBtn.disabled = false;
816
+
817
+ let returnBtn = document.getElementById('returnJumpBtn');
818
+ if (!returnBtn) {
819
+ returnBtn = document.createElement('button');
820
+ returnBtn.id = 'returnJumpBtn';
821
+ returnBtn.className = 'return-jump-btn';
822
+ returnBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"></path><polyline points="5 12 12 5 19 12"></polyline></svg>`;
823
+ returnBtn.setAttribute('aria-label', 'Back to previous spot');
824
+ returnBtn.title = 'Back to previous spot';
825
+ document.body.appendChild(returnBtn);
826
+ }
827
+ returnBtn.classList.add('show');
828
+
829
+ returnBtn.onclick = () => {
830
+ window.scrollTo({ top: fromY, behavior: 'smooth' });
831
+ returnBtn.classList.remove('show');
832
+ };
833
+
683
834
  setTimeout(() => {
684
- firstMessage.classList.remove('event-highlight');
685
- }, 2000);
686
- }
835
+ if (returnBtn) returnBtn.classList.remove('show');
836
+ }, 9000);
837
+ }, doneDelay);
687
838
  });
688
839
  }
689
840
 
@@ -765,12 +916,13 @@ async function viewSession(id) {
765
916
  }
766
917
 
767
918
  async function viewTimeline(date) {
919
+ clearJumpUi();
768
920
  if (!date) {
769
921
  const now = new Date();
770
922
  date = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
771
923
  }
772
924
  window._lastView = 'timeline';
773
- window._timelineState = { date, limit: 100, offset: 0, hasMore: true, loading: false };
925
+ window._timelineState = { date, limit: 100, offset: 0, hasMore: true, loading: false, seenEventIds: new Set() };
774
926
 
775
927
  content.innerHTML = `<div class="page-title">Timeline</div>
776
928
  <input type="date" class="date-input" id="dateInput" value="${date}">
@@ -797,10 +949,11 @@ async function viewTimeline(date) {
797
949
 
798
950
  state.hasMore = !!data.hasMore;
799
951
  state.offset += (data.events || []).length;
952
+ (data.events || []).forEach(ev => state.seenEventIds.add(ev.id));
800
953
 
801
954
  if (!append) {
802
955
  if (!data.events.length) {
803
- el.innerHTML = '<div class="empty"><h2>No activity</h2><p>Nothing recorded on this day</p></div>';
956
+ el.innerHTML = `<div class="timeline-events-wrap" id="timelineWrap"><div class="timeline-line"></div><div class="empty" id="timelineEmpty"><h2>No activity</h2><p>Nothing recorded on this day</p></div></div>`;
804
957
  return;
805
958
  }
806
959
  el.innerHTML = `<div class="timeline-events-wrap" id="timelineWrap"><div class="timeline-line"></div>${data.events.map(renderTimelineEvent).join('')}</div>`;
@@ -829,21 +982,35 @@ async function viewTimeline(date) {
829
982
  const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
830
983
  if (window._timelineSse) { window._timelineSse.close(); window._timelineSse = null; }
831
984
  if (date === todayStr) {
832
- const sse = new EventSource(`/api/timeline/stream?after=${encodeURIComponent(new Date().toISOString())}`);
985
+ const sse = new EventSource(`/api/timeline/stream?after=${encodeURIComponent(new Date().toISOString())}&afterId=`);
833
986
  window._timelineSse = sse;
834
987
  sse.onmessage = (evt) => {
835
988
  try {
836
989
  const rows = JSON.parse(evt.data);
837
- const wrap = $('#timelineWrap');
838
- if (wrap && rows.length) {
839
- const html = rows.map(renderTimelineEvent).join('');
840
- wrap.insertAdjacentHTML('afterbegin', html);
841
- // Flash new events
842
- rows.forEach(r => {
843
- const el = wrap.querySelector(`[data-event-id="${r.id}"]`);
844
- if (el) { el.classList.add('event-highlight'); setTimeout(() => el.classList.remove('event-highlight'), 2000); }
845
- });
990
+ if (!rows.length) return;
991
+
992
+ const fresh = rows.filter(r => !state.seenEventIds.has(r.id));
993
+ if (!fresh.length) return;
994
+ fresh.forEach(r => state.seenEventIds.add(r.id));
995
+
996
+ let wrap = $('#timelineWrap');
997
+ if (!wrap) {
998
+ el.innerHTML = `<div class="timeline-events-wrap" id="timelineWrap"><div class="timeline-line"></div></div>`;
999
+ wrap = $('#timelineWrap');
846
1000
  }
1001
+
1002
+ const empty = $('#timelineEmpty');
1003
+ if (empty) empty.remove();
1004
+
1005
+ const html = fresh.map(renderTimelineEvent).join('');
1006
+ wrap.insertAdjacentHTML('afterbegin', html);
1007
+ state.offset += fresh.length;
1008
+
1009
+ // Flash new events
1010
+ fresh.forEach(r => {
1011
+ const rowEl = wrap.querySelector(`[data-event-id="${r.id}"]`);
1012
+ if (rowEl) { rowEl.classList.add('event-highlight'); setTimeout(() => rowEl.classList.remove('event-highlight'), 2000); }
1013
+ });
847
1014
  } catch {}
848
1015
  };
849
1016
  }
@@ -854,33 +1021,100 @@ async function viewTimeline(date) {
854
1021
  });
855
1022
  }
856
1023
 
857
- async function viewStats() {
858
- content.innerHTML = `<div class="page-title">Stats</div><div class="stat-grid">${skeletonRows(5, 'stats')}</div>`;
1024
+ async function viewOverview() {
1025
+ clearJumpUi();
1026
+ content.innerHTML = `<div class="page-title">Overview</div><div class="stat-grid">${skeletonRows(5, 'stats')}</div>`;
859
1027
  transitionView();
860
1028
  const data = await api('/stats');
861
- if (data._error) {
1029
+ const sessionsRes = await api('/sessions?limit=30');
1030
+ if (data._error || sessionsRes._error) {
862
1031
  content.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
863
1032
  return;
864
1033
  }
865
1034
 
866
- let html = `<div class="page-title">Stats</div>
1035
+ const sessions = sessionsRes.sessions || [];
1036
+ const nowMs = Date.now();
1037
+ const ACTIVE_WINDOW_MIN = 15;
1038
+ const activeNow = sessions
1039
+ .filter(s => {
1040
+ const t = s.end_time || s.start_time;
1041
+ if (!t) return false;
1042
+ const ageMin = (nowMs - new Date(t).getTime()) / 60000;
1043
+ return ageMin >= 0 && ageMin <= ACTIVE_WINDOW_MIN;
1044
+ })
1045
+ .slice(0, 4);
1046
+ const activeIds = new Set(activeNow.map(s => s.id));
1047
+ const recentSessions = sessions.filter(s => !activeIds.has(s.id)).slice(0, 6);
1048
+ const uniqueTools = new Set((data.tools || []).filter(t => t).map(t => fmtToolGroup(t)));
1049
+
1050
+ let html = `<div class="page-title">Overview</div>
1051
+
1052
+ <div class="section-label">Key Metrics</div>
867
1053
  <div class="stat-grid">
868
1054
  <div class="stat-card accent-blue"><div class="label">Sessions</div><div class="value">${data.sessions}</div></div>
869
1055
  <div class="stat-card accent-green"><div class="label">Messages</div><div class="value">${data.messages.toLocaleString()}</div></div>
870
1056
  <div class="stat-card accent-amber"><div class="label">Tool Calls</div><div class="value">${data.toolCalls.toLocaleString()}</div></div>
871
- <div class="stat-card accent-purple"><div class="label">Unique Tools</div><div class="value">${new Set((data.tools||[]).filter(t=>t).map(t=>fmtToolGroup(t))).size}</div></div>
872
1057
  <div class="stat-card accent-teal"><div class="label">Total Tokens</div><div class="value">${(data.totalTokens || 0).toLocaleString()}</div></div>
873
1058
  </div>
874
1059
 
875
- <div class="section-label">Configuration</div>
876
- <div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));gap:var(--space-md);margin-bottom:var(--space-md)">
1060
+ <div class="section-label">Active Now (${activeNow.length})</div>
1061
+ ${activeNow.length ? activeNow.map(renderSessionItem).join('') : `<div class="empty" style="margin-bottom:var(--space-xl)"><p>No active sessions right now</p></div>`}
1062
+
1063
+ <div class="section-label">Recent Sessions</div>
1064
+ ${recentSessions.map(renderSessionItem).join('')}
1065
+ `;
1066
+
1067
+ content.innerHTML = html;
1068
+ transitionView();
1069
+ $$('.session-item').forEach(item => item.addEventListener('click', () => viewSession(item.dataset.id)));
1070
+ }
1071
+
1072
+ async function viewStats() {
1073
+ clearJumpUi();
1074
+ content.innerHTML = `<div class="page-title">Settings</div>${skeletonRows(4, 'session')}`;
1075
+ transitionView();
1076
+ const data = await api('/stats');
1077
+ if (data._error) {
1078
+ content.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
1079
+ return;
1080
+ }
1081
+
1082
+ const uniqueTools = new Set((data.tools||[]).filter(t=>t).map(t=>fmtToolGroup(t)));
1083
+ const themeMode = lsGet(THEME_MODE_KEY) || 'light';
1084
+ const darkVariant = lsGet(THEME_DARK_VARIANT_KEY) || 'default';
1085
+
1086
+ let html = `<div class="settings-page">
1087
+ <div class="page-title">Settings</div>
1088
+
1089
+ <div class="section-label">System</div>
1090
+ <div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(220px, 1fr));gap:var(--space-md);margin-bottom:var(--space-md)">
877
1091
  <div class="config-card"><div class="config-label">Storage Mode</div><div class="config-value">${escHtml(data.storageMode || 'reference')}</div></div>
878
1092
  <div class="config-card"><div class="config-label">DB Size</div><div class="config-value" id="dbSizeValue">${escHtml(data.dbSize?.display || 'N/A')}</div></div>
879
1093
  </div>
880
- <div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:var(--space-xl)">
1094
+ <p class="settings-help" style="margin-bottom:var(--space-sm)">Date range: ${fmtDate(data.dateRange?.earliest)} — ${fmtDate(data.dateRange?.latest)}</p>
1095
+ <div class="settings-maintenance">
881
1096
  <button class="export-btn" id="optimizeDbBtn">Optimize Database</button>
882
- <span style="color:var(--text-tertiary);font-size:12px;line-height:1.5">Reclaims unused space and merges pending writes. Safe to run anytime, doesn't delete any data.</span>
883
- <span id="optimizeDbStatus" style="color:var(--text-tertiary);font-size:12px"></span>
1097
+ <span id="optimizeDbStatus" class="settings-maintenance-status"></span>
1098
+ </div>
1099
+ <p class="settings-help">Reclaims unused space and merges pending writes. Safe to run anytime, doesn't delete any data.</p>
1100
+
1101
+ <div class="section-label">Appearance</div>
1102
+ <div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(220px, 1fr));gap:var(--space-md);margin-bottom:var(--space-xl)">
1103
+ <div class="config-card">
1104
+ <div class="config-label">Theme Mode</div>
1105
+ <select id="themeModeSelect" class="settings-select">
1106
+ <option value="system" ${themeMode==='system'?'selected':''}>System</option>
1107
+ <option value="light" ${themeMode==='light'?'selected':''}>Light</option>
1108
+ <option value="dark" ${themeMode==='dark'?'selected':''}>Dark</option>
1109
+ </select>
1110
+ </div>
1111
+ <div class="config-card">
1112
+ <div class="config-label">Dark Variant</div>
1113
+ <select id="darkVariantSelect" class="settings-select">
1114
+ <option value="default" ${darkVariant==='default'?'selected':''}>Default</option>
1115
+ <option value="trueblack" ${darkVariant==='trueblack'?'selected':''}>True Black</option>
1116
+ </select>
1117
+ </div>
884
1118
  </div>
885
1119
 
886
1120
  ${data.sessionDirs && data.sessionDirs.length ? (() => {
@@ -912,15 +1146,38 @@ async function viewStats() {
912
1146
  })() : ''}
913
1147
 
914
1148
  ${data.agents && data.agents.length > 1 ? `<div class="section-label">Agents</div><div class="filters" style="margin-bottom:var(--space-xl)">${data.agents.map(a => `<span class="filter-chip">${escHtml(a)}</span>`).join('')}</div>` : ''}
915
- <div class="section-label">Date Range</div>
916
- <p style="color:var(--text-secondary);font-size:13px;margin-bottom:var(--space-xl)">${fmtDate(data.dateRange?.earliest)} \u2014 ${fmtDate(data.dateRange?.latest)}</p>
917
1149
  <div class="section-label">Tools Used</div>
918
1150
  <div class="tools-grid">${[...new Set((data.tools||[]).filter(t => t).map(t => fmtToolGroup(t)))].sort().map(t => `<span class="tool-chip">${escHtml(t)}</span>`).join('')}</div>
919
- `;
1151
+
1152
+ <div class="section-label">System Info</div>
1153
+ <div id="systemInfoContainer" style="display:grid;grid-template-columns:repeat(auto-fit, minmax(220px, 1fr));gap:var(--space-md);margin-bottom:var(--space-md)">
1154
+ <div class="config-card"><div class="config-label">Version</div><div class="config-value" id="sysVersion">…</div></div>
1155
+ <div class="config-card"><div class="config-label">Uptime</div><div class="config-value" id="sysUptime">…</div></div>
1156
+ <div class="config-card"><div class="config-label">Indexed Sessions</div><div class="config-value" id="sysSessions">…</div></div>
1157
+ <div class="config-card"><div class="config-label">Node.js</div><div class="config-value" id="sysNode">…</div></div>
1158
+ </div>
1159
+ </div>`;
920
1160
 
921
1161
  content.innerHTML = html;
922
1162
  transitionView();
923
1163
 
1164
+ const themeModeSelect = $('#themeModeSelect');
1165
+ const darkVariantSelect = $('#darkVariantSelect');
1166
+ if (themeModeSelect) {
1167
+ themeModeSelect.addEventListener('change', () => {
1168
+ lsSet(THEME_MODE_KEY, themeModeSelect.value);
1169
+ window._themeMode = themeModeSelect.value;
1170
+ applyThemeFromPrefs();
1171
+ });
1172
+ }
1173
+ if (darkVariantSelect) {
1174
+ darkVariantSelect.addEventListener('change', () => {
1175
+ lsSet(THEME_DARK_VARIANT_KEY, darkVariantSelect.value);
1176
+ window._themeDarkVariant = darkVariantSelect.value;
1177
+ applyThemeFromPrefs();
1178
+ });
1179
+ }
1180
+
924
1181
  const optimizeBtn = $('#optimizeDbBtn');
925
1182
  const optimizeStatus = $('#optimizeDbStatus');
926
1183
  if (optimizeBtn) {
@@ -938,9 +1195,35 @@ async function viewStats() {
938
1195
  optimizeBtn.disabled = false;
939
1196
  });
940
1197
  }
1198
+
1199
+ // Fetch system info
1200
+ api('/health').then(h => {
1201
+ if (h._error) return;
1202
+ const fmtUptime = (s) => {
1203
+ const d = Math.floor(s / 86400);
1204
+ const hr = Math.floor((s % 86400) / 3600);
1205
+ const m = Math.floor((s % 3600) / 60);
1206
+ if (d > 0) return `${d}d ${hr}h`;
1207
+ if (hr > 0) return `${hr}h ${m}m`;
1208
+ return `${m}m`;
1209
+ };
1210
+ const fmtBytes = (b) => {
1211
+ if (b >= 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB`;
1212
+ return `${(b / 1024).toFixed(1)} KB`;
1213
+ };
1214
+ const v = $('#sysVersion');
1215
+ const u = $('#sysUptime');
1216
+ const sc = $('#sysSessions');
1217
+ const n = $('#sysNode');
1218
+ if (v) v.textContent = `v${h.version}`;
1219
+ if (u) u.textContent = fmtUptime(h.uptime);
1220
+ if (sc) sc.textContent = String(h.sessions);
1221
+ if (n) n.textContent = h.node;
1222
+ });
941
1223
  }
942
1224
 
943
1225
  async function viewFiles() {
1226
+ clearJumpUi();
944
1227
  window._lastView = 'files';
945
1228
  content.innerHTML = `<div class="page-title">Files</div>${skeletonRows(6, 'file')}`;
946
1229
  transitionView();
@@ -1112,6 +1395,7 @@ function renderFileItem(f) {
1112
1395
  }
1113
1396
 
1114
1397
  async function viewFileDetail(filePath) {
1398
+ clearJumpUi();
1115
1399
  content.innerHTML = '<div class="loading">Loading</div>';
1116
1400
  const data = await api(`/files/sessions?path=${encodeURIComponent(filePath)}`);
1117
1401
  if (data._error) {
@@ -1138,7 +1422,7 @@ async function viewFileDetail(filePath) {
1138
1422
  // --- Navigation ---
1139
1423
  window._searchType = '';
1140
1424
  window._searchRole = '';
1141
- window._lastView = 'sessions';
1425
+ window._lastView = 'overview';
1142
1426
 
1143
1427
  $$('.nav-item').forEach(item => {
1144
1428
  item.addEventListener('click', () => {
@@ -1149,7 +1433,7 @@ $$('.nav-item').forEach(item => {
1149
1433
  window._lastView = view;
1150
1434
  updateNavActive(view);
1151
1435
  setHash(view);
1152
- if (view === 'search') viewSearch();
1436
+ if (view === 'overview') viewOverview();
1153
1437
  else if (view === 'sessions') viewSessions();
1154
1438
  else if (view === 'files') viewFiles();
1155
1439
  else if (view === 'timeline') viewTimeline();
@@ -1157,9 +1441,259 @@ $$('.nav-item').forEach(item => {
1157
1441
  });
1158
1442
  });
1159
1443
 
1444
+ // --- Command Palette (Cmd+K) ---
1445
+ window._cmdk = { open: false, index: 0, items: [], scrollY: 0 };
1446
+
1447
+ function closeCmdk() {
1448
+ const el = $('#cmdkOverlay');
1449
+ if (el) el.remove();
1450
+
1451
+ document.documentElement.classList.remove('cmdk-open');
1452
+ document.body.classList.remove('cmdk-open');
1453
+
1454
+ window._cmdk.open = false;
1455
+ }
1456
+
1457
+ function execCmdkItem(i) {
1458
+ const item = window._cmdk.items[i];
1459
+ if (!item) return;
1460
+ closeCmdk();
1461
+ item.action();
1462
+ }
1463
+
1464
+ function renderCmdkList() {
1465
+ const list = $('#cmdkList');
1466
+ if (!list) return;
1467
+ const { items, index } = window._cmdk;
1468
+ if (!items.length) {
1469
+ list.innerHTML = '<div class="cmdk-empty"><h4>No results</h4>Try a different search term</div>';
1470
+ return;
1471
+ }
1472
+
1473
+ let html = '';
1474
+ let lastGroup = '';
1475
+ items.forEach((item, i) => {
1476
+ if (item.group !== lastGroup) {
1477
+ lastGroup = item.group;
1478
+ html += `<div class="cmdk-group-label">${escHtml(lastGroup)}</div>`;
1479
+ }
1480
+ html += `<button type="button" class="cmdk-item ${i === index ? 'active' : ''}" data-i="${i}">
1481
+ <div class="cmdk-item-body">
1482
+ <div class="cmdk-item-title">${escHtml(item.title)}</div>
1483
+ ${item.sub ? `<div class="cmdk-item-sub">${escHtml(item.sub)}</div>` : ''}
1484
+ </div>
1485
+ ${item.meta ? `<span class="cmdk-item-meta">${escHtml(item.meta)}</span>` : ''}
1486
+ </button>`;
1487
+ });
1488
+ list.innerHTML = html;
1489
+
1490
+ if (!list.dataset.bound) {
1491
+ list.addEventListener('click', (e) => {
1492
+ const btn = e.target.closest('.cmdk-item');
1493
+ if (!btn) return;
1494
+ e.preventDefault();
1495
+ const idx = Number(btn.dataset.i || -1);
1496
+ if (idx >= 0) execCmdkItem(idx);
1497
+ });
1498
+
1499
+ list.addEventListener('pointermove', (e) => {
1500
+ if (e.pointerType && e.pointerType !== 'mouse') return;
1501
+ const btn = e.target.closest('.cmdk-item');
1502
+ if (!btn) return;
1503
+ const idx = Number(btn.dataset.i || -1);
1504
+ if (idx >= 0 && idx !== window._cmdk.index) {
1505
+ window._cmdk.index = idx;
1506
+ renderCmdkList();
1507
+ }
1508
+ });
1509
+
1510
+ list.dataset.bound = '1';
1511
+ }
1512
+
1513
+ // scroll active into view
1514
+ const active = list.querySelector('.cmdk-item.active');
1515
+ if (active) active.scrollIntoView({ block: 'nearest' });
1516
+ }
1517
+
1518
+ async function loadCmdkResults(query) {
1519
+ const list = $('#cmdkList');
1520
+ if (!list) return;
1521
+ const q = (query || '').trim();
1522
+ if (!q) { loadCmdkHome(); return; }
1523
+
1524
+ list.innerHTML = '<div class="cmdk-loading">Searching\u2026</div>';
1525
+
1526
+ const [searchRes, sessionsRes, filesRes] = await Promise.all([
1527
+ api(`/search?q=${encodeURIComponent(q)}&limit=6`),
1528
+ api('/sessions?limit=20'),
1529
+ api('/files?limit=20')
1530
+ ]);
1531
+
1532
+ const items = [];
1533
+
1534
+ (sessionsRes.sessions || [])
1535
+ .filter(s => cleanSessionSummary(s.summary, s.initial_prompt).toLowerCase().includes(q.toLowerCase()))
1536
+ .slice(0, 3)
1537
+ .forEach(s => items.push({
1538
+ group: 'Sessions',
1539
+ title: truncate(cleanSessionSummary(s.summary, s.initial_prompt), 64),
1540
+ sub: shortSessionId(s.id),
1541
+ meta: fmtTime(s.start_time),
1542
+ action: () => viewSession(s.id)
1543
+ }));
1544
+
1545
+ (filesRes.files || [])
1546
+ .filter(f => f.file_path.toLowerCase().includes(q.toLowerCase()))
1547
+ .slice(0, 2)
1548
+ .forEach(f => items.push({
1549
+ group: 'Files',
1550
+ title: f.file_path.split('/').pop(),
1551
+ sub: f.file_path,
1552
+ meta: `${f.touch_count} touches`,
1553
+ action: () => viewFileDetail(f.file_path)
1554
+ }));
1555
+
1556
+ (searchRes.results || []).slice(0, 4).forEach(r => items.push({
1557
+ group: 'Search Results',
1558
+ title: truncate(r.content || r.tool_args || r.tool_result || '', 66),
1559
+ sub: shortSessionId(r.session_id),
1560
+ meta: fmtTime(r.timestamp),
1561
+ action: () => viewSession(r.session_id)
1562
+ }));
1563
+
1564
+ window._cmdk.items = items;
1565
+ window._cmdk.index = 0;
1566
+ renderCmdkList();
1567
+ }
1568
+
1569
+ async function loadCmdkHome() {
1570
+ const list = $('#cmdkList');
1571
+ if (!list) return;
1572
+ list.innerHTML = '<div class="cmdk-loading">Loading\u2026</div>';
1573
+
1574
+ const items = [
1575
+ { group: 'Go to', title: 'Sessions', sub: 'Browse all sessions', action: () => { setHash('sessions'); handleRoute(); } },
1576
+ { group: 'Go to', title: 'Timeline', sub: 'Today and historical events', action: () => { setHash('timeline'); handleRoute(); } },
1577
+ { group: 'Go to', title: 'Overview', sub: 'Dashboard summary', action: () => { setHash('overview'); handleRoute(); } },
1578
+ { group: 'Go to', title: 'Files', sub: 'Touched files explorer', action: () => { setHash('files'); handleRoute(); } },
1579
+ { group: 'Go to', title: 'Settings', sub: 'Configuration and maintenance', action: () => { setHash('stats'); handleRoute(); } },
1580
+ ];
1581
+
1582
+ const sessionsRes = await api('/sessions?limit=20');
1583
+
1584
+ const sessionList = sessionsRes.sessions || [];
1585
+ const sessionMap = new Map(sessionList.map(s => [s.id, s]));
1586
+
1587
+ const recentIds = window._recentSessionIds || [];
1588
+ const missingIds = recentIds.filter(id => !sessionMap.has(id));
1589
+ if (missingIds.length) {
1590
+ const fetched = await Promise.all(missingIds.map(id => api(`/sessions/${id}`)));
1591
+ fetched.forEach(r => {
1592
+ if (r && !r._error && r.session) sessionMap.set(r.session.id, r.session);
1593
+ });
1594
+ }
1595
+
1596
+ recentIds.forEach(id => {
1597
+ const s = sessionMap.get(id);
1598
+ if (!s) return;
1599
+ items.push({
1600
+ group: 'Recently Opened',
1601
+ title: truncate(cleanSessionSummary(s.summary, s.initial_prompt), 64),
1602
+ sub: shortSessionId(s.id),
1603
+ meta: fmtTime(s.start_time),
1604
+ action: () => viewSession(s.id)
1605
+ });
1606
+ });
1607
+
1608
+ sessionList.forEach(s => items.push({
1609
+ group: 'Recent Sessions',
1610
+ title: truncate(cleanSessionSummary(s.summary, s.initial_prompt), 64),
1611
+ sub: shortSessionId(s.id),
1612
+ meta: fmtTime(s.start_time),
1613
+ action: () => viewSession(s.id)
1614
+ }));
1615
+
1616
+ window._cmdk.items = items;
1617
+ window._cmdk.index = 0;
1618
+ renderCmdkList();
1619
+ }
1620
+
1621
+ function openCmdk() {
1622
+ if ($('#cmdkOverlay')) { $('#cmdkInput')?.focus(); return; }
1623
+
1624
+ const overlay = document.createElement('div');
1625
+ overlay.className = 'cmdk-overlay';
1626
+ overlay.id = 'cmdkOverlay';
1627
+ overlay.innerHTML = `<div class="cmdk-dialog" role="dialog" aria-modal="true">
1628
+ <div class="cmdk-input-wrap">
1629
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
1630
+ <input id="cmdkInput" type="text" placeholder="Search sessions, files, or jump to a view" />
1631
+ <kbd>ESC</kbd>
1632
+ </div>
1633
+ <div class="cmdk-list" id="cmdkList"></div>
1634
+ </div>`;
1635
+ document.body.appendChild(overlay);
1636
+ window._cmdk.open = true;
1637
+
1638
+ const input = $('#cmdkInput');
1639
+ const isMobile = window.matchMedia('(max-width: 768px)').matches;
1640
+
1641
+ document.documentElement.classList.add('cmdk-open');
1642
+ document.body.classList.add('cmdk-open');
1643
+
1644
+ if (isMobile) {
1645
+ // iOS keyboard requires focus in the same user gesture call stack.
1646
+ try {
1647
+ input.focus({ preventScroll: true });
1648
+ } catch {
1649
+ input.focus();
1650
+ }
1651
+ input.setSelectionRange(input.value.length, input.value.length);
1652
+ } else {
1653
+ input.focus();
1654
+ }
1655
+
1656
+ let debounce;
1657
+ input.addEventListener('input', () => {
1658
+ clearTimeout(debounce);
1659
+ debounce = setTimeout(() => loadCmdkResults(input.value), 150);
1660
+ });
1661
+
1662
+ overlay.addEventListener('click', e => { if (e.target === overlay) closeCmdk(); });
1663
+
1664
+ overlay.addEventListener('keydown', e => {
1665
+ if (e.key === 'Escape') { e.preventDefault(); closeCmdk(); return; }
1666
+ if (e.key === 'ArrowDown') {
1667
+ e.preventDefault();
1668
+ window._cmdk.index = Math.min(window._cmdk.index + 1, window._cmdk.items.length - 1);
1669
+ renderCmdkList();
1670
+ } else if (e.key === 'ArrowUp') {
1671
+ e.preventDefault();
1672
+ window._cmdk.index = Math.max(window._cmdk.index - 1, 0);
1673
+ renderCmdkList();
1674
+ } else if (e.key === 'Enter') {
1675
+ e.preventDefault();
1676
+ execCmdkItem(window._cmdk.index);
1677
+ }
1678
+ });
1679
+
1680
+ loadCmdkHome();
1681
+ }
1682
+
1160
1683
  initTheme();
1161
1684
  document.getElementById('theme-toggle')?.addEventListener('click', toggleTheme);
1162
1685
  document.getElementById('theme-toggle-mobile')?.addEventListener('click', toggleTheme);
1686
+ document.getElementById('cmdkBtn')?.addEventListener('click', () => openCmdk());
1687
+ document.getElementById('mobile-search-btn')?.addEventListener('click', () => openCmdk());
1688
+ document.addEventListener('keydown', e => {
1689
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
1690
+ e.preventDefault();
1691
+ if (window._cmdk.open) closeCmdk();
1692
+ else openCmdk();
1693
+ return;
1694
+ }
1695
+ if (e.key === 'Escape' && window._cmdk.open) { e.preventDefault(); closeCmdk(); }
1696
+ });
1163
1697
  handleRoute();
1164
1698
 
1165
1699
  // Swipe right from left edge to go back