agentacta 1.3.4 → 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,10 +3,74 @@ const $$ = (s, p = document) => [...p.querySelectorAll(s)];
3
3
  const content = $('#content');
4
4
  const API = '/api';
5
5
 
6
- async function api(path) {
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
+ }
24
+
25
+ function applyTheme(theme) {
26
+ document.documentElement.setAttribute('data-theme', theme);
27
+ const meta = document.querySelector('meta[name="theme-color"]');
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));
40
+ }
41
+
42
+ function initTheme() {
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
+ }
60
+ }
61
+
62
+ function toggleTheme() {
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();
68
+ }
69
+
70
+ async function api(path, options = {}) {
7
71
  let res;
8
72
  try {
9
- res = await fetch(API + path);
73
+ res = await fetch(API + path, options);
10
74
  } catch (err) {
11
75
  // Network error (server down, offline, etc.)
12
76
  return { _error: true, error: 'Network error' };
@@ -71,6 +135,26 @@ function escHtml(s) {
71
135
  return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
72
136
  }
73
137
 
138
+ function fmtToolName(name) {
139
+ if (!name) return '';
140
+ // MCP tools: mcp__provider__action → mcp_provider_action
141
+ const mcp = name.match(/^mcp__(.+?)__(.+)$/);
142
+ if (mcp) {
143
+ const provider = mcp[1].replace(/__/g, '_');
144
+ const action = mcp[2].replace(/__/g, '_');
145
+ return `mcp_${provider}_${action}`;
146
+ }
147
+ return name;
148
+ }
149
+
150
+ function fmtToolGroup(name) {
151
+ if (!name) return '';
152
+ // MCP tools: collapse to mcp_provider (no action)
153
+ const mcp = name.match(/^mcp__(.+?)__/);
154
+ if (mcp) return 'mcp_' + mcp[1].replace(/__/g, '_');
155
+ return name;
156
+ }
157
+
74
158
  function truncate(s, n = 200) {
75
159
  if (!s) return '';
76
160
  return s.length > n ? s.slice(0, n) + '\u2026' : s;
@@ -95,6 +179,48 @@ function transitionView() {
95
179
  content.classList.add('view-enter');
96
180
  }
97
181
 
182
+ function skeletonLine(width = '100%', height = '12px') {
183
+ return `<div class="skeleton-line" style="width:${width};height:${height}"></div>`;
184
+ }
185
+
186
+ function skeletonRows(count = 6, kind = 'event') {
187
+ if (kind === 'session') {
188
+ return Array.from({ length: count }).map(() => `
189
+ <div class="session-item skeleton-card">
190
+ <div class="skeleton-line" style="width:58%;height:12px"></div>
191
+ <div class="skeleton-line" style="width:90%;height:14px"></div>
192
+ <div class="skeleton-line" style="width:72%;height:14px"></div>
193
+ </div>
194
+ `).join('');
195
+ }
196
+ if (kind === 'stats') {
197
+ return Array.from({ length: count }).map(() => `
198
+ <div class="stat-card skeleton-card">
199
+ <div class="skeleton-line" style="width:44%;height:10px"></div>
200
+ <div class="skeleton-line" style="width:66%;height:28px;margin-top:8px"></div>
201
+ </div>
202
+ `).join('');
203
+ }
204
+ if (kind === 'file') {
205
+ return Array.from({ length: count }).map(() => `
206
+ <div class="file-item skeleton-card">
207
+ <div class="skeleton-line" style="width:62%;height:13px"></div>
208
+ <div class="skeleton-line" style="width:86%;height:12px;margin-top:10px"></div>
209
+ </div>
210
+ `).join('');
211
+ }
212
+ return Array.from({ length: count }).map(() => `
213
+ <div class="event-item skeleton-row">
214
+ <div class="skeleton-line" style="width:72px;height:10px"></div>
215
+ <div class="skeleton-line" style="width:60px;height:16px"></div>
216
+ <div class="event-body">
217
+ <div class="skeleton-line" style="width:82%;height:12px"></div>
218
+ <div class="skeleton-line" style="width:66%;height:12px;margin-top:8px"></div>
219
+ </div>
220
+ </div>
221
+ `).join('');
222
+ }
223
+
98
224
  // --- Hash routing ---
99
225
  window._navDepth = 0;
100
226
 
@@ -116,22 +242,25 @@ function updateNavActive(view) {
116
242
  }
117
243
 
118
244
  function handleRoute() {
119
- const raw = (window.location.hash || '').slice(1) || 'search';
245
+ clearJumpUi();
246
+ const raw = (window.location.hash || '').slice(1) || 'overview';
120
247
  if (window._sseCleanup) { window._sseCleanup(); window._sseCleanup = null; }
248
+ if (window._timelineScrollHandler) { window.removeEventListener('scroll', window._timelineScrollHandler); window._timelineScrollHandler = null; }
121
249
 
122
250
  if (raw.startsWith('session/')) {
123
251
  const id = decodeURIComponent(raw.slice('session/'.length));
124
252
  if (id) { viewSession(id); return; }
125
253
  }
126
254
 
127
- 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';
128
257
  window._lastView = view;
129
258
  updateNavActive(view);
130
- if (view === 'sessions') viewSessions();
259
+ if (view === 'overview') viewOverview();
260
+ else if (view === 'sessions') viewSessions();
131
261
  else if (view === 'files') viewFiles();
132
262
  else if (view === 'timeline') viewTimeline();
133
- else if (view === 'stats') viewStats();
134
- else viewSearch(window._lastSearchQuery || '');
263
+ else viewStats();
135
264
  }
136
265
 
137
266
  window.addEventListener('popstate', () => {
@@ -144,7 +273,7 @@ function renderEvent(ev) {
144
273
  let body = '';
145
274
 
146
275
  if (ev.type === 'tool_call') {
147
- body = `<span class="tool-name">${escHtml(ev.tool_name)}</span>`;
276
+ body = `<span class="tool-name">${escHtml(fmtToolName(ev.tool_name))}</span>`;
148
277
  if (ev.tool_args) {
149
278
  try {
150
279
  const args = JSON.parse(ev.tool_args);
@@ -154,7 +283,7 @@ function renderEvent(ev) {
154
283
  }
155
284
  }
156
285
  } else if (ev.type === 'tool_result') {
157
- body = `<span class="tool-name">\u2192 ${escHtml(ev.tool_name)}</span>`;
286
+ body = `<span class="tool-name">\u2192 ${escHtml(fmtToolName(ev.tool_name))}</span>`;
158
287
  if (ev.content) {
159
288
  body += `<div class="tool-args">${escHtml(truncate(ev.content, 500))}</div>`;
160
289
  }
@@ -178,7 +307,7 @@ function renderTimelineEvent(ev) {
178
307
  let body = '';
179
308
 
180
309
  if (ev.type === 'tool_call') {
181
- body = `<span class="tool-name">${escHtml(ev.tool_name)}</span>`;
310
+ body = `<span class="tool-name">${escHtml(fmtToolName(ev.tool_name))}</span>`;
182
311
  if (ev.tool_args) {
183
312
  try {
184
313
  const args = JSON.parse(ev.tool_args);
@@ -188,7 +317,7 @@ function renderTimelineEvent(ev) {
188
317
  }
189
318
  }
190
319
  } else if (ev.type === 'tool_result') {
191
- body = `<span class="tool-name">\u2192 ${escHtml(ev.tool_name)}</span>`;
320
+ body = `<span class="tool-name">\u2192 ${escHtml(fmtToolName(ev.tool_name))}</span>`;
192
321
  if (ev.content) {
193
322
  body += `<div class="tool-args">${escHtml(truncate(ev.content, 500))}</div>`;
194
323
  }
@@ -226,6 +355,56 @@ function normalizeAgentLabel(a) {
226
355
  return a;
227
356
  }
228
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
+
229
408
  function isInternalProjectTag(tag) {
230
409
  if (!tag) return true;
231
410
  if (tag.startsWith('agent:')) return true;
@@ -268,7 +447,7 @@ function renderSessionItem(s) {
268
447
  ${renderModelTags(s)}
269
448
  </span>
270
449
  </div>
271
- <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>
272
451
  <div class="session-meta">
273
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>
274
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>
@@ -280,6 +459,7 @@ function renderSessionItem(s) {
280
459
  // --- Views ---
281
460
 
282
461
  async function viewSearch(query = '') {
462
+ clearJumpUi();
283
463
  const typeFilter = window._searchType || '';
284
464
  const roleFilter = window._searchRole || '';
285
465
 
@@ -296,7 +476,12 @@ async function viewSearch(query = '') {
296
476
  <span class="filter-chip ${roleFilter==='user'?'active':''}" data-filter="role" data-val="user">User</span>
297
477
  <span class="filter-chip ${roleFilter==='assistant'?'active':''}" data-filter="role" data-val="assistant">Assistant</span>
298
478
  </div>
299
- <div id="results"></div>`;
479
+ <div id="results">
480
+ <div class="search-bar skeleton-card" style="margin-top:6px">
481
+ <div class="skeleton-line" style="height:16px;width:40%"></div>
482
+ </div>
483
+ ${skeletonRows(4, 'session')}
484
+ </div>`;
300
485
 
301
486
  content.innerHTML = html;
302
487
  transitionView();
@@ -325,10 +510,12 @@ async function viewSearch(query = '') {
325
510
 
326
511
  async function showSearchHome() {
327
512
  const el = $('#results');
328
- el.innerHTML = '<div class="loading">Loading</div>';
513
+ const reqId = (window._searchReqSeq = (window._searchReqSeq || 0) + 1);
514
+ el.innerHTML = `${skeletonRows(4, 'session')}`;
329
515
 
330
516
  const stats = await api('/stats');
331
517
  const sessions = await api('/sessions?limit=5');
518
+ if (reqId !== window._searchReqSeq) return;
332
519
  if (stats._error || sessions._error) {
333
520
  el.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
334
521
  return;
@@ -336,6 +523,7 @@ async function showSearchHome() {
336
523
 
337
524
  let suggestions = [];
338
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;
339
527
 
340
528
  let html = `
341
529
  <div class="search-stats" style="margin-top:8px">
@@ -374,9 +562,10 @@ async function showSearchHome() {
374
562
 
375
563
  async function doSearch(q) {
376
564
  const el = $('#results');
565
+ const reqId = (window._searchReqSeq = (window._searchReqSeq || 0) + 1);
377
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; }
378
567
 
379
- el.innerHTML = '<div class="loading">Searching</div>';
568
+ el.innerHTML = `${skeletonRows(6, 'event')}`;
380
569
 
381
570
  const type = window._searchType || '';
382
571
  const role = window._searchRole || '';
@@ -385,6 +574,7 @@ async function doSearch(q) {
385
574
  if (role) url += `&role=${role}`;
386
575
 
387
576
  const data = await api(url);
577
+ if (reqId !== window._searchReqSeq) return;
388
578
 
389
579
  if (data._error || data.error) { el.innerHTML = `<div class="empty"><p>${escHtml(data.error || 'Server error')}</p></div>`; return; }
390
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; }
@@ -402,7 +592,7 @@ async function doSearch(q) {
402
592
  <div class="result-meta">
403
593
  <span class="event-badge ${badgeClass(r.type, r.role)}">${r.type === 'tool_call' ? 'tool' : r.role || r.type}</span>
404
594
  <span class="session-time">${fmtTime(r.timestamp)}</span>
405
- ${r.tool_name ? `<span class="tool-name">${escHtml(r.tool_name)}</span>` : ''}
595
+ ${r.tool_name ? `<span class="tool-name">${escHtml(fmtToolName(r.tool_name))}</span>` : ''}
406
596
  <span class="session-link" data-session="${r.session_id}">view session \u2192</span>
407
597
  </div>
408
598
  <div class="result-content">${escHtml(truncate(r.content || r.tool_args || r.tool_result || '', 400))}</div>
@@ -419,8 +609,10 @@ async function doSearch(q) {
419
609
  }
420
610
 
421
611
  async function viewSessions() {
612
+ clearJumpUi();
422
613
  window._currentSessionId = null;
423
- content.innerHTML = '<div class="loading">Loading</div>';
614
+ content.innerHTML = `<div class="page-title">Sessions</div>${skeletonRows(4, 'session')}`;
615
+ transitionView();
424
616
  const data = await api('/sessions?limit=200');
425
617
  if (data._error) {
426
618
  content.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
@@ -438,10 +630,18 @@ async function viewSessions() {
438
630
  }
439
631
 
440
632
  async function viewSession(id) {
633
+ clearJumpUi();
634
+ window._recentSessionIds = [id, ...(window._recentSessionIds || []).filter(x => x !== id)].slice(0, 5);
441
635
  if (window._sseCleanup) { window._sseCleanup(); window._sseCleanup = null; }
442
636
  window._currentSessionId = id;
443
637
  setHash('session/' + encodeURIComponent(id));
444
638
  window.scrollTo(0, 0);
639
+ content.innerHTML = `
640
+ <div class="back-btn">← Back</div>
641
+ <div class="page-title">Session</div>
642
+ ${skeletonRows(8, 'event')}
643
+ `;
644
+ transitionView();
445
645
  const data = await api(`/sessions/${id}`);
446
646
 
447
647
  if (data._error || data.error) { content.innerHTML = `<div class="empty"><h2>${escHtml(data.error || 'Unable to load')}</h2></div>`; return; }
@@ -525,6 +725,7 @@ async function viewSession(id) {
525
725
  }
526
726
 
527
727
  $('#backBtn').addEventListener('click', () => {
728
+ clearJumpUi();
528
729
  if (window._navDepth > 0) {
529
730
  history.back();
530
731
  } else {
@@ -575,19 +776,65 @@ async function viewSession(id) {
575
776
 
576
777
  const jumpBtn = $('#jumpToStartBtn');
577
778
  if (jumpBtn) {
578
- jumpBtn.addEventListener('click', () => {
579
- // 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;
580
790
  while (rendered < allEvents.length) {
581
791
  renderBatch();
792
+ loops += 1;
793
+ if (loops % 2 === 0) await new Promise(requestAnimationFrame);
582
794
  }
795
+
583
796
  const firstMessage = document.querySelector(`[data-event-id="${s.first_message_id}"]`);
584
- if (firstMessage) {
585
- 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(() => {
586
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
+
587
834
  setTimeout(() => {
588
- firstMessage.classList.remove('event-highlight');
589
- }, 2000);
590
- }
835
+ if (returnBtn) returnBtn.classList.remove('show');
836
+ }, 9000);
837
+ }, doneDelay);
591
838
  });
592
839
  }
593
840
 
@@ -669,58 +916,205 @@ async function viewSession(id) {
669
916
  }
670
917
 
671
918
  async function viewTimeline(date) {
919
+ clearJumpUi();
672
920
  if (!date) {
673
921
  const now = new Date();
674
922
  date = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
675
923
  }
676
924
  window._lastView = 'timeline';
925
+ window._timelineState = { date, limit: 100, offset: 0, hasMore: true, loading: false, seenEventIds: new Set() };
677
926
 
678
- let html = `<div class="page-title">Timeline</div>
927
+ content.innerHTML = `<div class="page-title">Timeline</div>
679
928
  <input type="date" class="date-input" id="dateInput" value="${date}">
680
- <div id="timelineContent"><div class="loading">Loading</div></div>`;
681
- content.innerHTML = html;
929
+ <div id="timelineContent">${skeletonRows(8, 'event')}</div>
930
+ <div id="timelineLoadMore" class="loading-more" style="display:none">Loading more…</div>`;
682
931
  transitionView();
683
932
 
684
- const data = await api(`/timeline?date=${date}`);
685
- if (data._error) {
686
- $('#timelineContent').innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
687
- return;
688
- }
689
933
  const el = $('#timelineContent');
934
+ const state = window._timelineState;
690
935
 
691
- if (!data.events.length) {
692
- el.innerHTML = '<div class="empty"><h2>No activity</h2><p>Nothing recorded on this day</p></div>';
693
- } else {
694
- el.innerHTML = `<div class="timeline-events-wrap">
695
- <div class="timeline-line"></div>
696
- ${data.events.map(renderTimelineEvent).join('')}
697
- </div>`;
936
+ async function loadTimelinePage(append = false) {
937
+ if (state.loading || (!state.hasMore && append)) return;
938
+ state.loading = true;
939
+ if (append) $('#timelineLoadMore').style.display = 'block';
940
+
941
+ const data = await api(`/timeline?date=${state.date}&limit=${state.limit}&offset=${state.offset}`);
942
+ state.loading = false;
943
+ $('#timelineLoadMore').style.display = 'none';
944
+
945
+ if (data._error) {
946
+ if (!append) el.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
947
+ return;
948
+ }
949
+
950
+ state.hasMore = !!data.hasMore;
951
+ state.offset += (data.events || []).length;
952
+ (data.events || []).forEach(ev => state.seenEventIds.add(ev.id));
953
+
954
+ if (!append) {
955
+ if (!data.events.length) {
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>`;
957
+ return;
958
+ }
959
+ el.innerHTML = `<div class="timeline-events-wrap" id="timelineWrap"><div class="timeline-line"></div>${data.events.map(renderTimelineEvent).join('')}</div>`;
960
+ return;
961
+ }
962
+
963
+ const wrap = $('#timelineWrap');
964
+ if (wrap) {
965
+ wrap.insertAdjacentHTML('beforeend', data.events.map(renderTimelineEvent).join(''));
966
+ }
698
967
  }
699
968
 
700
- $('#dateInput').addEventListener('change', e => viewTimeline(e.target.value));
969
+ await loadTimelinePage(false);
970
+
971
+ if (window._timelineScrollHandler) window.removeEventListener('scroll', window._timelineScrollHandler);
972
+ window._timelineScrollHandler = () => {
973
+ const st = window._timelineState;
974
+ if (!st || st.loading || !st.hasMore) return;
975
+ const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 300;
976
+ if (nearBottom) loadTimelinePage(true);
977
+ };
978
+ window.addEventListener('scroll', window._timelineScrollHandler, { passive: true });
979
+
980
+ // Live updates via SSE (only for today)
981
+ const today = new Date();
982
+ const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
983
+ if (window._timelineSse) { window._timelineSse.close(); window._timelineSse = null; }
984
+ if (date === todayStr) {
985
+ const sse = new EventSource(`/api/timeline/stream?after=${encodeURIComponent(new Date().toISOString())}&afterId=`);
986
+ window._timelineSse = sse;
987
+ sse.onmessage = (evt) => {
988
+ try {
989
+ const rows = JSON.parse(evt.data);
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');
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
+ });
1014
+ } catch {}
1015
+ };
1016
+ }
1017
+
1018
+ $('#dateInput').addEventListener('change', e => {
1019
+ if (window._timelineSse) { window._timelineSse.close(); window._timelineSse = null; }
1020
+ viewTimeline(e.target.value);
1021
+ });
701
1022
  }
702
1023
 
703
- async function viewStats() {
704
- content.innerHTML = '<div class="loading">Loading</div>';
1024
+ async function viewOverview() {
1025
+ clearJumpUi();
1026
+ content.innerHTML = `<div class="page-title">Overview</div><div class="stat-grid">${skeletonRows(5, 'stats')}</div>`;
1027
+ transitionView();
705
1028
  const data = await api('/stats');
706
- if (data._error) {
1029
+ const sessionsRes = await api('/sessions?limit=30');
1030
+ if (data._error || sessionsRes._error) {
707
1031
  content.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
708
1032
  return;
709
1033
  }
710
1034
 
711
- 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>
712
1053
  <div class="stat-grid">
713
1054
  <div class="stat-card accent-blue"><div class="label">Sessions</div><div class="value">${data.sessions}</div></div>
714
1055
  <div class="stat-card accent-green"><div class="label">Messages</div><div class="value">${data.messages.toLocaleString()}</div></div>
715
1056
  <div class="stat-card accent-amber"><div class="label">Tool Calls</div><div class="value">${data.toolCalls.toLocaleString()}</div></div>
716
- <div class="stat-card accent-purple"><div class="label">Unique Tools</div><div class="value">${data.uniqueTools}</div></div>
717
1057
  <div class="stat-card accent-teal"><div class="label">Total Tokens</div><div class="value">${(data.totalTokens || 0).toLocaleString()}</div></div>
718
1058
  </div>
719
1059
 
720
- <div class="section-label">Configuration</div>
721
- <div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));gap:var(--space-md);margin-bottom:var(--space-xl)">
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)">
722
1091
  <div class="config-card"><div class="config-label">Storage Mode</div><div class="config-value">${escHtml(data.storageMode || 'reference')}</div></div>
723
- <div class="config-card"><div class="config-label">DB Size</div><div class="config-value">${escHtml(data.dbSize?.display || 'N/A')}</div></div>
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>
1093
+ </div>
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">
1096
+ <button class="export-btn" id="optimizeDbBtn">Optimize Database</button>
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>
724
1118
  </div>
725
1119
 
726
1120
  ${data.sessionDirs && data.sessionDirs.length ? (() => {
@@ -752,19 +1146,87 @@ async function viewStats() {
752
1146
  })() : ''}
753
1147
 
754
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>` : ''}
755
- <div class="section-label">Date Range</div>
756
- <p style="color:var(--text-secondary);font-size:13px;margin-bottom:var(--space-xl)">${fmtDate(data.dateRange?.earliest)} \u2014 ${fmtDate(data.dateRange?.latest)}</p>
757
1149
  <div class="section-label">Tools Used</div>
758
- <div class="tools-grid">${(data.tools||[]).filter(t => t).sort().map(t => `<span class="tool-chip">${escHtml(t)}</span>`).join('')}</div>
759
- `;
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>
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>`;
760
1160
 
761
1161
  content.innerHTML = html;
762
1162
  transitionView();
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
+
1181
+ const optimizeBtn = $('#optimizeDbBtn');
1182
+ const optimizeStatus = $('#optimizeDbStatus');
1183
+ if (optimizeBtn) {
1184
+ optimizeBtn.addEventListener('click', async () => {
1185
+ optimizeBtn.disabled = true;
1186
+ optimizeStatus.textContent = 'Optimizing…';
1187
+ const result = await api('/maintenance', { method: 'POST' });
1188
+ if (result._error || !result.ok) {
1189
+ optimizeStatus.textContent = `Failed: ${result.error || 'Unknown error'}`;
1190
+ } else {
1191
+ optimizeStatus.textContent = `${result.sizeBefore?.display || 'N/A'} → ${result.sizeAfter?.display || 'N/A'}`;
1192
+ const dbSizeValue = $('#dbSizeValue');
1193
+ if (dbSizeValue) dbSizeValue.textContent = result.sizeAfter?.display || 'N/A';
1194
+ }
1195
+ optimizeBtn.disabled = false;
1196
+ });
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
+ });
763
1223
  }
764
1224
 
765
1225
  async function viewFiles() {
1226
+ clearJumpUi();
766
1227
  window._lastView = 'files';
767
- content.innerHTML = '<div class="loading">Loading</div>';
1228
+ content.innerHTML = `<div class="page-title">Files</div>${skeletonRows(6, 'file')}`;
1229
+ transitionView();
768
1230
  const data = await api('/files?limit=500');
769
1231
  if (data._error) {
770
1232
  content.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
@@ -933,6 +1395,7 @@ function renderFileItem(f) {
933
1395
  }
934
1396
 
935
1397
  async function viewFileDetail(filePath) {
1398
+ clearJumpUi();
936
1399
  content.innerHTML = '<div class="loading">Loading</div>';
937
1400
  const data = await api(`/files/sessions?path=${encodeURIComponent(filePath)}`);
938
1401
  if (data._error) {
@@ -959,16 +1422,18 @@ async function viewFileDetail(filePath) {
959
1422
  // --- Navigation ---
960
1423
  window._searchType = '';
961
1424
  window._searchRole = '';
962
- window._lastView = 'sessions';
1425
+ window._lastView = 'overview';
963
1426
 
964
1427
  $$('.nav-item').forEach(item => {
965
1428
  item.addEventListener('click', () => {
966
1429
  if (window._sseCleanup) { window._sseCleanup(); window._sseCleanup = null; }
1430
+ if (window._timelineScrollHandler) { window.removeEventListener('scroll', window._timelineScrollHandler); window._timelineScrollHandler = null; }
1431
+ if (window._timelineSse) { window._timelineSse.close(); window._timelineSse = null; }
967
1432
  const view = item.dataset.view;
968
1433
  window._lastView = view;
969
1434
  updateNavActive(view);
970
1435
  setHash(view);
971
- if (view === 'search') viewSearch();
1436
+ if (view === 'overview') viewOverview();
972
1437
  else if (view === 'sessions') viewSessions();
973
1438
  else if (view === 'files') viewFiles();
974
1439
  else if (view === 'timeline') viewTimeline();
@@ -976,6 +1441,259 @@ $$('.nav-item').forEach(item => {
976
1441
  });
977
1442
  });
978
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
+
1683
+ initTheme();
1684
+ document.getElementById('theme-toggle')?.addEventListener('click', toggleTheme);
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
+ });
979
1697
  handleRoute();
980
1698
 
981
1699
  // Swipe right from left edge to go back