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 +90 -3
- package/indexer.js +27 -20
- package/package.json +1 -1
- package/public/app.js +175 -13
- package/public/style.css +46 -0
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
|
-
|
|
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)
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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
|
-
|
|
447
|
-
|
|
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
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
833
|
-
|
|
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
|
+
}
|