agentacta 1.0.0 → 1.1.1
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/README.md +6 -5
- package/indexer.js +81 -16
- package/package.json +1 -1
- package/public/app.js +73 -17
package/README.md
CHANGED
|
@@ -62,7 +62,7 @@ Open `http://localhost:4003` in your browser.
|
|
|
62
62
|
|
|
63
63
|
AgentActa automatically finds your sessions in:
|
|
64
64
|
- `~/.openclaw/agents/*/sessions/` (OpenClaw)
|
|
65
|
-
- `~/.claude/projects
|
|
65
|
+
- `~/.claude/projects/*/` (Claude Code)
|
|
66
66
|
|
|
67
67
|
Or point it at a custom path:
|
|
68
68
|
|
|
@@ -76,7 +76,7 @@ AGENTACTA_SESSIONS_PATH=/path/to/sessions agentacta
|
|
|
76
76
|
Full-text search powered by SQLite FTS5. Filter by message type (messages, tool calls, results) and role (user, assistant). Quick search suggestions are generated from your actual data — most-used tools, common topics, frequently touched files.
|
|
77
77
|
|
|
78
78
|
### Sessions
|
|
79
|
-
Browse all indexed sessions with auto-generated summaries, token breakdowns (output vs input), and model info. Click into any session to see the full event history, most recent first.
|
|
79
|
+
Browse all indexed sessions with auto-generated summaries, token breakdowns (output vs input), and model info. Sessions are automatically tagged by type — cron jobs, sub-agent tasks, and heartbeat sessions get distinct badges. Click into any session to see the full event history, most recent first.
|
|
80
80
|
|
|
81
81
|
### Timeline
|
|
82
82
|
Pick a date, see everything that happened. Messages, tool invocations, file changes — most recent first.
|
|
@@ -101,13 +101,14 @@ Data never leaves your machine.
|
|
|
101
101
|
|
|
102
102
|
## Configuration
|
|
103
103
|
|
|
104
|
-
On first run, AgentActa creates `agentacta.config.json`
|
|
104
|
+
On first run, AgentActa creates a config file with sensible defaults at `~/.config/agentacta/config.json` (or `agentacta.config.json` in the current directory if it exists):
|
|
105
105
|
|
|
106
106
|
```json
|
|
107
107
|
{
|
|
108
108
|
"port": 4003,
|
|
109
109
|
"storage": "reference",
|
|
110
|
-
"
|
|
110
|
+
"sessionsPath": null,
|
|
111
|
+
"dbPath": "./agentacta.db"
|
|
111
112
|
}
|
|
112
113
|
```
|
|
113
114
|
|
|
@@ -199,7 +200,7 @@ All data stays local. AgentActa runs entirely on your machine — no cloud servi
|
|
|
199
200
|
|
|
200
201
|
## Contributing
|
|
201
202
|
|
|
202
|
-
PRs welcome. If you're adding support for a new agent format, add a parser in `indexer.js` and open a PR.
|
|
203
|
+
PRs welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for dev setup and guidelines. If you're adding support for a new agent format, add a parser in `indexer.js` and open a PR.
|
|
203
204
|
|
|
204
205
|
## Etymology
|
|
205
206
|
|
package/indexer.js
CHANGED
|
@@ -30,11 +30,18 @@ function discoverSessionDirs(config) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
// Scan ~/.claude/projects/*/
|
|
33
|
+
// Scan ~/.claude/projects/*/ (Claude Code stores JSONL directly in project dirs)
|
|
34
34
|
const claudeProjects = path.join(home, '.claude/projects');
|
|
35
35
|
if (fs.existsSync(claudeProjects)) {
|
|
36
36
|
for (const proj of fs.readdirSync(claudeProjects)) {
|
|
37
|
-
const
|
|
37
|
+
const projDir = path.join(claudeProjects, proj);
|
|
38
|
+
// Claude Code: JSONL files directly in project dir
|
|
39
|
+
if (fs.existsSync(projDir) && fs.statSync(projDir).isDirectory()) {
|
|
40
|
+
const hasJsonl = fs.readdirSync(projDir).some(f => f.endsWith('.jsonl'));
|
|
41
|
+
if (hasJsonl) dirs.push({ path: projDir, agent: `claude-${proj}` });
|
|
42
|
+
}
|
|
43
|
+
// Also check sessions/ subdirectory (future-proofing)
|
|
44
|
+
const sp = path.join(projDir, 'sessions');
|
|
38
45
|
if (fs.existsSync(sp) && fs.statSync(sp).isDirectory()) {
|
|
39
46
|
dirs.push({ path: sp, agent: `claude-${proj}` });
|
|
40
47
|
}
|
|
@@ -133,23 +140,40 @@ function indexFile(db, filePath, agentName, stmts, archiveMode) {
|
|
|
133
140
|
let firstMessageTimestamp = null;
|
|
134
141
|
|
|
135
142
|
const firstLine = JSON.parse(lines[0]);
|
|
143
|
+
let isClaudeCode = false;
|
|
144
|
+
|
|
136
145
|
if (firstLine.type === 'session') {
|
|
146
|
+
// OpenClaw format
|
|
137
147
|
sessionId = firstLine.id;
|
|
138
148
|
sessionStart = firstLine.timestamp;
|
|
139
|
-
// Parse agent info from session metadata
|
|
140
149
|
if (firstLine.agent) agent = firstLine.agent;
|
|
141
150
|
if (firstLine.sessionType) sessionType = firstLine.sessionType;
|
|
142
|
-
// Detect sub-agent from ID patterns
|
|
143
151
|
if (sessionId.includes('subagent')) sessionType = 'subagent';
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
152
|
+
} else if (firstLine.type === 'user' || firstLine.type === 'assistant' || firstLine.type === 'file-history-snapshot') {
|
|
153
|
+
// Claude Code format — no session header, extract from first message line
|
|
154
|
+
isClaudeCode = true;
|
|
155
|
+
for (const line of lines) {
|
|
156
|
+
let obj; try { obj = JSON.parse(line); } catch { continue; }
|
|
157
|
+
if ((obj.type === 'user' || obj.type === 'assistant') && obj.sessionId) {
|
|
158
|
+
sessionId = obj.sessionId;
|
|
159
|
+
sessionStart = obj.timestamp;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!sessionId) {
|
|
164
|
+
// Fallback: use filename as session ID
|
|
165
|
+
sessionId = path.basename(filePath, '.jsonl');
|
|
166
|
+
sessionStart = new Date(firstLine.timestamp || Date.now()).toISOString();
|
|
167
|
+
}
|
|
149
168
|
} else {
|
|
150
169
|
return { skipped: true };
|
|
151
170
|
}
|
|
152
171
|
|
|
172
|
+
stmts.deleteEvents.run(sessionId);
|
|
173
|
+
stmts.deleteSession.run(sessionId);
|
|
174
|
+
stmts.deleteFileActivity.run(sessionId);
|
|
175
|
+
if (stmts.deleteArchive) stmts.deleteArchive.run(sessionId);
|
|
176
|
+
|
|
153
177
|
const pendingEvents = [];
|
|
154
178
|
const fileActivities = [];
|
|
155
179
|
|
|
@@ -157,16 +181,34 @@ function indexFile(db, filePath, agentName, stmts, archiveMode) {
|
|
|
157
181
|
let obj;
|
|
158
182
|
try { obj = JSON.parse(line); } catch { continue; }
|
|
159
183
|
|
|
160
|
-
if (obj.type === 'session' || obj.type === 'model_change' || obj.type === 'thinking_level_change' || obj.type === 'custom') {
|
|
184
|
+
if (obj.type === 'session' || obj.type === 'model_change' || obj.type === 'thinking_level_change' || obj.type === 'custom' || obj.type === 'file-history-snapshot') {
|
|
161
185
|
if (obj.type === 'model_change') model = obj.modelId || model;
|
|
162
186
|
continue;
|
|
163
187
|
}
|
|
164
188
|
|
|
189
|
+
// Normalize: Claude Code uses top-level type "user"/"assistant" with message object
|
|
190
|
+
// OpenClaw uses type "message" with message.role
|
|
191
|
+
let msg, ts;
|
|
165
192
|
if (obj.type === 'message' && obj.message) {
|
|
166
|
-
|
|
167
|
-
|
|
193
|
+
msg = obj.message;
|
|
194
|
+
ts = obj.timestamp;
|
|
195
|
+
} else if ((obj.type === 'user' || obj.type === 'assistant') && obj.message) {
|
|
196
|
+
// Claude Code format: wrap into consistent shape
|
|
197
|
+
msg = obj.message;
|
|
198
|
+
if (!msg.role) msg.role = obj.type === 'user' ? 'user' : 'assistant';
|
|
199
|
+
ts = obj.timestamp;
|
|
200
|
+
} else {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (msg) {
|
|
168
205
|
sessionEnd = ts;
|
|
169
206
|
|
|
207
|
+
// Extract model from assistant messages as fallback
|
|
208
|
+
if (!model && msg.role === 'assistant' && msg.model && msg.model !== 'delivery-mirror' && !msg.model.startsWith('<')) {
|
|
209
|
+
model = msg.model;
|
|
210
|
+
}
|
|
211
|
+
|
|
170
212
|
// Cost tracking
|
|
171
213
|
if (msg.usage && msg.usage.cost && typeof msg.usage.cost.total === 'number') {
|
|
172
214
|
totalCost += msg.usage.cost.total;
|
|
@@ -175,15 +217,23 @@ function indexFile(db, filePath, agentName, stmts, archiveMode) {
|
|
|
175
217
|
totalTokens += msg.usage.totalTokens;
|
|
176
218
|
}
|
|
177
219
|
if (msg.usage) {
|
|
220
|
+
// OpenClaw format
|
|
178
221
|
if (typeof msg.usage.input === 'number') totalInputTokens += msg.usage.input;
|
|
179
222
|
if (typeof msg.usage.output === 'number') totalOutputTokens += msg.usage.output;
|
|
180
223
|
if (typeof msg.usage.cacheRead === 'number') totalCacheReadTokens += msg.usage.cacheRead;
|
|
181
224
|
if (typeof msg.usage.cacheWrite === 'number') totalCacheWriteTokens += msg.usage.cacheWrite;
|
|
225
|
+
// Claude Code format
|
|
226
|
+
if (typeof msg.usage.input_tokens === 'number') totalInputTokens += msg.usage.input_tokens;
|
|
227
|
+
if (typeof msg.usage.output_tokens === 'number') totalOutputTokens += msg.usage.output_tokens;
|
|
228
|
+
if (typeof msg.usage.cache_read_input_tokens === 'number') totalCacheReadTokens += msg.usage.cache_read_input_tokens;
|
|
229
|
+
if (typeof msg.usage.cache_creation_input_tokens === 'number') totalCacheWriteTokens += msg.usage.cache_creation_input_tokens;
|
|
182
230
|
}
|
|
183
231
|
|
|
232
|
+
const eventId = obj.id || obj.uuid || `evt-${Date.parse(ts) || Math.random()}`;
|
|
233
|
+
|
|
184
234
|
const tr = extractToolResult(msg);
|
|
185
235
|
if (tr) {
|
|
186
|
-
pendingEvents.push([
|
|
236
|
+
pendingEvents.push([eventId, sessionId, ts, 'tool_result', 'tool', tr.content, tr.toolName, null, tr.content]);
|
|
187
237
|
continue;
|
|
188
238
|
}
|
|
189
239
|
|
|
@@ -191,7 +241,7 @@ function indexFile(db, filePath, agentName, stmts, archiveMode) {
|
|
|
191
241
|
const role = msg.role || 'unknown';
|
|
192
242
|
|
|
193
243
|
if (content) {
|
|
194
|
-
pendingEvents.push([
|
|
244
|
+
pendingEvents.push([eventId, sessionId, ts, 'message', role, content, null, null, null]);
|
|
195
245
|
msgCount++;
|
|
196
246
|
// Better summary: skip heartbeat messages
|
|
197
247
|
if (!summary && role === 'user' && !isHeartbeat(content)) {
|
|
@@ -200,14 +250,14 @@ function indexFile(db, filePath, agentName, stmts, archiveMode) {
|
|
|
200
250
|
// Capture initial prompt from first substantial user message
|
|
201
251
|
if (!initialPrompt && role === 'user' && content.trim().length > 10 && !isHeartbeat(content)) {
|
|
202
252
|
initialPrompt = content.slice(0, 500); // Limit to 500 chars
|
|
203
|
-
firstMessageId =
|
|
253
|
+
firstMessageId = eventId;
|
|
204
254
|
firstMessageTimestamp = ts;
|
|
205
255
|
}
|
|
206
256
|
}
|
|
207
257
|
|
|
208
258
|
const tools = extractToolCalls(msg);
|
|
209
259
|
for (const tool of tools) {
|
|
210
|
-
pendingEvents.push([tool.id || `${
|
|
260
|
+
pendingEvents.push([tool.id || `${eventId}-${tool.name}`, sessionId, ts, 'tool_call', role, null, tool.name, tool.args, null]);
|
|
211
261
|
toolCount++;
|
|
212
262
|
|
|
213
263
|
// File activity tracking
|
|
@@ -227,6 +277,21 @@ function indexFile(db, filePath, agentName, stmts, archiveMode) {
|
|
|
227
277
|
summary = 'Heartbeat session';
|
|
228
278
|
}
|
|
229
279
|
|
|
280
|
+
// Infer session type from first user message content
|
|
281
|
+
if (!sessionType && initialPrompt) {
|
|
282
|
+
const p = initialPrompt.toLowerCase();
|
|
283
|
+
if (p.includes('[cron:')) sessionType = 'cron';
|
|
284
|
+
else if (p.includes('heartbeat') && p.includes('heartbeat_ok')) sessionType = 'heartbeat';
|
|
285
|
+
}
|
|
286
|
+
if (!sessionType && !initialPrompt) sessionType = 'heartbeat';
|
|
287
|
+
// Detect subagent: task-style prompts injected by sessions_spawn
|
|
288
|
+
// These typically start with a date/time stamp and contain a detailed task
|
|
289
|
+
if (!sessionType && initialPrompt) {
|
|
290
|
+
const p = initialPrompt.trim();
|
|
291
|
+
// Sub-agent prompts start with "[Wed 2026-..." or "You are working on..."
|
|
292
|
+
if (/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-/.test(p)) sessionType = 'subagent';
|
|
293
|
+
}
|
|
294
|
+
|
|
230
295
|
stmts.upsertSession.run(sessionId, sessionStart, sessionEnd, msgCount, toolCount, model, summary, agent, sessionType, totalCost, totalTokens, totalInputTokens, totalOutputTokens, totalCacheReadTokens, totalCacheWriteTokens, initialPrompt, firstMessageId, firstMessageTimestamp);
|
|
231
296
|
for (const ev of pendingEvents) stmts.insertEvent.run(...ev);
|
|
232
297
|
for (const fa of fileActivities) stmts.insertFileActivity.run(...fa);
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -218,7 +218,11 @@ async function showSearchHome() {
|
|
|
218
218
|
});
|
|
219
219
|
|
|
220
220
|
$$('.session-item', el).forEach(item => {
|
|
221
|
-
item.addEventListener('click', () =>
|
|
221
|
+
item.addEventListener('click', () => {
|
|
222
|
+
window._lastView = 'search';
|
|
223
|
+
window._lastSearchQuery = $('#searchInput')?.value || '';
|
|
224
|
+
viewSession(item.dataset.id);
|
|
225
|
+
});
|
|
222
226
|
});
|
|
223
227
|
}
|
|
224
228
|
|
|
@@ -260,11 +264,16 @@ async function doSearch(q) {
|
|
|
260
264
|
`).join('');
|
|
261
265
|
|
|
262
266
|
$$('.session-link', el).forEach(link => {
|
|
263
|
-
link.addEventListener('click', () =>
|
|
267
|
+
link.addEventListener('click', () => {
|
|
268
|
+
window._lastView = 'search';
|
|
269
|
+
window._lastSearchQuery = q;
|
|
270
|
+
viewSession(link.dataset.session);
|
|
271
|
+
});
|
|
264
272
|
});
|
|
265
273
|
}
|
|
266
274
|
|
|
267
275
|
async function viewSessions() {
|
|
276
|
+
window._currentSessionId = null;
|
|
268
277
|
content.innerHTML = '<div class="loading">Loading…</div>';
|
|
269
278
|
const data = await api('/sessions?limit=200');
|
|
270
279
|
|
|
@@ -278,6 +287,7 @@ async function viewSessions() {
|
|
|
278
287
|
}
|
|
279
288
|
|
|
280
289
|
async function viewSession(id) {
|
|
290
|
+
window._currentSessionId = id;
|
|
281
291
|
content.innerHTML = '<div class="loading">Loading…</div>';
|
|
282
292
|
const data = await api(`/sessions/${id}`);
|
|
283
293
|
|
|
@@ -287,14 +297,12 @@ async function viewSession(id) {
|
|
|
287
297
|
const cost = fmtCost(s.total_cost);
|
|
288
298
|
let html = `
|
|
289
299
|
<div class="back-btn" id="backBtn">← Back</div>
|
|
290
|
-
<div
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
<a class="export-btn" href="#" onclick="dlExport('/api/export/session/${id}?format=json','session.json');return false">📋 JSON</a>
|
|
297
|
-
</div>
|
|
300
|
+
<div class="page-title">Session</div>
|
|
301
|
+
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:8px">
|
|
302
|
+
${s.first_message_id ? `<button class="jump-to-start-btn" id="jumpToStartBtn" title="Jump to initial prompt">↗️ Initial Prompt</button>` : ''}
|
|
303
|
+
${data.hasArchive ? `<a class="export-btn" href="#" onclick="dlExport('/api/archive/export/${id}','session.jsonl');return false">📦 JSONL</a>` : ''}
|
|
304
|
+
<a class="export-btn" href="#" onclick="dlExport('/api/export/session/${id}?format=md','session.md');return false">📄 MD</a>
|
|
305
|
+
<a class="export-btn" href="#" onclick="dlExport('/api/export/session/${id}?format=json','session.json');return false">📋 JSON</a>
|
|
298
306
|
</div>
|
|
299
307
|
<div class="session-item" style="cursor:default">
|
|
300
308
|
<div class="session-header">
|
|
@@ -320,6 +328,7 @@ async function viewSession(id) {
|
|
|
320
328
|
$('#backBtn').addEventListener('click', () => {
|
|
321
329
|
if (window._lastView === 'timeline') viewTimeline();
|
|
322
330
|
else if (window._lastView === 'files') viewFiles();
|
|
331
|
+
else if (window._lastView === 'search') viewSearch(window._lastSearchQuery || '');
|
|
323
332
|
else viewSessions();
|
|
324
333
|
});
|
|
325
334
|
|
|
@@ -480,21 +489,28 @@ function renderFiles() {
|
|
|
480
489
|
groups[dir].push(f);
|
|
481
490
|
});
|
|
482
491
|
|
|
483
|
-
// Sort groups by
|
|
492
|
+
// Sort groups by active sort criteria
|
|
493
|
+
const groupMetric = (files) => {
|
|
494
|
+
if (sort === 'touches') return files.reduce((s, f) => s + f.touch_count, 0);
|
|
495
|
+
if (sort === 'sessions') return files.reduce((s, f) => s + f.session_count, 0);
|
|
496
|
+
if (sort === 'recent') return Math.max(...files.map(f => new Date(f.last_touched).getTime()));
|
|
497
|
+
return 0;
|
|
498
|
+
};
|
|
484
499
|
const sortedGroups = Object.entries(groups).sort((a, b) => {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
return bTotal - aTotal;
|
|
500
|
+
if (sort === 'name') return a[0].localeCompare(b[0]);
|
|
501
|
+
return groupMetric(b[1]) - groupMetric(a[1]);
|
|
488
502
|
});
|
|
489
503
|
|
|
490
504
|
listEl.innerHTML = sortedGroups.map(([dir, dirFiles]) => {
|
|
491
505
|
const totalTouches = dirFiles.reduce((s, f) => s + f.touch_count, 0);
|
|
506
|
+
const totalSessions = dirFiles.reduce((s, f) => s + f.session_count, 0);
|
|
507
|
+
const groupStat = sort === 'sessions' ? `${totalSessions} sessions` : `${totalTouches} touches`;
|
|
492
508
|
return `
|
|
493
509
|
<div class="file-group">
|
|
494
510
|
<div class="file-group-header" data-dir="${escHtml(dir)}">
|
|
495
511
|
<span class="file-group-arrow">▶</span>
|
|
496
512
|
<span class="file-group-name">~/${escHtml(dir)}</span>
|
|
497
|
-
<span style="color:var(--text2);font-size:12px;margin-left:auto">${dirFiles.length} files · ${
|
|
513
|
+
<span style="color:var(--text2);font-size:12px;margin-left:auto">${dirFiles.length} files · ${groupStat}</span>
|
|
498
514
|
</div>
|
|
499
515
|
<div class="file-group-items" style="display:none">
|
|
500
516
|
${dirFiles.map(f => renderFileItem(f)).join('')}
|
|
@@ -606,6 +622,40 @@ $$('.nav-item').forEach(item => {
|
|
|
606
622
|
|
|
607
623
|
viewSearch();
|
|
608
624
|
|
|
625
|
+
// Swipe right from left edge to go back
|
|
626
|
+
(function initSwipeBack() {
|
|
627
|
+
let startX = 0, startY = 0, swiping = false;
|
|
628
|
+
const edgeWidth = 30; // px from left edge
|
|
629
|
+
const threshold = 80;
|
|
630
|
+
|
|
631
|
+
document.addEventListener('touchstart', e => {
|
|
632
|
+
const x = e.touches[0].clientX;
|
|
633
|
+
if (x <= edgeWidth) {
|
|
634
|
+
startX = x;
|
|
635
|
+
startY = e.touches[0].clientY;
|
|
636
|
+
swiping = true;
|
|
637
|
+
}
|
|
638
|
+
}, { passive: true });
|
|
639
|
+
|
|
640
|
+
document.addEventListener('touchmove', e => {
|
|
641
|
+
if (!swiping) return;
|
|
642
|
+
const dx = e.touches[0].clientX - startX;
|
|
643
|
+
const dy = Math.abs(e.touches[0].clientY - startY);
|
|
644
|
+
// Cancel if vertical movement exceeds horizontal (it's a scroll)
|
|
645
|
+
if (dy > dx) { swiping = false; }
|
|
646
|
+
}, { passive: true });
|
|
647
|
+
|
|
648
|
+
document.addEventListener('touchend', e => {
|
|
649
|
+
if (!swiping) return;
|
|
650
|
+
swiping = false;
|
|
651
|
+
const dx = e.changedTouches[0].clientX - startX;
|
|
652
|
+
if (dx > threshold) {
|
|
653
|
+
const backBtn = $('#backBtn');
|
|
654
|
+
if (backBtn) backBtn.click();
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
})();
|
|
658
|
+
|
|
609
659
|
// Pull to refresh
|
|
610
660
|
(function initPTR() {
|
|
611
661
|
let startY = 0;
|
|
@@ -645,8 +695,14 @@ viewSearch();
|
|
|
645
695
|
indicator.classList.add('refreshing');
|
|
646
696
|
try {
|
|
647
697
|
await api('/reindex');
|
|
648
|
-
|
|
649
|
-
|
|
698
|
+
// If viewing a session detail, refresh it in place
|
|
699
|
+
const backBtn = $('#backBtn');
|
|
700
|
+
if (backBtn && window._currentSessionId) {
|
|
701
|
+
await viewSession(window._currentSessionId);
|
|
702
|
+
} else {
|
|
703
|
+
const active = $('.nav-item.active');
|
|
704
|
+
if (active) active.click();
|
|
705
|
+
}
|
|
650
706
|
} catch(err) {}
|
|
651
707
|
setTimeout(() => {
|
|
652
708
|
indicator.classList.remove('visible', 'refreshing');
|