agentacta 1.3.3 → 1.4.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,31 @@ 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';
7
+
8
+ function applyTheme(theme) {
9
+ document.documentElement.setAttribute('data-theme', theme);
10
+ const meta = document.querySelector('meta[name="theme-color"]');
11
+ if (meta) meta.setAttribute('content', theme === 'light' ? '#f5f7fb' : '#0a0e1a');
12
+ }
13
+
14
+ function initTheme() {
15
+ const saved = localStorage.getItem(THEME_KEY);
16
+ const theme = saved === 'dark' || saved === 'light' ? saved : 'light';
17
+ applyTheme(theme);
18
+ }
19
+
20
+ 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);
25
+ }
26
+
27
+ async function api(path, options = {}) {
7
28
  let res;
8
29
  try {
9
- res = await fetch(API + path);
30
+ res = await fetch(API + path, options);
10
31
  } catch (err) {
11
32
  // Network error (server down, offline, etc.)
12
33
  return { _error: true, error: 'Network error' };
@@ -71,6 +92,26 @@ function escHtml(s) {
71
92
  return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
72
93
  }
73
94
 
95
+ function fmtToolName(name) {
96
+ if (!name) return '';
97
+ // MCP tools: mcp__provider__action → mcp_provider_action
98
+ const mcp = name.match(/^mcp__(.+?)__(.+)$/);
99
+ if (mcp) {
100
+ const provider = mcp[1].replace(/__/g, '_');
101
+ const action = mcp[2].replace(/__/g, '_');
102
+ return `mcp_${provider}_${action}`;
103
+ }
104
+ return name;
105
+ }
106
+
107
+ function fmtToolGroup(name) {
108
+ if (!name) return '';
109
+ // MCP tools: collapse to mcp_provider (no action)
110
+ const mcp = name.match(/^mcp__(.+?)__/);
111
+ if (mcp) return 'mcp_' + mcp[1].replace(/__/g, '_');
112
+ return name;
113
+ }
114
+
74
115
  function truncate(s, n = 200) {
75
116
  if (!s) return '';
76
117
  return s.length > n ? s.slice(0, n) + '\u2026' : s;
@@ -95,6 +136,48 @@ function transitionView() {
95
136
  content.classList.add('view-enter');
96
137
  }
97
138
 
139
+ function skeletonLine(width = '100%', height = '12px') {
140
+ return `<div class="skeleton-line" style="width:${width};height:${height}"></div>`;
141
+ }
142
+
143
+ function skeletonRows(count = 6, kind = 'event') {
144
+ if (kind === 'session') {
145
+ return Array.from({ length: count }).map(() => `
146
+ <div class="session-item skeleton-card">
147
+ <div class="skeleton-line" style="width:58%;height:12px"></div>
148
+ <div class="skeleton-line" style="width:90%;height:14px"></div>
149
+ <div class="skeleton-line" style="width:72%;height:14px"></div>
150
+ </div>
151
+ `).join('');
152
+ }
153
+ if (kind === 'stats') {
154
+ return Array.from({ length: count }).map(() => `
155
+ <div class="stat-card skeleton-card">
156
+ <div class="skeleton-line" style="width:44%;height:10px"></div>
157
+ <div class="skeleton-line" style="width:66%;height:28px;margin-top:8px"></div>
158
+ </div>
159
+ `).join('');
160
+ }
161
+ if (kind === 'file') {
162
+ return Array.from({ length: count }).map(() => `
163
+ <div class="file-item skeleton-card">
164
+ <div class="skeleton-line" style="width:62%;height:13px"></div>
165
+ <div class="skeleton-line" style="width:86%;height:12px;margin-top:10px"></div>
166
+ </div>
167
+ `).join('');
168
+ }
169
+ return Array.from({ length: count }).map(() => `
170
+ <div class="event-item skeleton-row">
171
+ <div class="skeleton-line" style="width:72px;height:10px"></div>
172
+ <div class="skeleton-line" style="width:60px;height:16px"></div>
173
+ <div class="event-body">
174
+ <div class="skeleton-line" style="width:82%;height:12px"></div>
175
+ <div class="skeleton-line" style="width:66%;height:12px;margin-top:8px"></div>
176
+ </div>
177
+ </div>
178
+ `).join('');
179
+ }
180
+
98
181
  // --- Hash routing ---
99
182
  window._navDepth = 0;
100
183
 
@@ -118,6 +201,7 @@ function updateNavActive(view) {
118
201
  function handleRoute() {
119
202
  const raw = (window.location.hash || '').slice(1) || 'search';
120
203
  if (window._sseCleanup) { window._sseCleanup(); window._sseCleanup = null; }
204
+ if (window._timelineScrollHandler) { window.removeEventListener('scroll', window._timelineScrollHandler); window._timelineScrollHandler = null; }
121
205
 
122
206
  if (raw.startsWith('session/')) {
123
207
  const id = decodeURIComponent(raw.slice('session/'.length));
@@ -144,7 +228,7 @@ function renderEvent(ev) {
144
228
  let body = '';
145
229
 
146
230
  if (ev.type === 'tool_call') {
147
- body = `<span class="tool-name">${escHtml(ev.tool_name)}</span>`;
231
+ body = `<span class="tool-name">${escHtml(fmtToolName(ev.tool_name))}</span>`;
148
232
  if (ev.tool_args) {
149
233
  try {
150
234
  const args = JSON.parse(ev.tool_args);
@@ -154,7 +238,7 @@ function renderEvent(ev) {
154
238
  }
155
239
  }
156
240
  } else if (ev.type === 'tool_result') {
157
- body = `<span class="tool-name">\u2192 ${escHtml(ev.tool_name)}</span>`;
241
+ body = `<span class="tool-name">\u2192 ${escHtml(fmtToolName(ev.tool_name))}</span>`;
158
242
  if (ev.content) {
159
243
  body += `<div class="tool-args">${escHtml(truncate(ev.content, 500))}</div>`;
160
244
  }
@@ -178,7 +262,7 @@ function renderTimelineEvent(ev) {
178
262
  let body = '';
179
263
 
180
264
  if (ev.type === 'tool_call') {
181
- body = `<span class="tool-name">${escHtml(ev.tool_name)}</span>`;
265
+ body = `<span class="tool-name">${escHtml(fmtToolName(ev.tool_name))}</span>`;
182
266
  if (ev.tool_args) {
183
267
  try {
184
268
  const args = JSON.parse(ev.tool_args);
@@ -188,7 +272,7 @@ function renderTimelineEvent(ev) {
188
272
  }
189
273
  }
190
274
  } else if (ev.type === 'tool_result') {
191
- body = `<span class="tool-name">\u2192 ${escHtml(ev.tool_name)}</span>`;
275
+ body = `<span class="tool-name">\u2192 ${escHtml(fmtToolName(ev.tool_name))}</span>`;
192
276
  if (ev.content) {
193
277
  body += `<div class="tool-args">${escHtml(truncate(ev.content, 500))}</div>`;
194
278
  }
@@ -226,12 +310,20 @@ function normalizeAgentLabel(a) {
226
310
  return a;
227
311
  }
228
312
 
313
+ function isInternalProjectTag(tag) {
314
+ if (!tag) return true;
315
+ if (tag.startsWith('agent:')) return true;
316
+ if (tag.startsWith('claude:')) return true;
317
+ return false;
318
+ }
319
+
229
320
  function renderProjectTags(s) {
230
321
  let projects = [];
231
322
  if (s.projects) {
232
323
  try { projects = JSON.parse(s.projects); } catch {}
233
324
  }
234
- return projects.map(p => `<span class="session-project">${escHtml(p)}</span>`).join('');
325
+ const visible = [...new Set(projects)].filter(p => !isInternalProjectTag(p));
326
+ return visible.map(p => `<span class="session-project">${escHtml(p)}</span>`).join('');
235
327
  }
236
328
 
237
329
  function renderModelTags(s) {
@@ -246,6 +338,8 @@ function renderModelTags(s) {
246
338
  function renderSessionItem(s) {
247
339
  const duration = fmtDuration(s.start_time, s.end_time);
248
340
  const timeRange = `${fmtTime(s.start_time)} \u2192 ${s.end_time ? fmtTimeOnly(s.end_time) : 'now'}`;
341
+ const isSubagent = s.session_type === 'subagent';
342
+ const showAgentTag = s.agent && s.agent !== 'main' && !isSubagent;
249
343
 
250
344
  return `
251
345
  <div class="session-item" data-id="${s.id}">
@@ -253,8 +347,8 @@ function renderSessionItem(s) {
253
347
  <span class="session-time">${timeRange} \u00b7 ${duration}</span>
254
348
  <span style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
255
349
  ${renderProjectTags(s)}
256
- ${s.agent && s.agent !== 'main' ? `<span class="session-agent">${escHtml(normalizeAgentLabel(s.agent))}</span>` : ''}
257
- ${s.session_type ? `<span class="session-type">${escHtml(s.session_type)}</span>` : ''}
350
+ ${showAgentTag ? `<span class="session-agent">${escHtml(normalizeAgentLabel(s.agent))}</span>` : ''}
351
+ ${s.session_type && s.session_type !== normalizeAgentLabel(s.agent || '') ? `<span class="session-type">${escHtml(s.session_type)}</span>` : ''}
258
352
  ${renderModelTags(s)}
259
353
  </span>
260
354
  </div>
@@ -286,7 +380,12 @@ async function viewSearch(query = '') {
286
380
  <span class="filter-chip ${roleFilter==='user'?'active':''}" data-filter="role" data-val="user">User</span>
287
381
  <span class="filter-chip ${roleFilter==='assistant'?'active':''}" data-filter="role" data-val="assistant">Assistant</span>
288
382
  </div>
289
- <div id="results"></div>`;
383
+ <div id="results">
384
+ <div class="search-bar skeleton-card" style="margin-top:6px">
385
+ <div class="skeleton-line" style="height:16px;width:40%"></div>
386
+ </div>
387
+ ${skeletonRows(4, 'session')}
388
+ </div>`;
290
389
 
291
390
  content.innerHTML = html;
292
391
  transitionView();
@@ -315,7 +414,7 @@ async function viewSearch(query = '') {
315
414
 
316
415
  async function showSearchHome() {
317
416
  const el = $('#results');
318
- el.innerHTML = '<div class="loading">Loading</div>';
417
+ el.innerHTML = `${skeletonRows(4, 'session')}`;
319
418
 
320
419
  const stats = await api('/stats');
321
420
  const sessions = await api('/sessions?limit=5');
@@ -366,7 +465,7 @@ async function doSearch(q) {
366
465
  const el = $('#results');
367
466
  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; }
368
467
 
369
- el.innerHTML = '<div class="loading">Searching</div>';
468
+ el.innerHTML = `${skeletonRows(6, 'event')}`;
370
469
 
371
470
  const type = window._searchType || '';
372
471
  const role = window._searchRole || '';
@@ -392,7 +491,7 @@ async function doSearch(q) {
392
491
  <div class="result-meta">
393
492
  <span class="event-badge ${badgeClass(r.type, r.role)}">${r.type === 'tool_call' ? 'tool' : r.role || r.type}</span>
394
493
  <span class="session-time">${fmtTime(r.timestamp)}</span>
395
- ${r.tool_name ? `<span class="tool-name">${escHtml(r.tool_name)}</span>` : ''}
494
+ ${r.tool_name ? `<span class="tool-name">${escHtml(fmtToolName(r.tool_name))}</span>` : ''}
396
495
  <span class="session-link" data-session="${r.session_id}">view session \u2192</span>
397
496
  </div>
398
497
  <div class="result-content">${escHtml(truncate(r.content || r.tool_args || r.tool_result || '', 400))}</div>
@@ -410,7 +509,8 @@ async function doSearch(q) {
410
509
 
411
510
  async function viewSessions() {
412
511
  window._currentSessionId = null;
413
- content.innerHTML = '<div class="loading">Loading</div>';
512
+ content.innerHTML = `<div class="page-title">Sessions</div>${skeletonRows(4, 'session')}`;
513
+ transitionView();
414
514
  const data = await api('/sessions?limit=200');
415
515
  if (data._error) {
416
516
  content.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
@@ -432,6 +532,12 @@ async function viewSession(id) {
432
532
  window._currentSessionId = id;
433
533
  setHash('session/' + encodeURIComponent(id));
434
534
  window.scrollTo(0, 0);
535
+ content.innerHTML = `
536
+ <div class="back-btn">← Back</div>
537
+ <div class="page-title">Session</div>
538
+ ${skeletonRows(8, 'event')}
539
+ `;
540
+ transitionView();
435
541
  const data = await api(`/sessions/${id}`);
436
542
 
437
543
  if (data._error || data.error) { content.innerHTML = `<div class="empty"><h2>${escHtml(data.error || 'Unable to load')}</h2></div>`; return; }
@@ -459,7 +565,7 @@ async function viewSession(id) {
459
565
  <span style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
460
566
  ${renderProjectTags(s)}
461
567
  ${s.agent && s.agent !== 'main' ? `<span class="session-agent">${escHtml(normalizeAgentLabel(s.agent))}</span>` : ''}
462
- ${s.session_type ? `<span class="session-type">${escHtml(s.session_type)}</span>` : ''}
568
+ ${s.session_type && s.session_type !== normalizeAgentLabel(s.agent || '') ? `<span class="session-type">${escHtml(s.session_type)}</span>` : ''}
463
569
  ${renderModelTags(s)}
464
570
  </span>
465
571
  </div>
@@ -664,34 +770,93 @@ async function viewTimeline(date) {
664
770
  date = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
665
771
  }
666
772
  window._lastView = 'timeline';
773
+ window._timelineState = { date, limit: 100, offset: 0, hasMore: true, loading: false };
667
774
 
668
- let html = `<div class="page-title">Timeline</div>
775
+ content.innerHTML = `<div class="page-title">Timeline</div>
669
776
  <input type="date" class="date-input" id="dateInput" value="${date}">
670
- <div id="timelineContent"><div class="loading">Loading</div></div>`;
671
- content.innerHTML = html;
777
+ <div id="timelineContent">${skeletonRows(8, 'event')}</div>
778
+ <div id="timelineLoadMore" class="loading-more" style="display:none">Loading more…</div>`;
672
779
  transitionView();
673
780
 
674
- const data = await api(`/timeline?date=${date}`);
675
- if (data._error) {
676
- $('#timelineContent').innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
677
- return;
678
- }
679
781
  const el = $('#timelineContent');
782
+ const state = window._timelineState;
680
783
 
681
- if (!data.events.length) {
682
- el.innerHTML = '<div class="empty"><h2>No activity</h2><p>Nothing recorded on this day</p></div>';
683
- } else {
684
- el.innerHTML = `<div class="timeline-events-wrap">
685
- <div class="timeline-line"></div>
686
- ${data.events.map(renderTimelineEvent).join('')}
687
- </div>`;
784
+ async function loadTimelinePage(append = false) {
785
+ if (state.loading || (!state.hasMore && append)) return;
786
+ state.loading = true;
787
+ if (append) $('#timelineLoadMore').style.display = 'block';
788
+
789
+ const data = await api(`/timeline?date=${state.date}&limit=${state.limit}&offset=${state.offset}`);
790
+ state.loading = false;
791
+ $('#timelineLoadMore').style.display = 'none';
792
+
793
+ if (data._error) {
794
+ if (!append) el.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
795
+ return;
796
+ }
797
+
798
+ state.hasMore = !!data.hasMore;
799
+ state.offset += (data.events || []).length;
800
+
801
+ if (!append) {
802
+ if (!data.events.length) {
803
+ el.innerHTML = '<div class="empty"><h2>No activity</h2><p>Nothing recorded on this day</p></div>';
804
+ return;
805
+ }
806
+ el.innerHTML = `<div class="timeline-events-wrap" id="timelineWrap"><div class="timeline-line"></div>${data.events.map(renderTimelineEvent).join('')}</div>`;
807
+ return;
808
+ }
809
+
810
+ const wrap = $('#timelineWrap');
811
+ if (wrap) {
812
+ wrap.insertAdjacentHTML('beforeend', data.events.map(renderTimelineEvent).join(''));
813
+ }
688
814
  }
689
815
 
690
- $('#dateInput').addEventListener('change', e => viewTimeline(e.target.value));
816
+ await loadTimelinePage(false);
817
+
818
+ if (window._timelineScrollHandler) window.removeEventListener('scroll', window._timelineScrollHandler);
819
+ window._timelineScrollHandler = () => {
820
+ const st = window._timelineState;
821
+ if (!st || st.loading || !st.hasMore) return;
822
+ const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 300;
823
+ if (nearBottom) loadTimelinePage(true);
824
+ };
825
+ window.addEventListener('scroll', window._timelineScrollHandler, { passive: true });
826
+
827
+ // Live updates via SSE (only for today)
828
+ const today = new Date();
829
+ const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
830
+ if (window._timelineSse) { window._timelineSse.close(); window._timelineSse = null; }
831
+ if (date === todayStr) {
832
+ const sse = new EventSource(`/api/timeline/stream?after=${encodeURIComponent(new Date().toISOString())}`);
833
+ window._timelineSse = sse;
834
+ sse.onmessage = (evt) => {
835
+ try {
836
+ 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
+ });
846
+ }
847
+ } catch {}
848
+ };
849
+ }
850
+
851
+ $('#dateInput').addEventListener('change', e => {
852
+ if (window._timelineSse) { window._timelineSse.close(); window._timelineSse = null; }
853
+ viewTimeline(e.target.value);
854
+ });
691
855
  }
692
856
 
693
857
  async function viewStats() {
694
- content.innerHTML = '<div class="loading">Loading</div>';
858
+ content.innerHTML = `<div class="page-title">Stats</div><div class="stat-grid">${skeletonRows(5, 'stats')}</div>`;
859
+ transitionView();
695
860
  const data = await api('/stats');
696
861
  if (data._error) {
697
862
  content.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
@@ -703,14 +868,19 @@ async function viewStats() {
703
868
  <div class="stat-card accent-blue"><div class="label">Sessions</div><div class="value">${data.sessions}</div></div>
704
869
  <div class="stat-card accent-green"><div class="label">Messages</div><div class="value">${data.messages.toLocaleString()}</div></div>
705
870
  <div class="stat-card accent-amber"><div class="label">Tool Calls</div><div class="value">${data.toolCalls.toLocaleString()}</div></div>
706
- <div class="stat-card accent-purple"><div class="label">Unique Tools</div><div class="value">${data.uniqueTools}</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>
707
872
  <div class="stat-card accent-teal"><div class="label">Total Tokens</div><div class="value">${(data.totalTokens || 0).toLocaleString()}</div></div>
708
873
  </div>
709
874
 
710
875
  <div class="section-label">Configuration</div>
711
- <div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));gap:var(--space-md);margin-bottom:var(--space-xl)">
876
+ <div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));gap:var(--space-md);margin-bottom:var(--space-md)">
712
877
  <div class="config-card"><div class="config-label">Storage Mode</div><div class="config-value">${escHtml(data.storageMode || 'reference')}</div></div>
713
- <div class="config-card"><div class="config-label">DB Size</div><div class="config-value">${escHtml(data.dbSize?.display || 'N/A')}</div></div>
878
+ <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
+ </div>
880
+ <div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:var(--space-xl)">
881
+ <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>
714
884
  </div>
715
885
 
716
886
  ${data.sessionDirs && data.sessionDirs.length ? (() => {
@@ -745,16 +915,35 @@ async function viewStats() {
745
915
  <div class="section-label">Date Range</div>
746
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>
747
917
  <div class="section-label">Tools Used</div>
748
- <div class="tools-grid">${(data.tools||[]).filter(t => t).sort().map(t => `<span class="tool-chip">${escHtml(t)}</span>`).join('')}</div>
918
+ <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>
749
919
  `;
750
920
 
751
921
  content.innerHTML = html;
752
922
  transitionView();
923
+
924
+ const optimizeBtn = $('#optimizeDbBtn');
925
+ const optimizeStatus = $('#optimizeDbStatus');
926
+ if (optimizeBtn) {
927
+ optimizeBtn.addEventListener('click', async () => {
928
+ optimizeBtn.disabled = true;
929
+ optimizeStatus.textContent = 'Optimizing…';
930
+ const result = await api('/maintenance', { method: 'POST' });
931
+ if (result._error || !result.ok) {
932
+ optimizeStatus.textContent = `Failed: ${result.error || 'Unknown error'}`;
933
+ } else {
934
+ optimizeStatus.textContent = `${result.sizeBefore?.display || 'N/A'} → ${result.sizeAfter?.display || 'N/A'}`;
935
+ const dbSizeValue = $('#dbSizeValue');
936
+ if (dbSizeValue) dbSizeValue.textContent = result.sizeAfter?.display || 'N/A';
937
+ }
938
+ optimizeBtn.disabled = false;
939
+ });
940
+ }
753
941
  }
754
942
 
755
943
  async function viewFiles() {
756
944
  window._lastView = 'files';
757
- content.innerHTML = '<div class="loading">Loading</div>';
945
+ content.innerHTML = `<div class="page-title">Files</div>${skeletonRows(6, 'file')}`;
946
+ transitionView();
758
947
  const data = await api('/files?limit=500');
759
948
  if (data._error) {
760
949
  content.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
@@ -954,6 +1143,8 @@ window._lastView = 'sessions';
954
1143
  $$('.nav-item').forEach(item => {
955
1144
  item.addEventListener('click', () => {
956
1145
  if (window._sseCleanup) { window._sseCleanup(); window._sseCleanup = null; }
1146
+ if (window._timelineScrollHandler) { window.removeEventListener('scroll', window._timelineScrollHandler); window._timelineScrollHandler = null; }
1147
+ if (window._timelineSse) { window._timelineSse.close(); window._timelineSse = null; }
957
1148
  const view = item.dataset.view;
958
1149
  window._lastView = view;
959
1150
  updateNavActive(view);
@@ -966,6 +1157,9 @@ $$('.nav-item').forEach(item => {
966
1157
  });
967
1158
  });
968
1159
 
1160
+ initTheme();
1161
+ document.getElementById('theme-toggle')?.addEventListener('click', toggleTheme);
1162
+ document.getElementById('theme-toggle-mobile')?.addEventListener('click', toggleTheme);
969
1163
  handleRoute();
970
1164
 
971
1165
  // Swipe right from left edge to go back
package/public/index.html CHANGED
@@ -21,6 +21,7 @@
21
21
  <nav class="sidebar">
22
22
  <div class="sidebar-header">
23
23
  <h1>Agent<span>Acta</span></h1>
24
+ <button class="theme-toggle" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme"><svg class="theme-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg><svg class="theme-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></button>
24
25
  </div>
25
26
  <div class="nav-section">
26
27
  <div class="nav-item" data-view="sessions">
@@ -43,9 +44,12 @@
43
44
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 20V10M12 20V4M6 20v-6"/></svg>
44
45
  <span>Stats</span>
45
46
  </div>
47
+
46
48
  </div>
47
49
  </nav>
48
50
  <main class="main" id="content"></main>
51
+ <button class="theme-toggle-mobile" id="theme-toggle-mobile" title="Toggle theme" aria-label="Toggle theme"><svg class="theme-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg><svg class="theme-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></button>
52
+
49
53
  </div>
50
54
  <script src="/app.js"></script>
51
55
  <script>
package/public/style.css CHANGED
@@ -71,6 +71,47 @@
71
71
 
72
72
  /* Layout */
73
73
  --sidebar-width: 240px;
74
+ --input-scheme: dark;
75
+ }
76
+
77
+ [data-theme="light"] {
78
+ --bg-base: #f5f7fb;
79
+ --bg-surface: #ffffff;
80
+ --bg-elevated: #f8fafc;
81
+ --bg-hover: #eef2f7;
82
+ --bg-active: #e8edf6;
83
+
84
+ --border-subtle: rgba(15, 23, 42, 0.06);
85
+ --border-default: rgba(15, 23, 42, 0.1);
86
+ --border-hover: rgba(15, 23, 42, 0.16);
87
+ --border-focus: rgba(36, 89, 214, 0.45);
88
+
89
+ --text-primary: #111827;
90
+ --text-secondary: #5b6678;
91
+ --text-tertiary: #8793a7;
92
+ --text-inverse: #ffffff;
93
+
94
+ --accent: #2459d6;
95
+ --accent-soft: rgba(36, 89, 214, 0.12);
96
+ --accent-medium: rgba(36, 89, 214, 0.2);
97
+
98
+ --green: #158f69;
99
+ --green-soft: rgba(21, 143, 105, 0.12);
100
+
101
+ --purple: #7d62eb;
102
+ --purple-soft: rgba(125, 98, 235, 0.12);
103
+
104
+ --amber: #b7791f;
105
+ --amber-soft: rgba(183, 121, 31, 0.12);
106
+
107
+ --red: #c24141;
108
+ --red-soft: rgba(194, 65, 65, 0.12);
109
+
110
+ --teal: #0f9f9a;
111
+ --teal-soft: rgba(15, 159, 154, 0.12);
112
+
113
+ --shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
114
+ --input-scheme: light;
74
115
  }
75
116
 
76
117
  /* ---- Reset ---- */
@@ -125,6 +166,10 @@ body {
125
166
 
126
167
  .sidebar-header {
127
168
  padding: var(--space-xl) var(--space-xl) var(--space-lg);
169
+ display: flex;
170
+ align-items: center;
171
+ justify-content: space-between;
172
+ gap: var(--space-sm);
128
173
  }
129
174
 
130
175
  .sidebar h1 {
@@ -141,6 +186,63 @@ body {
141
186
  background-clip: text;
142
187
  }
143
188
 
189
+ .theme-toggle {
190
+ width: 30px;
191
+ height: 30px;
192
+ border-radius: var(--radius-md);
193
+ border: 1px solid var(--border-default);
194
+ background: var(--bg-elevated);
195
+ color: var(--text-secondary);
196
+ display: inline-flex;
197
+ align-items: center;
198
+ justify-content: center;
199
+ cursor: pointer;
200
+ padding: 0;
201
+ }
202
+
203
+ .theme-toggle:hover {
204
+ background: var(--bg-hover);
205
+ color: var(--text-primary);
206
+ }
207
+
208
+ .theme-toggle svg,
209
+ .theme-toggle-mobile svg {
210
+ width: 16px;
211
+ height: 16px;
212
+ }
213
+
214
+ .theme-icon-moon { display: none; }
215
+ [data-theme="light"] .theme-icon-sun { display: none; }
216
+ [data-theme="light"] .theme-icon-moon { display: block; }
217
+
218
+ .theme-toggle-mobile {
219
+ display: none;
220
+ width: 32px;
221
+ height: 32px;
222
+ border-radius: var(--radius-md);
223
+ border: 1px solid var(--border-default);
224
+ background: var(--bg-surface);
225
+ color: var(--text-secondary);
226
+ align-items: center;
227
+ justify-content: center;
228
+ cursor: pointer;
229
+ padding: 0;
230
+ position: absolute;
231
+ top: calc(var(--space-xl) + env(safe-area-inset-top, 0px));
232
+ right: var(--space-lg);
233
+ z-index: 10;
234
+ }
235
+
236
+ .theme-toggle-mobile:hover {
237
+ background: var(--bg-hover);
238
+ color: var(--text-primary);
239
+ }
240
+
241
+ .theme-toggle-mobile svg {
242
+ width: 16px;
243
+ height: 16px;
244
+ }
245
+
144
246
  .nav-section {
145
247
  display: flex;
146
248
  flex-direction: column;
@@ -203,7 +305,7 @@ body {
203
305
  flex: 1;
204
306
  margin-left: var(--sidebar-width);
205
307
  padding: var(--space-2xl) var(--space-3xl);
206
- max-width: 1000px;
308
+ max-width: none;
207
309
  min-height: 100vh;
208
310
  }
209
311
 
@@ -405,8 +507,8 @@ body {
405
507
  .session-project {
406
508
  font-size: 11px;
407
509
  font-weight: 500;
408
- color: var(--green);
409
- background: var(--green-soft);
510
+ color: #7fb4ff;
511
+ background: rgba(39, 94, 182, 0.18);
410
512
  padding: 2px 10px;
411
513
  border-radius: 10px;
412
514
  }
@@ -655,6 +757,40 @@ mark {
655
757
  font-weight: 500;
656
758
  }
657
759
 
760
+ /* ---- Skeletons ---- */
761
+ .skeleton-card,
762
+ .skeleton-row {
763
+ border-color: var(--border-subtle);
764
+ }
765
+
766
+ .skeleton-line {
767
+ position: relative;
768
+ border-radius: var(--radius-sm);
769
+ background: var(--bg-elevated);
770
+ border: 1px solid var(--border-subtle);
771
+ overflow: hidden;
772
+ }
773
+
774
+ .skeleton-line::after {
775
+ content: '';
776
+ position: absolute;
777
+ inset: 0;
778
+ transform: translateX(-100%);
779
+ background: linear-gradient(90deg, transparent, var(--bg-hover), transparent);
780
+ animation: skeletonShimmer 1.4s ease-in-out infinite;
781
+ }
782
+
783
+ @keyframes skeletonShimmer {
784
+ 100% { transform: translateX(100%); }
785
+ }
786
+
787
+ .loading-more {
788
+ text-align: center;
789
+ color: var(--text-tertiary);
790
+ font-size: 12px;
791
+ padding: var(--space-md) 0;
792
+ }
793
+
658
794
  @keyframes pulse {
659
795
  0%, 100% { opacity: 0.4; }
660
796
  50% { opacity: 1; }
@@ -683,7 +819,7 @@ mark {
683
819
  outline: none;
684
820
  margin-bottom: var(--space-lg);
685
821
  transition: all var(--duration-normal) var(--ease-out);
686
- color-scheme: dark;
822
+ color-scheme: var(--input-scheme);
687
823
  }
688
824
 
689
825
  .date-input:hover {
@@ -1230,6 +1366,10 @@ mark {
1230
1366
 
1231
1367
  .sidebar-header { display: none; }
1232
1368
 
1369
+ .theme-toggle-mobile {
1370
+ display: flex;
1371
+ }
1372
+
1233
1373
  .nav-section {
1234
1374
  display: flex;
1235
1375
  flex-direction: row;