agentacta 1.1.4 → 1.2.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
@@ -54,10 +54,13 @@ function escHtml(s) {
54
54
 
55
55
  function truncate(s, n = 200) {
56
56
  if (!s) return '';
57
- return s.length > n ? s.slice(0, n) + '' : s;
57
+ return s.length > n ? s.slice(0, n) + '\u2026' : s;
58
58
  }
59
59
 
60
- // Removed jumpToInitialPrompt - now handled within session view
60
+ function shortSessionId(id) {
61
+ if (!id) return '';
62
+ return id.length > 24 ? `${id.slice(0, 8)}\u2026${id.slice(-8)}` : id;
63
+ }
61
64
 
62
65
  function badgeClass(type, role) {
63
66
  if (type === 'tool_call') return 'badge-tool_call';
@@ -67,6 +70,12 @@ function badgeClass(type, role) {
67
70
  return 'badge-message';
68
71
  }
69
72
 
73
+ function transitionView() {
74
+ content.classList.remove('view-enter');
75
+ void content.offsetWidth;
76
+ content.classList.add('view-enter');
77
+ }
78
+
70
79
  function renderEvent(ev) {
71
80
  const badge = `<span class="event-badge ${badgeClass(ev.type, ev.role)}">${ev.type === 'tool_call' ? 'tool' : ev.role || ev.type}</span>`;
72
81
  let body = '';
@@ -82,7 +91,7 @@ function renderEvent(ev) {
82
91
  }
83
92
  }
84
93
  } else if (ev.type === 'tool_result') {
85
- body = `<span class="tool-name">→ ${escHtml(ev.tool_name)}</span>`;
94
+ body = `<span class="tool-name">\u2192 ${escHtml(ev.tool_name)}</span>`;
86
95
  if (ev.content) {
87
96
  body += `<div class="tool-args">${escHtml(truncate(ev.content, 500))}</div>`;
88
97
  }
@@ -97,6 +106,40 @@ function renderEvent(ev) {
97
106
  </div>`;
98
107
  }
99
108
 
109
+ function renderTimelineEvent(ev) {
110
+ const typeClass = ev.type === 'tool_call' || ev.type === 'tool_result' ? 'type-tool' :
111
+ ev.role === 'user' ? 'type-user' :
112
+ ev.role === 'assistant' ? 'type-assistant' : '';
113
+
114
+ const badge = `<span class="event-badge ${badgeClass(ev.type, ev.role)}">${ev.type === 'tool_call' ? 'tool' : ev.role || ev.type}</span>`;
115
+ let body = '';
116
+
117
+ if (ev.type === 'tool_call') {
118
+ body = `<span class="tool-name">${escHtml(ev.tool_name)}</span>`;
119
+ if (ev.tool_args) {
120
+ try {
121
+ const args = JSON.parse(ev.tool_args);
122
+ body += `<div class="tool-args">${escHtml(JSON.stringify(args, null, 2))}</div>`;
123
+ } catch {
124
+ body += `<div class="tool-args">${escHtml(ev.tool_args)}</div>`;
125
+ }
126
+ }
127
+ } else if (ev.type === 'tool_result') {
128
+ body = `<span class="tool-name">\u2192 ${escHtml(ev.tool_name)}</span>`;
129
+ if (ev.content) {
130
+ body += `<div class="tool-args">${escHtml(truncate(ev.content, 500))}</div>`;
131
+ }
132
+ } else {
133
+ body = `<div class="event-content">${escHtml(ev.content || '')}</div>`;
134
+ }
135
+
136
+ return `<div class="timeline-event ${typeClass}" data-event-id="${ev.id}">
137
+ <div class="event-time">${fmtTimeShort(ev.timestamp)}</div>
138
+ ${badge}
139
+ <div class="event-body">${body}</div>
140
+ </div>`;
141
+ }
142
+
100
143
  function fmtDuration(start, end) {
101
144
  if (!start) return '';
102
145
  const ms = (end ? new Date(end) : new Date()) - new Date(start);
@@ -113,6 +156,13 @@ function fmtTimeOnly(ts) {
113
156
  return new Date(ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
114
157
  }
115
158
 
159
+ function normalizeAgentLabel(a) {
160
+ if (!a) return a;
161
+ if (a === 'main') return 'openclaw-main';
162
+ if (a.startsWith('claude-') || a.startsWith('claude--')) return 'claude-code';
163
+ return a;
164
+ }
165
+
116
166
  function renderProjectTags(s) {
117
167
  let projects = [];
118
168
  if (s.projects) {
@@ -122,7 +172,6 @@ function renderProjectTags(s) {
122
172
  }
123
173
 
124
174
  function renderModelTags(s) {
125
- // Prefer models array if present, fall back to single model
126
175
  let models = [];
127
176
  if (s.models) {
128
177
  try { models = JSON.parse(s.models); } catch {}
@@ -133,23 +182,23 @@ function renderModelTags(s) {
133
182
 
134
183
  function renderSessionItem(s) {
135
184
  const duration = fmtDuration(s.start_time, s.end_time);
136
- const timeRange = `${fmtTime(s.start_time)} ${s.end_time ? fmtTimeOnly(s.end_time) : 'now'}`;
185
+ const timeRange = `${fmtTime(s.start_time)} \u2192 ${s.end_time ? fmtTimeOnly(s.end_time) : 'now'}`;
137
186
 
138
187
  return `
139
188
  <div class="session-item" data-id="${s.id}">
140
189
  <div class="session-header">
141
- <span class="session-time">${timeRange} · ${duration}</span>
190
+ <span class="session-time">${timeRange} \u00b7 ${duration}</span>
142
191
  <span style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
143
192
  ${renderProjectTags(s)}
144
- ${s.agent && s.agent !== 'main' ? `<span class="session-agent">${escHtml(s.agent)}</span>` : ''}
193
+ ${s.agent && s.agent !== 'main' ? `<span class="session-agent">${escHtml(normalizeAgentLabel(s.agent))}</span>` : ''}
145
194
  ${s.session_type ? `<span class="session-type">${escHtml(s.session_type)}</span>` : ''}
146
195
  ${renderModelTags(s)}
147
196
  </span>
148
197
  </div>
149
198
  <div class="session-summary">${escHtml(truncate(s.summary || 'No summary', 120))}</div>
150
199
  <div class="session-meta">
151
- <span>💬 ${s.message_count}</span>
152
- <span>🔧 ${s.tool_count}</span>
200
+ <span><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> ${s.message_count}</span>
201
+ <span><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> ${s.tool_count}</span>
153
202
  </div>
154
203
  </div>
155
204
  `;
@@ -163,8 +212,8 @@ async function viewSearch(query = '') {
163
212
 
164
213
  let html = `<div class="page-title">Search</div>
165
214
  <div class="search-bar">
166
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
167
- <input type="text" id="searchInput" placeholder="Search messages, tool calls, files" value="${escHtml(query)}">
215
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
216
+ <input type="text" id="searchInput" placeholder="Search messages, tool calls, files\u2026" value="${escHtml(query)}">
168
217
  </div>
169
218
  <div class="filters">
170
219
  <span class="filter-chip ${typeFilter===''?'active':''}" data-filter="type" data-val="">All</span>
@@ -177,6 +226,7 @@ async function viewSearch(query = '') {
177
226
  <div id="results"></div>`;
178
227
 
179
228
  content.innerHTML = html;
229
+ transitionView();
180
230
 
181
231
  const input = $('#searchInput');
182
232
  input.focus();
@@ -202,7 +252,7 @@ async function viewSearch(query = '') {
202
252
 
203
253
  async function showSearchHome() {
204
254
  const el = $('#results');
205
- el.innerHTML = '<div class="loading">Loading…</div>';
255
+ el.innerHTML = '<div class="loading">Loading</div>';
206
256
 
207
257
  const stats = await api('/stats');
208
258
  const sessions = await api('/sessions?limit=5');
@@ -211,17 +261,17 @@ async function showSearchHome() {
211
261
  try { const r = await fetch('/api/suggestions'); const d = await r.json(); suggestions = d.suggestions || []; } catch(e) { suggestions = []; }
212
262
 
213
263
  let html = `
214
- <div class="stat-grid" style="margin-top:8px">
215
- <div class="stat-card"><div class="label">Sessions</div><div class="value">${stats.sessions}</div></div>
216
- <div class="stat-card"><div class="label">Messages</div><div class="value">${stats.messages.toLocaleString()}</div></div>
217
- <div class="stat-card"><div class="label">Tool Calls</div><div class="value">${stats.toolCalls.toLocaleString()}</div></div>
218
- <div class="stat-card"><div class="label">Tokens</div><div class="value">${(stats.totalTokens || 0).toLocaleString()}</div></div>
264
+ <div class="search-stats" style="margin-top:8px">
265
+ <div class="search-stat"><div class="num">${stats.sessions}</div><div class="lbl">Sessions</div></div>
266
+ <div class="search-stat"><div class="num">${stats.messages.toLocaleString()}</div><div class="lbl">Messages</div></div>
267
+ <div class="search-stat"><div class="num">${stats.toolCalls.toLocaleString()}</div><div class="lbl">Tool Calls</div></div>
268
+ <div class="search-stat"><div class="num">${fmtTokens(stats.totalTokens || 0)}</div><div class="lbl">Tokens</div></div>
219
269
  </div>
220
270
 
221
- <div class="section-label">Quick Search</div>
271
+ ${suggestions.length ? `<div class="section-label">Quick Search</div>
222
272
  <div class="filters" id="suggestions">
223
- ${suggestions.map(s => `<span class="filter-chip suggestion" data-q="${s}">${s}</span>`).join('')}
224
- </div>
273
+ ${suggestions.map(s => `<span class="suggestion-chip" data-q="${s}">${s}</span>`).join('')}
274
+ </div>` : ''}
225
275
 
226
276
  <div class="section-label">Recent Sessions</div>
227
277
  ${sessions.sessions.map(renderSessionItem).join('')}
@@ -229,7 +279,7 @@ async function showSearchHome() {
229
279
 
230
280
  el.innerHTML = html;
231
281
 
232
- $$('.suggestion', el).forEach(chip => {
282
+ $$('.suggestion-chip', el).forEach(chip => {
233
283
  chip.addEventListener('click', () => {
234
284
  $('#searchInput').value = chip.dataset.q;
235
285
  doSearch(chip.dataset.q);
@@ -249,7 +299,7 @@ async function doSearch(q) {
249
299
  const el = $('#results');
250
300
  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; }
251
301
 
252
- el.innerHTML = '<div class="loading">Searching…</div>';
302
+ el.innerHTML = '<div class="loading">Searching</div>';
253
303
 
254
304
  const type = window._searchType || '';
255
305
  const role = window._searchRole || '';
@@ -260,13 +310,13 @@ async function doSearch(q) {
260
310
  const data = await api(url);
261
311
 
262
312
  if (data.error) { el.innerHTML = `<div class="empty"><p>${escHtml(data.error)}</p></div>`; return; }
263
- if (!data.results.length) { el.innerHTML = '<div class="empty"><h2>No results</h2></div>'; return; }
313
+ 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; }
264
314
 
265
- let header = `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
315
+ let header = `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-md)">
266
316
  <span class="section-label" style="margin:0">${data.results.length} results</span>
267
317
  <div style="display:flex;gap:8px">
268
- <a class="export-btn" href="#" onclick="dlExport('/api/export/search?q=${encodeURIComponent(q)}&format=md','search.md');return false">📄 MD</a>
269
- <a class="export-btn" href="#" onclick="dlExport('/api/export/search?q=${encodeURIComponent(q)}&format=json','search.json');return false">📋 JSON</a>
318
+ <a class="export-btn" href="#" onclick="dlExport('/api/export/search?q=${encodeURIComponent(q)}&format=md','search.md');return false">MD</a>
319
+ <a class="export-btn" href="#" onclick="dlExport('/api/export/search?q=${encodeURIComponent(q)}&format=json','search.json');return false">JSON</a>
270
320
  </div>
271
321
  </div>`;
272
322
 
@@ -276,7 +326,7 @@ async function doSearch(q) {
276
326
  <span class="event-badge ${badgeClass(r.type, r.role)}">${r.type === 'tool_call' ? 'tool' : r.role || r.type}</span>
277
327
  <span class="session-time">${fmtTime(r.timestamp)}</span>
278
328
  ${r.tool_name ? `<span class="tool-name">${escHtml(r.tool_name)}</span>` : ''}
279
- <span class="session-link" data-session="${r.session_id}">view session →</span>
329
+ <span class="session-link" data-session="${r.session_id}">view session \u2192</span>
280
330
  </div>
281
331
  <div class="result-content">${escHtml(truncate(r.content || r.tool_args || r.tool_result || '', 400))}</div>
282
332
  </div>
@@ -293,12 +343,13 @@ async function doSearch(q) {
293
343
 
294
344
  async function viewSessions() {
295
345
  window._currentSessionId = null;
296
- content.innerHTML = '<div class="loading">Loading…</div>';
346
+ content.innerHTML = '<div class="loading">Loading</div>';
297
347
  const data = await api('/sessions?limit=200');
298
348
 
299
349
  let html = `<div class="page-title">Sessions</div>`;
300
350
  html += data.sessions.map(renderSessionItem).join('');
301
351
  content.innerHTML = html;
352
+ transitionView();
302
353
 
303
354
  $$('.session-item').forEach(item => {
304
355
  item.addEventListener('click', () => viewSession(item.dataset.id));
@@ -307,7 +358,7 @@ async function viewSessions() {
307
358
 
308
359
  async function viewSession(id) {
309
360
  window._currentSessionId = id;
310
- content.innerHTML = '<div class="loading">Loading…</div>';
361
+ content.innerHTML = '<div class="loading">Loading</div>';
311
362
  const data = await api(`/sessions/${id}`);
312
363
 
313
364
  if (data.error) { content.innerHTML = `<div class="empty"><h2>${data.error}</h2></div>`; return; }
@@ -315,28 +366,34 @@ async function viewSession(id) {
315
366
  const s = data.session;
316
367
  const cost = fmtCost(s.total_cost);
317
368
  let html = `
318
- <div class="back-btn" id="backBtn">← Back</div>
369
+ <div class="back-btn" id="backBtn">\u2190 Back</div>
319
370
  <div class="page-title">Session</div>
320
- <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:8px">
321
- ${s.first_message_id ? `<button class="jump-to-start-btn" id="jumpToStartBtn" title="Jump to initial prompt">↗️ Initial Prompt</button>` : ''}
322
- ${data.hasArchive ? `<a class="export-btn" href="#" onclick="dlExport('/api/archive/export/${id}','session.jsonl');return false">📦 JSONL</a>` : ''}
323
- <a class="export-btn" href="#" onclick="dlExport('/api/export/session/${id}?format=md','session.md');return false">📄 MD</a>
324
- <a class="export-btn" href="#" onclick="dlExport('/api/export/session/${id}?format=json','session.json');return false">📋 JSON</a>
371
+ <div class="session-id-row">
372
+ <span class="session-id-label">ID</span>
373
+ <span class="session-id-value" title="${escHtml(id)}">${escHtml(id)}</span>
374
+ <button class="session-id-copy" id="copySessionId" title="Copy session ID">\u29c9</button>
375
+ <span class="session-id-copied" id="copyConfirm">Copied!</span>
325
376
  </div>
326
- <div class="session-item" style="cursor:default">
327
- <div class="session-header">
328
- <span class="session-time">${fmtDate(s.start_time)} · ${fmtTimeShort(s.start_time)} ${fmtTimeShort(s.end_time)}</span>
377
+ <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:12px">
378
+ ${s.first_message_id ? `<button class="jump-to-start-btn" id="jumpToStartBtn" title="Jump to initial prompt">Initial Prompt</button>` : ''}
379
+ ${data.hasArchive ? `<a class="export-btn" href="#" onclick="dlExport('/api/archive/export/${id}','session.jsonl');return false">JSONL</a>` : ''}
380
+ <a class="export-btn" href="#" onclick="dlExport('/api/export/session/${id}?format=md','session.md');return false">MD</a>
381
+ <a class="export-btn" href="#" onclick="dlExport('/api/export/session/${id}?format=json','session.json');return false">JSON</a>
382
+ </div>
383
+ <div class="session-detail-card">
384
+ <div class="session-header" style="margin-bottom:12px">
385
+ <span class="session-time">${fmtDate(s.start_time)} \u00b7 ${fmtTimeShort(s.start_time)} \u2013 ${fmtTimeShort(s.end_time)}</span>
329
386
  <span style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
330
387
  ${renderProjectTags(s)}
331
- ${s.agent && s.agent !== 'main' ? `<span class="session-agent">${escHtml(s.agent)}</span>` : ''}
388
+ ${s.agent && s.agent !== 'main' ? `<span class="session-agent">${escHtml(normalizeAgentLabel(s.agent))}</span>` : ''}
332
389
  ${s.session_type ? `<span class="session-type">${escHtml(s.session_type)}</span>` : ''}
333
390
  ${renderModelTags(s)}
334
391
  </span>
335
392
  </div>
336
- <div class="session-meta" style="display:grid;grid-template-columns:repeat(2,1fr);gap:6px 16px">
337
- <span>💬 ${s.message_count} messages</span>
338
- <span>🔧 ${s.tool_count} tools</span>
339
- ${s.output_tokens ? `<span>📤 ${fmtTokens(s.output_tokens)} output</span><span>📥 ${fmtTokens(s.input_tokens + s.cache_read_tokens)} input</span>` : s.total_tokens ? `<span>🔤 ${fmtTokens(s.total_tokens)} tokens</span><span></span>` : '<span></span><span></span>'}
393
+ <div class="session-detail-grid">
394
+ <span><span class="detail-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span> ${s.message_count} messages</span>
395
+ <span><span class="detail-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg></span> ${s.tool_count} tools</span>
396
+ ${s.output_tokens ? `<span><span class="detail-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/></svg></span> ${fmtTokens(s.output_tokens)} output</span><span><span class="detail-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg></span> ${fmtTokens(s.input_tokens + s.cache_read_tokens)} input</span>` : s.total_tokens ? `<span><span class="detail-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg></span> ${fmtTokens(s.total_tokens)} tokens</span><span></span>` : '<span></span><span></span>'}
340
397
  </div>
341
398
  </div>
342
399
  <div class="section-label">Events</div>
@@ -344,6 +401,7 @@ async function viewSession(id) {
344
401
 
345
402
  html += data.events.map(renderEvent).join('');
346
403
  content.innerHTML = html;
404
+ transitionView();
347
405
 
348
406
  $('#backBtn').addEventListener('click', () => {
349
407
  if (window._lastView === 'timeline') viewTimeline();
@@ -352,15 +410,54 @@ async function viewSession(id) {
352
410
  else viewSessions();
353
411
  });
354
412
 
413
+ $('#copySessionId').addEventListener('click', async () => {
414
+ const conf = $('#copyConfirm');
415
+ const showCopied = () => {
416
+ conf.textContent = 'Copied!';
417
+ conf.classList.add('show');
418
+ setTimeout(() => conf.classList.remove('show'), 1500);
419
+ };
420
+
421
+ try {
422
+ if (navigator.clipboard && window.isSecureContext) {
423
+ await navigator.clipboard.writeText(id);
424
+ showCopied();
425
+ return;
426
+ }
427
+
428
+ const ta = document.createElement('textarea');
429
+ ta.value = id;
430
+ ta.setAttribute('readonly', '');
431
+ ta.style.position = 'fixed';
432
+ ta.style.opacity = '0';
433
+ ta.style.pointerEvents = 'none';
434
+ document.body.appendChild(ta);
435
+ ta.focus();
436
+ ta.select();
437
+ const ok = document.execCommand('copy');
438
+ document.body.removeChild(ta);
439
+
440
+ if (ok) showCopied();
441
+ else throw new Error('Copy failed');
442
+ } catch {
443
+ conf.textContent = 'Press Ctrl/Cmd+C';
444
+ conf.classList.add('show');
445
+ setTimeout(() => {
446
+ conf.classList.remove('show');
447
+ conf.textContent = 'Copied!';
448
+ }, 1800);
449
+ }
450
+ });
451
+
355
452
  const jumpBtn = $('#jumpToStartBtn');
356
453
  if (jumpBtn) {
357
454
  jumpBtn.addEventListener('click', () => {
358
455
  const firstMessage = document.querySelector(`[data-event-id="${s.first_message_id}"]`);
359
456
  if (firstMessage) {
360
457
  firstMessage.scrollIntoView({ behavior: 'smooth', block: 'center' });
361
- firstMessage.style.background = 'var(--accent-bg)';
458
+ firstMessage.classList.add('event-highlight');
362
459
  setTimeout(() => {
363
- firstMessage.style.background = '';
460
+ firstMessage.classList.remove('event-highlight');
364
461
  }, 2000);
365
462
  }
366
463
  });
@@ -368,13 +465,17 @@ async function viewSession(id) {
368
465
  }
369
466
 
370
467
  async function viewTimeline(date) {
371
- if (!date) date = new Date().toISOString().slice(0, 10);
468
+ if (!date) {
469
+ const now = new Date();
470
+ date = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
471
+ }
372
472
  window._lastView = 'timeline';
373
473
 
374
474
  let html = `<div class="page-title">Timeline</div>
375
475
  <input type="date" class="date-input" id="dateInput" value="${date}">
376
- <div id="timelineContent"><div class="loading">Loading…</div></div>`;
476
+ <div id="timelineContent"><div class="loading">Loading</div></div>`;
377
477
  content.innerHTML = html;
478
+ transitionView();
378
479
 
379
480
  const data = await api(`/timeline?date=${date}`);
380
481
  const el = $('#timelineContent');
@@ -382,52 +483,76 @@ async function viewTimeline(date) {
382
483
  if (!data.events.length) {
383
484
  el.innerHTML = '<div class="empty"><h2>No activity</h2><p>Nothing recorded on this day</p></div>';
384
485
  } else {
385
- el.innerHTML = data.events.map(renderEvent).join('');
486
+ el.innerHTML = `<div class="timeline-events-wrap">
487
+ <div class="timeline-line"></div>
488
+ ${data.events.map(renderTimelineEvent).join('')}
489
+ </div>`;
386
490
  }
387
491
 
388
492
  $('#dateInput').addEventListener('change', e => viewTimeline(e.target.value));
389
493
  }
390
494
 
391
495
  async function viewStats() {
392
- content.innerHTML = '<div class="loading">Loading…</div>';
496
+ content.innerHTML = '<div class="loading">Loading</div>';
393
497
  const data = await api('/stats');
394
498
 
395
499
  let html = `<div class="page-title">Stats</div>
396
500
  <div class="stat-grid">
397
- <div class="stat-card"><div class="label">Sessions</div><div class="value">${data.sessions}</div></div>
398
- <div class="stat-card"><div class="label">Messages</div><div class="value">${data.messages.toLocaleString()}</div></div>
399
- <div class="stat-card"><div class="label">Tool Calls</div><div class="value">${data.toolCalls.toLocaleString()}</div></div>
400
- <div class="stat-card"><div class="label">Unique Tools</div><div class="value">${data.uniqueTools}</div></div>
401
- <div class="stat-card"><div class="label">Total Tokens</div><div class="value">${(data.totalTokens || 0).toLocaleString()}</div></div>
501
+ <div class="stat-card accent-blue"><div class="label">Sessions</div><div class="value">${data.sessions}</div></div>
502
+ <div class="stat-card accent-green"><div class="label">Messages</div><div class="value">${data.messages.toLocaleString()}</div></div>
503
+ <div class="stat-card accent-amber"><div class="label">Tool Calls</div><div class="value">${data.toolCalls.toLocaleString()}</div></div>
504
+ <div class="stat-card accent-purple"><div class="label">Unique Tools</div><div class="value">${data.uniqueTools}</div></div>
505
+ <div class="stat-card accent-teal"><div class="label">Total Tokens</div><div class="value">${(data.totalTokens || 0).toLocaleString()}</div></div>
402
506
  </div>
403
507
 
404
508
  <div class="section-label">Configuration</div>
405
- <div class="stat-grid">
406
- <div class="stat-card"><div class="label">Storage Mode</div><div class="value" style="font-size:18px">${escHtml(data.storageMode || 'reference')}</div></div>
407
- <div class="stat-card"><div class="label">DB Size</div><div class="value" style="font-size:18px">${escHtml(data.dbSize?.display || 'N/A')}</div></div>
509
+ <div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));gap:var(--space-md);margin-bottom:var(--space-xl)">
510
+ <div class="config-card"><div class="config-label">Storage Mode</div><div class="config-value">${escHtml(data.storageMode || 'reference')}</div></div>
511
+ <div class="config-card"><div class="config-label">DB Size</div><div class="config-value">${escHtml(data.dbSize?.display || 'N/A')}</div></div>
408
512
  </div>
409
513
 
410
- ${data.sessionDirs && data.sessionDirs.length ? `<div class="section-label">Sessions Paths</div>
411
- <div style="font-size:13px;color:var(--text2);font-family:var(--mono)">
412
- ${data.sessionDirs.map(d => {
413
- const display = d.path.replace(/^\/home\/[^/]+/, '~').replace(/^\/Users\/[^/]+/, '~');
414
- return `<div style="margin-bottom:4px">📂 ${escHtml(display)} <span style="color:var(--accent)">(${escHtml(d.agent)})</span></div>`;
415
- }).join('')}
416
- </div>` : ''}
514
+ ${data.sessionDirs && data.sessionDirs.length ? (() => {
515
+ const dirs = data.sessionDirs || [];
516
+ const claudeDirs = dirs.filter(d => d.agent === 'claude-code' || /^claude-/.test(d.agent || ''));
517
+ const otherDirs = dirs.filter(d => !(d.agent === 'claude-code' || /^claude-/.test(d.agent || '')));
518
+
519
+ const lines = [];
520
+
521
+ if (claudeDirs.length) {
522
+ const projects = new Set();
523
+ for (const d of claudeDirs) {
524
+ const m = (d.path || '').match(/[\\/]\\.claude[\\/]projects[\\/]([^\\/]+)$/);
525
+ if (m && m[1]) projects.add(m[1]);
526
+ }
527
+ const projectCount = projects.size || claudeDirs.length;
528
+ lines.push(`<div style="display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border-subtle)"><span style="color:var(--text-tertiary)">~/.claude/projects/*</span> <span style="color:var(--accent);font-size:12px">claude-code \u00b7 ${projectCount} workspace${projectCount === 1 ? '' : 's'}</span></div>`);
529
+ }
530
+
531
+ for (const d of otherDirs) {
532
+ const display = (d.path || '').replace(/^\/home\/[^/]+/, '~').replace(/^\/Users\/[^/]+/, '~');
533
+ lines.push(`<div style="display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border-subtle)"><span style="color:var(--text-tertiary)">${escHtml(display)}</span> <span style="color:var(--accent);font-size:12px">${escHtml(normalizeAgentLabel(d.agent))}</span></div>`);
534
+ }
535
+
536
+ return `<div class="section-label">Session Paths</div>
537
+ <div class="config-card" style="margin-bottom:var(--space-xl)">
538
+ <div style="font-size:12.5px;font-family:var(--font-mono)">${lines.join('')}</div>
539
+ </div>`;
540
+ })() : ''}
417
541
 
418
- ${data.agents && data.agents.length > 1 ? `<div class="section-label">Agents</div><div class="filters">${data.agents.map(a => `<span class="filter-chip">${escHtml(a)}</span>`).join('')}</div>` : ''}
542
+ ${data.agents && data.agents.length > 1 ? `<div class="section-label">Agents</div><div class="filters" style="margin-bottom:var(--space-xl)">${data.agents.map(a => `<span class="filter-chip">${escHtml(a)}</span>`).join('')}</div>` : ''}
419
543
  <div class="section-label">Date Range</div>
420
- <p style="color:var(--text2);font-size:14px">${fmtDate(data.dateRange?.earliest)} ${fmtDate(data.dateRange?.latest)}</p>
544
+ <p style="color:var(--text-secondary);font-size:13px;margin-bottom:var(--space-xl)">${fmtDate(data.dateRange?.earliest)} \u2014 ${fmtDate(data.dateRange?.latest)}</p>
421
545
  <div class="section-label">Tools Used</div>
422
- <div class="filters">${(data.tools||[]).filter(t => t).sort().map(t => `<span class="filter-chip">${escHtml(t)}</span>`).join('')}</div>
546
+ <div class="tools-grid">${(data.tools||[]).filter(t => t).sort().map(t => `<span class="tool-chip">${escHtml(t)}</span>`).join('')}</div>
423
547
  `;
424
548
 
425
549
  content.innerHTML = html;
550
+ transitionView();
426
551
  }
427
552
 
428
553
  async function viewFiles() {
429
554
  window._lastView = 'files';
430
- content.innerHTML = '<div class="loading">Loading…</div>';
555
+ content.innerHTML = '<div class="loading">Loading</div>';
431
556
  const data = await api('/files?limit=500');
432
557
  window._allFiles = data.files || [];
433
558
  window._fileSort = window._fileSort || 'touches';
@@ -443,8 +568,6 @@ function getFileExt(p) {
443
568
  }
444
569
 
445
570
  function getFileDir(p) {
446
- // Group by project-level directory
447
- // Strip common home dir prefixes
448
571
  let rel = p.replace(/^\/home\/[^/]+\//, '~/').replace(/^\/Users\/[^/]+\//, '~/');
449
572
  if (rel.startsWith('~/')) rel = rel.slice(2);
450
573
  const parts = rel.split('/');
@@ -455,53 +578,48 @@ function getFileDir(p) {
455
578
  function renderFiles() {
456
579
  let files = [...window._allFiles];
457
580
 
458
- // Search filter
459
581
  const q = window._fileSearch.toLowerCase();
460
582
  if (q) files = files.filter(f => f.file_path.toLowerCase().includes(q));
461
583
 
462
- // Extension filter
463
584
  if (window._fileFilter) {
464
585
  files = files.filter(f => getFileExt(f.file_path) === window._fileFilter);
465
586
  }
466
587
 
467
- // Sort
468
588
  const sort = window._fileSort;
469
589
  if (sort === 'touches') files.sort((a, b) => b.touch_count - a.touch_count);
470
590
  else if (sort === 'recent') files.sort((a, b) => new Date(b.last_touched) - new Date(a.last_touched));
471
591
  else if (sort === 'name') files.sort((a, b) => a.file_path.localeCompare(b.file_path));
472
592
  else if (sort === 'sessions') files.sort((a, b) => b.session_count - a.session_count);
473
593
 
474
- // Get unique extensions for filter chips
475
594
  const exts = [...new Set(window._allFiles.map(f => getFileExt(f.file_path)))].sort();
476
595
 
477
596
  let html = `<div class="page-title">Files</div>
478
597
  <div class="search-bar" style="margin-bottom:12px">
479
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
480
- <input type="text" id="fileSearchInput" placeholder="Filter by filename or path" value="${escHtml(window._fileSearch)}">
598
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
599
+ <input type="text" id="fileSearchInput" placeholder="Filter by filename or path\u2026" value="${escHtml(window._fileSearch)}">
481
600
  </div>
482
601
  <div class="filters" style="margin-bottom:8px">
483
602
  <span class="filter-chip ${sort==='touches'?'active':''}" data-sort="touches">Most touched</span>
484
603
  <span class="filter-chip ${sort==='recent'?'active':''}" data-sort="recent">Recent</span>
485
604
  <span class="filter-chip ${sort==='sessions'?'active':''}" data-sort="sessions">Most sessions</span>
486
- <span class="filter-chip ${sort==='name'?'active':''}" data-sort="name">A-Z</span>
605
+ <span class="filter-chip ${sort==='name'?'active':''}" data-sort="name">A\u2013Z</span>
487
606
  </div>
488
607
  <div class="filters" style="margin-bottom:12px">
489
608
  <span class="filter-chip ext-chip ${!window._fileFilter?'active':''}" data-ext="">All</span>
490
609
  ${exts.map(e => `<span class="filter-chip ext-chip ${window._fileFilter===e?'active':''}" data-ext="${e}">${e}</span>`).join('')}
491
610
  </div>
492
611
  <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
493
- <span style="color:var(--text2);font-size:13px">${files.length} files</span>
494
- <span class="filter-chip ${window._fileGrouped?'active':''}" id="groupToggle" style="cursor:pointer">📂 Group by directory</span>
612
+ <span style="color:var(--text-tertiary);font-size:12px;font-weight:500">${files.length} files</span>
613
+ <span class="filter-chip ${window._fileGrouped?'active':''}" id="groupToggle" style="cursor:pointer">Group by directory</span>
495
614
  </div>
496
615
  <div id="filesList"></div>`;
497
616
 
498
617
  content.innerHTML = html;
618
+ transitionView();
499
619
 
500
- // Render file list
501
620
  const listEl = $('#filesList');
502
621
 
503
622
  if (window._fileGrouped && !q) {
504
- // Group by directory
505
623
  const groups = {};
506
624
  files.forEach(f => {
507
625
  const dir = getFileDir(f.file_path);
@@ -509,7 +627,6 @@ function renderFiles() {
509
627
  groups[dir].push(f);
510
628
  });
511
629
 
512
- // Sort groups by active sort criteria
513
630
  const groupMetric = (files) => {
514
631
  if (sort === 'touches') return files.reduce((s, f) => s + f.touch_count, 0);
515
632
  if (sort === 'sessions') return files.reduce((s, f) => s + f.session_count, 0);
@@ -528,9 +645,9 @@ function renderFiles() {
528
645
  return `
529
646
  <div class="file-group">
530
647
  <div class="file-group-header" data-dir="${escHtml(dir)}">
531
- <span class="file-group-arrow">▶</span>
648
+ <span class="file-group-arrow">\u25b6</span>
532
649
  <span class="file-group-name">~/${escHtml(dir)}</span>
533
- <span style="color:var(--text2);font-size:12px;margin-left:auto">${dirFiles.length} files · ${groupStat}</span>
650
+ <span style="color:var(--text-tertiary);font-size:12px;margin-left:auto">${dirFiles.length} files \u00b7 ${groupStat}</span>
534
651
  </div>
535
652
  <div class="file-group-items" style="display:none">
536
653
  ${dirFiles.map(f => renderFileItem(f)).join('')}
@@ -544,10 +661,10 @@ function renderFiles() {
544
661
  const arrow = h.querySelector('.file-group-arrow');
545
662
  if (items.style.display === 'none') {
546
663
  items.style.display = 'block';
547
- arrow.textContent = '';
664
+ arrow.textContent = '\u25bc';
548
665
  } else {
549
666
  items.style.display = 'none';
550
- arrow.textContent = '';
667
+ arrow.textContent = '\u25b6';
551
668
  }
552
669
  });
553
670
  });
@@ -555,7 +672,6 @@ function renderFiles() {
555
672
  listEl.innerHTML = files.map(f => renderFileItem(f)).join('');
556
673
  }
557
674
 
558
- // Event listeners — must re-attach every render since innerHTML replaces DOM
559
675
  let debounce;
560
676
  const searchInput = $('#fileSearchInput');
561
677
  searchInput.addEventListener('input', e => {
@@ -563,7 +679,6 @@ function renderFiles() {
563
679
  debounce = setTimeout(() => { window._fileSearch = e.target.value; renderFiles(); }, 200);
564
680
  });
565
681
 
566
- // Preserve cursor position after re-render
567
682
  const cursorPos = window._fileCursorPos || 0;
568
683
  searchInput.setSelectionRange(cursorPos, cursorPos);
569
684
  if (window._fileSearch) searchInput.focus();
@@ -582,7 +697,6 @@ function renderFiles() {
582
697
  item.addEventListener('click', () => viewFileDetail(item.dataset.path));
583
698
  });
584
699
 
585
- // Track cursor for re-renders
586
700
  searchInput.addEventListener('keyup', () => { window._fileCursorPos = searchInput.selectionStart; });
587
701
  }
588
702
 
@@ -591,11 +705,11 @@ function renderFileItem(f) {
591
705
  const dir = f.file_path.split('/').slice(0, -1).join('/');
592
706
  return `
593
707
  <div class="file-item" data-path="${escHtml(f.file_path)}">
594
- <div class="file-path"><span style="color:var(--text)">${escHtml(fname)}</span> <span style="color:var(--text2);font-size:12px">${escHtml(dir)}/</span></div>
708
+ <div class="file-path"><span style="color:var(--text-primary);font-weight:500">${escHtml(fname)}</span> <span style="color:var(--text-tertiary);font-size:12px">${escHtml(dir)}/</span></div>
595
709
  <div class="file-meta">
596
710
  <span>${f.touch_count} touches</span>
597
711
  <span>${f.session_count} sessions</span>
598
- <span style="color:var(--orange)">${escHtml(f.operations)}</span>
712
+ <span style="color:var(--amber)">${escHtml(f.operations)}</span>
599
713
  <span class="session-time">${fmtTime(f.last_touched)}</span>
600
714
  </div>
601
715
  </div>
@@ -603,17 +717,18 @@ function renderFileItem(f) {
603
717
  }
604
718
 
605
719
  async function viewFileDetail(filePath) {
606
- content.innerHTML = '<div class="loading">Loading…</div>';
720
+ content.innerHTML = '<div class="loading">Loading</div>';
607
721
  const data = await api(`/files/sessions?path=${encodeURIComponent(filePath)}`);
608
722
 
609
723
  let html = `
610
- <div class="back-btn" id="backBtn">← Back</div>
724
+ <div class="back-btn" id="backBtn">\u2190 Back</div>
611
725
  <div class="page-title" style="word-break:break-all;font-size:16px">${escHtml(filePath)}</div>
612
726
  <div class="section-label">${data.sessions.length} sessions touched this file</div>
613
727
  `;
614
728
 
615
729
  html += data.sessions.map(s => renderSessionItem(s)).join('');
616
730
  content.innerHTML = html;
731
+ transitionView();
617
732
 
618
733
  $('#backBtn').addEventListener('click', () => viewFiles());
619
734
  $$('.session-item').forEach(item => {
@@ -645,7 +760,7 @@ viewSearch();
645
760
  // Swipe right from left edge to go back
646
761
  (function initSwipeBack() {
647
762
  let startX = 0, startY = 0, swiping = false;
648
- const edgeWidth = 30; // px from left edge
763
+ const edgeWidth = 30;
649
764
  const threshold = 80;
650
765
 
651
766
  document.addEventListener('touchstart', e => {
@@ -661,7 +776,6 @@ viewSearch();
661
776
  if (!swiping) return;
662
777
  const dx = e.touches[0].clientX - startX;
663
778
  const dy = Math.abs(e.touches[0].clientY - startY);
664
- // Cancel if vertical movement exceeds horizontal (it's a scroll)
665
779
  if (dy > dx) { swiping = false; }
666
780
  }, { passive: true });
667
781
 
@@ -685,7 +799,7 @@ viewSearch();
685
799
  const indicator = document.createElement('div');
686
800
  indicator.className = 'ptr-indicator';
687
801
  indicator.id = 'ptr';
688
- indicator.textContent = ' Pull to refresh';
802
+ indicator.textContent = '\u2193 Pull to refresh';
689
803
  document.body.appendChild(indicator);
690
804
 
691
805
  document.addEventListener('touchstart', e => {
@@ -700,7 +814,7 @@ viewSearch();
700
814
  const diff = e.touches[0].clientY - startY;
701
815
  if (diff > 20 && window.scrollY <= 0) {
702
816
  indicator.classList.add('visible');
703
- indicator.textContent = diff > threshold ? ' Release to refresh' : ' Pull to refresh';
817
+ indicator.textContent = diff > threshold ? '\u2191 Release to refresh' : '\u2193 Pull to refresh';
704
818
  } else {
705
819
  indicator.classList.remove('visible');
706
820
  }
@@ -711,11 +825,10 @@ viewSearch();
711
825
  pulling = false;
712
826
  const diff = e.changedTouches[0].clientY - startY;
713
827
  if (diff > threshold && indicator.classList.contains('visible')) {
714
- indicator.textContent = 'Refreshing';
828
+ indicator.textContent = 'Refreshing\u2026';
715
829
  indicator.classList.add('refreshing');
716
830
  try {
717
831
  await api('/reindex');
718
- // If viewing a session detail, refresh it in place
719
832
  const backBtn = $('#backBtn');
720
833
  if (backBtn && window._currentSessionId) {
721
834
  await viewSession(window._currentSessionId);