agentacta 1.2.1 → 1.3.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/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  const http = require('http');
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const { EventEmitter } = require('events');
5
6
 
6
7
  // --demo flag: use demo session data (must run before config load)
7
8
  if (process.argv.includes('--demo')) {
@@ -129,6 +130,10 @@ const db = open();
129
130
  // Live re-indexing setup
130
131
  const stmts = createStmts(db);
131
132
 
133
+ // SSE emitter: notifies connected clients when a session is re-indexed
134
+ const sseEmitter = new EventEmitter();
135
+ sseEmitter.setMaxListeners(100);
136
+
132
137
  const sessionDirs = discoverSessionDirs(config);
133
138
 
134
139
  // Initial indexing pass
@@ -146,20 +151,31 @@ for (const dir of sessionDirs) {
146
151
 
147
152
  console.log(`Watching ${sessionDirs.length} session directories`);
148
153
 
154
+ // Debounce map: filePath -> timeout handle
155
+ const _reindexTimers = new Map();
156
+ const REINDEX_DEBOUNCE_MS = 2000;
157
+
149
158
  for (const dir of sessionDirs) {
150
159
  try {
151
160
  fs.watch(dir.path, { persistent: false }, (eventType, filename) => {
152
161
  if (!filename || !filename.endsWith('.jsonl')) return;
153
162
  const filePath = path.join(dir.path, filename);
154
163
  if (!fs.existsSync(filePath)) return;
155
- setTimeout(() => {
164
+
165
+ // Debounce: cancel pending re-index for this file, schedule a new one
166
+ if (_reindexTimers.has(filePath)) clearTimeout(_reindexTimers.get(filePath));
167
+ _reindexTimers.set(filePath, setTimeout(() => {
168
+ _reindexTimers.delete(filePath);
156
169
  try {
157
170
  const result = indexFile(db, filePath, dir.agent, stmts, ARCHIVE_MODE);
158
- if (!result.skipped) console.log(`Live re-indexed: ${filename} (${dir.agent})`);
171
+ if (!result.skipped) {
172
+ console.log(`Live re-indexed: ${filename} (${dir.agent})`);
173
+ if (result.sessionId) sseEmitter.emit('session-update', result.sessionId);
174
+ }
159
175
  } catch (err) {
160
176
  console.error(`Error re-indexing ${filename}:`, err.message);
161
177
  }
162
- }, 500);
178
+ }, REINDEX_DEBOUNCE_MS));
163
179
  });
164
180
  console.log(` Watching: ${dir.path}`);
165
181
  } catch (err) {
@@ -244,6 +260,66 @@ const server = http.createServer((req, res) => {
244
260
  const total = agent ? db.prepare(countSql).get(agent).c : db.prepare(countSql).get().c;
245
261
  json(res, { sessions: rows, total, limit, offset });
246
262
  }
263
+
264
+ else if (pathname.match(/^\/api\/sessions\/[^/]+\/events$/)) {
265
+ const id = pathname.split('/')[3];
266
+ const session = db.prepare('SELECT id FROM sessions WHERE id = ?').get(id);
267
+ if (!session) return json(res, { error: 'Not found' }, 404);
268
+
269
+ const after = query.after || '1970-01-01T00:00:00.000Z';
270
+ const afterId = query.afterId || '';
271
+ const limit = Math.min(parseInt(query.limit || '50', 10) || 50, 200);
272
+ const rows = db.prepare(
273
+ `SELECT * FROM events
274
+ WHERE session_id = ?
275
+ AND (timestamp > ? OR (timestamp = ? AND id > ?))
276
+ ORDER BY timestamp ASC, id ASC
277
+ LIMIT ?`
278
+ ).all(id, after, after, afterId, limit);
279
+ json(res, { events: rows, after, afterId, count: rows.length });
280
+ }
281
+
282
+ else if (pathname.match(/^\/api\/sessions\/[^/]+\/stream$/)) {
283
+ const id = pathname.split('/')[3];
284
+ const session = db.prepare('SELECT id FROM sessions WHERE id = ?').get(id);
285
+ if (!session) return json(res, { error: 'Not found' }, 404);
286
+
287
+ res.writeHead(200, {
288
+ 'Content-Type': 'text/event-stream',
289
+ 'Cache-Control': 'no-cache',
290
+ 'Connection': 'keep-alive',
291
+ 'X-Accel-Buffering': 'no'
292
+ });
293
+ res.write(': connected\n\n');
294
+
295
+ let lastTs = req.headers['last-event-id'] || query.after || new Date().toISOString();
296
+
297
+ const onUpdate = (sessionId) => {
298
+ if (sessionId !== id) return;
299
+ try {
300
+ const rows = db.prepare(
301
+ 'SELECT * FROM events WHERE session_id = ? AND timestamp > ? ORDER BY timestamp ASC'
302
+ ).all(id, lastTs);
303
+ if (rows.length) {
304
+ lastTs = rows[rows.length - 1].timestamp;
305
+ res.write(`id: ${lastTs}\ndata: ${JSON.stringify(rows)}\n\n`);
306
+ }
307
+ } catch (err) {
308
+ console.error('SSE query error:', err.message);
309
+ }
310
+ };
311
+
312
+ sseEmitter.on('session-update', onUpdate);
313
+
314
+ const ping = setInterval(() => {
315
+ try { res.write(': ping\n\n'); } catch {}
316
+ }, 30000);
317
+
318
+ req.on('close', () => {
319
+ sseEmitter.off('session-update', onUpdate);
320
+ clearInterval(ping);
321
+ });
322
+ }
247
323
  else if (pathname.match(/^\/api\/sessions\/[^/]+$/) && !pathname.includes('export')) {
248
324
  const id = pathname.split('/')[3];
249
325
  const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
@@ -403,6 +479,17 @@ const server = http.createServer((req, res) => {
403
479
  });
404
480
 
405
481
  const HOST = process.env.AGENTACTA_HOST || '127.0.0.1';
482
+ server.on('error', (err) => {
483
+ if (err.code === 'EADDRINUSE') {
484
+ console.error(`Port ${PORT} in use, retrying in 2s...`);
485
+ setTimeout(() => {
486
+ server.close();
487
+ server.listen(PORT, HOST);
488
+ }, 2000);
489
+ } else {
490
+ throw err;
491
+ }
492
+ });
406
493
  server.listen(PORT, HOST, () => console.log(`AgentActa running on http://${HOST}:${PORT}`));
407
494
 
408
495
  // Graceful shutdown
package/indexer.js CHANGED
@@ -208,11 +208,7 @@ function indexFile(db, filePath, agentName, stmts, archiveMode, config) {
208
208
  return { skipped: true };
209
209
  }
210
210
 
211
- stmts.deleteEvents.run(sessionId);
212
- stmts.deleteSession.run(sessionId);
213
- stmts.deleteFileActivity.run(sessionId);
214
- if (stmts.deleteArchive) stmts.deleteArchive.run(sessionId);
215
-
211
+ // --- Parse the entire file BEFORE any DB operations ---
216
212
  const pendingEvents = [];
217
213
  const fileActivities = [];
218
214
  const projectCounts = new Map();
@@ -338,11 +334,8 @@ function indexFile(db, filePath, agentName, stmts, archiveMode, config) {
338
334
  }
339
335
  if (!sessionType && !initialPrompt) sessionType = 'heartbeat';
340
336
  // Detect subagent: task-style prompts injected by sessions_spawn
341
- // These typically start with a date/time stamp (e.g. "[Wed 2026-...")
342
- // But exclude System Messages (cron announcements injected into main session)
343
337
  if (!sessionType && initialPrompt) {
344
338
  const p = initialPrompt.trim();
345
- // Sub-agent prompts start with "[Wed 2026-..." but NOT "[... [System Message]"
346
339
  if (/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-/.test(p) && !p.includes('[System Message]')) {
347
340
  sessionType = 'subagent';
348
341
  }
@@ -354,18 +347,28 @@ function indexFile(db, filePath, agentName, stmts, archiveMode, config) {
354
347
  .map(([name]) => name);
355
348
  const projectsJson = projects.length > 0 ? JSON.stringify(projects) : null;
356
349
 
357
- stmts.upsertSession.run(sessionId, sessionStart, sessionEnd, msgCount, toolCount, model, summary, agent, sessionType, totalCost, totalTokens, totalInputTokens, totalOutputTokens, totalCacheReadTokens, totalCacheWriteTokens, initialPrompt, firstMessageId, firstMessageTimestamp, modelsJson, projectsJson);
358
- for (const ev of pendingEvents) stmts.insertEvent.run(...ev);
359
- for (const fa of fileActivities) stmts.insertFileActivity.run(...fa);
360
-
361
- // Archive mode: store raw JSONL lines
362
- if (archiveMode && stmts.insertArchive) {
363
- for (let i = 0; i < lines.length; i++) {
364
- stmts.insertArchive.run(sessionId, i + 1, lines[i]);
350
+ // --- All DB operations in a single transaction for atomicity ---
351
+ const commitIndex = db.transaction(() => {
352
+ stmts.deleteEvents.run(sessionId);
353
+ stmts.deleteFileActivity.run(sessionId);
354
+ if (stmts.deleteArchive) stmts.deleteArchive.run(sessionId);
355
+ stmts.deleteSession.run(sessionId);
356
+
357
+ stmts.upsertSession.run(sessionId, sessionStart, sessionEnd, msgCount, toolCount, model, summary, agent, sessionType, totalCost, totalTokens, totalInputTokens, totalOutputTokens, totalCacheReadTokens, totalCacheWriteTokens, initialPrompt, firstMessageId, firstMessageTimestamp, modelsJson, projectsJson);
358
+ for (const ev of pendingEvents) stmts.insertEvent.run(...ev);
359
+ for (const fa of fileActivities) stmts.insertFileActivity.run(...fa);
360
+
361
+ // Archive mode: store raw JSONL lines
362
+ if (archiveMode && stmts.insertArchive) {
363
+ for (let i = 0; i < lines.length; i++) {
364
+ stmts.insertArchive.run(sessionId, i + 1, lines[i]);
365
+ }
365
366
  }
366
- }
367
367
 
368
- stmts.upsertState.run(filePath, lines.length, mtime);
368
+ stmts.upsertState.run(filePath, lines.length, mtime);
369
+ });
370
+
371
+ commitIndex();
369
372
 
370
373
  return { sessionId, msgCount, toolCount };
371
374
  }
@@ -443,8 +446,12 @@ function indexAll(db, config) {
443
446
  for (const dir of sessionDirs) {
444
447
  const files = fs.readdirSync(dir.path).filter(f => f.endsWith('.jsonl'));
445
448
  for (const file of files) {
446
- const result = indexFile(db, path.join(dir.path, file), dir.agent, stmts, archiveMode, config);
447
- if (!result.skipped) totalSessions++;
449
+ try {
450
+ const result = indexFile(db, path.join(dir.path, file), dir.agent, stmts, archiveMode, config);
451
+ if (!result.skipped) totalSessions++;
452
+ } catch (err) {
453
+ console.error(`Error indexing ${file}:`, err.message);
454
+ }
448
455
  }
449
456
  }
450
457
  const stats = db.prepare('SELECT COUNT(*) as sessions FROM sessions').get();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentacta",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Audit trail and search engine for AI agent sessions",
5
5
  "main": "index.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -4,8 +4,26 @@ const content = $('#content');
4
4
  const API = '/api';
5
5
 
6
6
  async function api(path) {
7
- const res = await fetch(API + path);
8
- return res.json();
7
+ let res;
8
+ try {
9
+ res = await fetch(API + path);
10
+ } catch (err) {
11
+ // Network error (server down, offline, etc.)
12
+ return { _error: true, error: 'Network error' };
13
+ }
14
+ if (!res.ok) {
15
+ try {
16
+ const body = await res.json();
17
+ return { _error: true, error: body.error || `HTTP ${res.status}`, status: res.status };
18
+ } catch {
19
+ return { _error: true, error: `HTTP ${res.status}`, status: res.status };
20
+ }
21
+ }
22
+ try {
23
+ return await res.json();
24
+ } catch {
25
+ return { _error: true, error: 'Invalid JSON response' };
26
+ }
9
27
  }
10
28
 
11
29
  function fmtTime(ts) {
@@ -256,6 +274,10 @@ async function showSearchHome() {
256
274
 
257
275
  const stats = await api('/stats');
258
276
  const sessions = await api('/sessions?limit=5');
277
+ if (stats._error || sessions._error) {
278
+ el.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
279
+ return;
280
+ }
259
281
 
260
282
  let suggestions = [];
261
283
  try { const r = await fetch('/api/suggestions'); const d = await r.json(); suggestions = d.suggestions || []; } catch(e) { suggestions = []; }
@@ -309,7 +331,7 @@ async function doSearch(q) {
309
331
 
310
332
  const data = await api(url);
311
333
 
312
- if (data.error) { el.innerHTML = `<div class="empty"><p>${escHtml(data.error)}</p></div>`; return; }
334
+ if (data._error || data.error) { el.innerHTML = `<div class="empty"><p>${escHtml(data.error || 'Server error')}</p></div>`; return; }
313
335
  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; }
314
336
 
315
337
  let header = `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-md)">
@@ -345,6 +367,10 @@ async function viewSessions() {
345
367
  window._currentSessionId = null;
346
368
  content.innerHTML = '<div class="loading">Loading</div>';
347
369
  const data = await api('/sessions?limit=200');
370
+ if (data._error) {
371
+ content.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
372
+ return;
373
+ }
348
374
 
349
375
  let html = `<div class="page-title">Sessions</div>`;
350
376
  html += data.sessions.map(renderSessionItem).join('');
@@ -357,11 +383,12 @@ async function viewSessions() {
357
383
  }
358
384
 
359
385
  async function viewSession(id) {
386
+ if (window._sseCleanup) { window._sseCleanup(); window._sseCleanup = null; }
360
387
  window._currentSessionId = id;
361
- content.innerHTML = '<div class="loading">Loading</div>';
388
+ window.scrollTo(0, 0);
362
389
  const data = await api(`/sessions/${id}`);
363
390
 
364
- if (data.error) { content.innerHTML = `<div class="empty"><h2>${data.error}</h2></div>`; return; }
391
+ if (data._error || data.error) { content.innerHTML = `<div class="empty"><h2>${escHtml(data.error || 'Unable to load')}</h2></div>`; return; }
365
392
 
366
393
  const s = data.session;
367
394
  const cost = fmtCost(s.total_cost);
@@ -399,11 +426,51 @@ async function viewSession(id) {
399
426
  <div class="section-label">Events</div>
400
427
  `;
401
428
 
402
- html += data.events.map(renderEvent).join('');
429
+ const PAGE_SIZE = 50;
430
+ const allEvents = data.events;
431
+ let rendered = 0;
432
+
433
+ function renderBatch() {
434
+ const batch = allEvents.slice(rendered, rendered + PAGE_SIZE);
435
+ if (!batch.length) return;
436
+ const frag = document.createElement('div');
437
+ frag.innerHTML = batch.map(renderEvent).join('');
438
+ const container = document.getElementById('eventsContainer');
439
+ if (container) {
440
+ while (frag.firstChild) container.appendChild(frag.firstChild);
441
+ }
442
+ rendered += batch.length;
443
+
444
+ }
445
+
446
+ html += '<div id="eventsContainer">' + allEvents.slice(0, PAGE_SIZE).map(renderEvent).join('') + '</div>';
447
+ rendered = Math.min(PAGE_SIZE, allEvents.length);
403
448
  content.innerHTML = html;
404
449
  transitionView();
405
450
 
451
+ let onScroll = null;
452
+ if (allEvents.length > PAGE_SIZE) {
453
+ let loading = false;
454
+ onScroll = () => {
455
+ if (loading || rendered >= allEvents.length) return;
456
+ const scrollBottom = window.innerHeight + window.scrollY;
457
+ const threshold = document.body.offsetHeight - 300;
458
+ if (scrollBottom >= threshold) {
459
+ loading = true;
460
+ renderBatch();
461
+ loading = false;
462
+ if (rendered >= allEvents.length) {
463
+ window.removeEventListener('scroll', onScroll);
464
+ onScroll = null;
465
+ }
466
+ }
467
+ };
468
+ window.addEventListener('scroll', onScroll, { passive: true });
469
+ }
470
+
406
471
  $('#backBtn').addEventListener('click', () => {
472
+ if (onScroll) { window.removeEventListener('scroll', onScroll); onScroll = null; }
473
+ if (window._sseCleanup) { window._sseCleanup(); window._sseCleanup = null; }
407
474
  if (window._lastView === 'timeline') viewTimeline();
408
475
  else if (window._lastView === 'files') viewFiles();
409
476
  else if (window._lastView === 'search') viewSearch(window._lastSearchQuery || '');
@@ -452,6 +519,10 @@ async function viewSession(id) {
452
519
  const jumpBtn = $('#jumpToStartBtn');
453
520
  if (jumpBtn) {
454
521
  jumpBtn.addEventListener('click', () => {
522
+ // Load all remaining events to find the first message
523
+ while (rendered < allEvents.length) {
524
+ renderBatch();
525
+ }
455
526
  const firstMessage = document.querySelector(`[data-event-id="${s.first_message_id}"]`);
456
527
  if (firstMessage) {
457
528
  firstMessage.scrollIntoView({ behavior: 'smooth', block: 'center' });
@@ -462,6 +533,81 @@ async function viewSession(id) {
462
533
  }
463
534
  });
464
535
  }
536
+
537
+ // --- Lightweight realtime updates (polling fallback first) ---
538
+ const knownIds = new Set(allEvents.map(e => e.id));
539
+ let pendingNewCount = 0;
540
+
541
+ const applyIncomingEvents = (incoming) => {
542
+ const container = document.getElementById('eventsContainer');
543
+ if (!container || !incoming?.length) return;
544
+
545
+ const fresh = incoming.filter(e => !knownIds.has(e.id));
546
+ if (!fresh.length) return;
547
+ fresh.forEach(e => knownIds.add(e.id));
548
+
549
+ const isAtTop = window.scrollY < 100;
550
+ for (const ev of fresh) {
551
+ const div = document.createElement('div');
552
+ div.innerHTML = renderEvent(ev);
553
+ const el = div.firstElementChild;
554
+ el.classList.add('event-highlight');
555
+ container.insertBefore(el, container.firstChild);
556
+ setTimeout(() => el.classList.remove('event-highlight'), 2000);
557
+ }
558
+
559
+ if (!isAtTop) {
560
+ pendingNewCount += fresh.length;
561
+ let indicator = document.getElementById('newEventsIndicator');
562
+ if (!indicator) {
563
+ indicator = document.createElement('div');
564
+ indicator.id = 'newEventsIndicator';
565
+ indicator.className = 'new-events-indicator';
566
+ document.body.appendChild(indicator);
567
+ indicator.addEventListener('click', () => {
568
+ window.scrollTo({ top: 0, behavior: 'smooth' });
569
+ indicator.remove();
570
+ pendingNewCount = 0;
571
+ });
572
+ }
573
+ indicator.textContent = `${pendingNewCount} new event${pendingNewCount !== 1 ? 's' : ''} ↑`;
574
+ }
575
+ };
576
+
577
+ // Poll every 3s for new events using delta endpoint
578
+ let lastSeenTs = allEvents.length ? allEvents[0].timestamp : new Date(0).toISOString();
579
+ let lastSeenId = allEvents.length ? allEvents[0].id : '';
580
+ const pollNewEvents = async () => {
581
+ try {
582
+ const latest = await api(`/sessions/${id}/events?after=${encodeURIComponent(lastSeenTs)}&afterId=${encodeURIComponent(lastSeenId)}&limit=50`);
583
+ const incoming = latest.events || [];
584
+ if (incoming.length) {
585
+ const tail = incoming[incoming.length - 1];
586
+ lastSeenTs = tail.timestamp || lastSeenTs;
587
+ lastSeenId = tail.id || lastSeenId;
588
+ applyIncomingEvents(incoming);
589
+ }
590
+ } catch (err) {
591
+ // silent; next tick will retry
592
+ }
593
+ };
594
+
595
+ const pollInterval = setInterval(pollNewEvents, 3000);
596
+
597
+ const sseScrollHandler = () => {
598
+ if (window.scrollY < 100) {
599
+ const ind = document.getElementById('newEventsIndicator');
600
+ if (ind) { ind.remove(); pendingNewCount = 0; }
601
+ }
602
+ };
603
+ window.addEventListener('scroll', sseScrollHandler, { passive: true });
604
+
605
+ window._sseCleanup = () => {
606
+ clearInterval(pollInterval);
607
+ window.removeEventListener('scroll', sseScrollHandler);
608
+ const ind = document.getElementById('newEventsIndicator');
609
+ if (ind) ind.remove();
610
+ };
465
611
  }
466
612
 
467
613
  async function viewTimeline(date) {
@@ -478,6 +624,10 @@ async function viewTimeline(date) {
478
624
  transitionView();
479
625
 
480
626
  const data = await api(`/timeline?date=${date}`);
627
+ if (data._error) {
628
+ $('#timelineContent').innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
629
+ return;
630
+ }
481
631
  const el = $('#timelineContent');
482
632
 
483
633
  if (!data.events.length) {
@@ -495,6 +645,10 @@ async function viewTimeline(date) {
495
645
  async function viewStats() {
496
646
  content.innerHTML = '<div class="loading">Loading</div>';
497
647
  const data = await api('/stats');
648
+ if (data._error) {
649
+ content.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
650
+ return;
651
+ }
498
652
 
499
653
  let html = `<div class="page-title">Stats</div>
500
654
  <div class="stat-grid">
@@ -554,6 +708,10 @@ async function viewFiles() {
554
708
  window._lastView = 'files';
555
709
  content.innerHTML = '<div class="loading">Loading</div>';
556
710
  const data = await api('/files?limit=500');
711
+ if (data._error) {
712
+ content.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
713
+ return;
714
+ }
557
715
  window._allFiles = data.files || [];
558
716
  window._fileSort = window._fileSort || 'touches';
559
717
  window._fileFilter = window._fileFilter || '';
@@ -719,6 +877,10 @@ function renderFileItem(f) {
719
877
  async function viewFileDetail(filePath) {
720
878
  content.innerHTML = '<div class="loading">Loading</div>';
721
879
  const data = await api(`/files/sessions?path=${encodeURIComponent(filePath)}`);
880
+ if (data._error) {
881
+ content.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
882
+ return;
883
+ }
722
884
 
723
885
  let html = `
724
886
  <div class="back-btn" id="backBtn">\u2190 Back</div>
@@ -743,6 +905,7 @@ window._lastView = 'sessions';
743
905
 
744
906
  $$('.nav-item').forEach(item => {
745
907
  item.addEventListener('click', () => {
908
+ if (window._sseCleanup) { window._sseCleanup(); window._sseCleanup = null; }
746
909
  $$('.nav-item').forEach(i => i.classList.remove('active'));
747
910
  item.classList.add('active');
748
911
  const view = item.dataset.view;
@@ -794,6 +957,7 @@ viewSearch();
794
957
  (function initPTR() {
795
958
  let startY = 0;
796
959
  let pulling = false;
960
+ let refreshing = false;
797
961
  const threshold = 80;
798
962
 
799
963
  const indicator = document.createElement('div');
@@ -823,22 +987,20 @@ viewSearch();
823
987
  document.addEventListener('touchend', async e => {
824
988
  if (!pulling) return;
825
989
  pulling = false;
990
+ if (refreshing) { indicator.classList.remove('visible'); return; }
826
991
  const diff = e.changedTouches[0].clientY - startY;
827
992
  if (diff > threshold && indicator.classList.contains('visible')) {
993
+ refreshing = true;
828
994
  indicator.textContent = 'Refreshing\u2026';
829
995
  indicator.classList.add('refreshing');
830
996
  try {
831
997
  await api('/reindex');
832
- const backBtn = $('#backBtn');
833
- if (backBtn && window._currentSessionId) {
834
- await viewSession(window._currentSessionId);
835
- } else {
836
- const active = $('.nav-item.active');
837
- if (active) active.click();
838
- }
998
+ // Just reindex data without re-rendering the view
999
+ // The next manual navigation will pick up new data
839
1000
  } catch(err) {}
840
1001
  setTimeout(() => {
841
1002
  indicator.classList.remove('visible', 'refreshing');
1003
+ refreshing = false;
842
1004
  }, 500);
843
1005
  } else {
844
1006
  indicator.classList.remove('visible');
package/public/style.css CHANGED
@@ -1077,6 +1077,34 @@ mark {
1077
1077
  animation: highlightFade 2s var(--ease-out);
1078
1078
  }
1079
1079
 
1080
+ /* ---- New Events SSE Indicator ---- */
1081
+ .new-events-indicator {
1082
+ position: fixed;
1083
+ top: 20px;
1084
+ left: 50%;
1085
+ transform: translateX(-50%);
1086
+ background: var(--accent);
1087
+ color: #fff;
1088
+ padding: 8px 20px;
1089
+ border-radius: 20px;
1090
+ font-size: 12px;
1091
+ font-weight: 600;
1092
+ cursor: pointer;
1093
+ z-index: 90;
1094
+ box-shadow: 0 4px 16px rgba(99, 144, 240, 0.4);
1095
+ animation: slideDown 0.3s var(--ease-out);
1096
+ }
1097
+
1098
+ .new-events-indicator:hover {
1099
+ background: #4a7ae8;
1100
+ transform: translateX(-50%) scale(1.05);
1101
+ }
1102
+
1103
+ @keyframes slideDown {
1104
+ from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
1105
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
1106
+ }
1107
+
1080
1108
  /* ---- Search Home Stats ---- */
1081
1109
  .search-stats {
1082
1110
  display: grid;
@@ -1349,3 +1377,21 @@ mark {
1349
1377
  }
1350
1378
  }
1351
1379
 
1380
+
1381
+ /* Load more button */
1382
+ .load-more-btn {
1383
+ text-align: center;
1384
+ padding: var(--space-lg);
1385
+ margin: var(--space-md) 0;
1386
+ color: var(--accent);
1387
+ font-size: 13px;
1388
+ font-weight: 500;
1389
+ cursor: pointer;
1390
+ border: 1px solid var(--border-subtle);
1391
+ border-radius: var(--radius-md);
1392
+ transition: all var(--duration-normal) var(--ease-out);
1393
+ }
1394
+ .load-more-btn:hover {
1395
+ background: var(--bg-active);
1396
+ border-color: var(--border-hover);
1397
+ }