aisessions 1.1.0 → 1.1.2

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/bin/cli.js CHANGED
@@ -3,7 +3,7 @@ import { createServer } from '../src/server.js'
3
3
  import { parseArgs } from 'node:util'
4
4
  import { exec } from 'node:child_process'
5
5
 
6
- const VERSION = '1.1.0'
6
+ const VERSION = '1.1.2'
7
7
 
8
8
  const { values } = parseArgs({
9
9
  options: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aisessions",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Browse, manage and analyze your AI coding agent sessions — Claude Code, Codex, and more",
5
5
  "bin": {
6
6
  "aisessions": "bin/cli.js"
package/src/router.js CHANGED
@@ -3,6 +3,7 @@ import { getAllSessions, getInstalledAgents } from './agents/index.js'
3
3
  import { getUsageData } from './usage/index.js'
4
4
  import { listTrash, moveToTrash, restoreFromTrash, purgeTrash } from './storage/trash.js'
5
5
  import { listBackups, createBackup, restoreBackup, deleteBackup } from './storage/backup.js'
6
+ import { getSessionDetail } from './session/detail.js'
6
7
 
7
8
  function sendJson(res, data, status = 200) {
8
9
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
@@ -60,6 +61,9 @@ export async function router(req, res) {
60
61
  const sessions = await getAllSessions()
61
62
  return sendJson(res, sessions.map(({ date, ...rest }) => rest))
62
63
  }
64
+ if (method === 'GET' && pathname === '/api/session') {
65
+ return sendJson(res, await getSessionDetail(query.path || ''))
66
+ }
63
67
 
64
68
  // ── Usage (supports ?agent=claude) ───────────────────────────────────────
65
69
  if (method === 'GET' && pathname === '/api/usage') {
@@ -0,0 +1,59 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs'
2
+ import { parseJsonl, readLimited } from '../agents/utils.js'
3
+
4
+ const MAX_DETAIL_BYTES = 2_000_000
5
+
6
+ function cleanText(v) {
7
+ return String(v || '').replace(/\s+/g, ' ').trim()
8
+ }
9
+
10
+ function extractText(v) {
11
+ if (typeof v === 'string') return [v]
12
+ if (Array.isArray(v)) return v.flatMap(extractText)
13
+ if (v && typeof v === 'object') {
14
+ const out = []
15
+ if (typeof v.text === 'string') out.push(v.text)
16
+ if (v.content) out.push(...extractText(v.content))
17
+ if (v.message) out.push(...extractText(v.message))
18
+ if (v.parts) out.push(...extractText(v.parts))
19
+ return out
20
+ }
21
+ return []
22
+ }
23
+
24
+ function roleOf(obj) {
25
+ const role = cleanText(obj?.role || obj?.message?.role || obj?.payload?.role).toLowerCase()
26
+ const type = cleanText(obj?.type || obj?.item_type).toLowerCase()
27
+ if (role) return role
28
+ if (type.includes('user')) return 'user'
29
+ if (type.includes('assistant') || type.includes('model')) return 'assistant'
30
+ if (type.includes('system')) return 'system'
31
+ return type || 'entry'
32
+ }
33
+
34
+ function messagesFromObjs(objs) {
35
+ return objs.map((obj, idx) => {
36
+ const text = extractText(obj).map(cleanText).filter(Boolean).join('\n\n')
37
+ return {
38
+ index: idx + 1,
39
+ role: roleOf(obj),
40
+ text: text || JSON.stringify(obj).slice(0, 4000),
41
+ }
42
+ }).filter(m => m.text)
43
+ }
44
+
45
+ export async function getSessionDetail(path) {
46
+ if (!path || !existsSync(path)) return { ok: false, error: 'session file not found' }
47
+ const st = statSync(path)
48
+ if (!st.isFile()) return { ok: false, error: 'session path is not a file' }
49
+ const text = readLimited(path, MAX_DETAIL_BYTES)
50
+ const objs = parseJsonl(text)
51
+ return {
52
+ ok: true,
53
+ path,
54
+ size: st.size,
55
+ truncated: st.size > MAX_DETAIL_BYTES,
56
+ messages: messagesFromObjs(objs),
57
+ raw: text,
58
+ }
59
+ }
@@ -1,12 +1,37 @@
1
1
  import { homedir } from 'node:os'
2
2
  import { existsSync, mkdirSync, readdirSync, writeFileSync, readFileSync, statSync, rmSync, cpSync } from 'node:fs'
3
- import { join, basename } from 'node:path'
3
+ import { dirname, join, basename } from 'node:path'
4
4
 
5
5
  const HOME = homedir()
6
6
  const BACKUP_DIR = join(HOME, '.aisessions', 'backups')
7
7
 
8
8
  function ensureBackup() { mkdirSync(BACKUP_DIR, { recursive: true }) }
9
9
 
10
+ function safePart(v, fallback = 'item') {
11
+ return String(v || fallback).replace(/[^a-z0-9._-]+/gi, '_').replace(/^_+|_+$/g, '').slice(0, 80) || fallback
12
+ }
13
+
14
+ function findMetaFiles(dir) {
15
+ const out = []
16
+ try {
17
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
18
+ const full = join(dir, e.name)
19
+ if (e.isDirectory()) out.push(...findMetaFiles(full))
20
+ else if (e.name.endsWith('.meta.json') || e.name === 'meta.json') out.push(full)
21
+ }
22
+ } catch {}
23
+ return out
24
+ }
25
+
26
+ function removeStoredItem(metaPath, payloadPath) {
27
+ if (basename(metaPath) === 'meta.json') {
28
+ rmSync(dirname(metaPath), { recursive: true, force: true })
29
+ return
30
+ }
31
+ if (payloadPath && existsSync(payloadPath)) rmSync(payloadPath, { recursive: true, force: true })
32
+ if (existsSync(metaPath)) rmSync(metaPath, { force: true })
33
+ }
34
+
10
35
  function normaliseItems(raw) {
11
36
  return (Array.isArray(raw) ? raw : [raw]).map(i =>
12
37
  typeof i === 'string' ? { path: i } : i
@@ -17,9 +42,7 @@ export async function listBackups(agent) {
17
42
  ensureBackup()
18
43
  const items = []
19
44
  try {
20
- for (const f of readdirSync(BACKUP_DIR)) {
21
- if (!f.endsWith('.meta.json')) continue
22
- const mp = join(BACKUP_DIR, f)
45
+ for (const mp of findMetaFiles(BACKUP_DIR)) {
23
46
  try {
24
47
  const meta = JSON.parse(readFileSync(mp, 'utf8'))
25
48
  if (agent && agent !== 'all' && meta.agent && meta.agent !== agent) continue
@@ -39,10 +62,13 @@ export async function createBackup(rawItems, note = '') {
39
62
  if (!existsSync(p)) { results.push({ path: p, ok: false, error: 'not found' }); continue }
40
63
  const ts = Date.now()
41
64
  const name = basename(p)
42
- const backupName = ts + '_' + name
43
- const backupPath = join(BACKUP_DIR, backupName)
44
- const metaPath = join(BACKUP_DIR, backupName + '.meta.json')
65
+ const agentId = safePart(item.agent || 'unknown')
66
+ const backupName = ts + '_' + safePart(item.title || name)
67
+ const itemDir = join(BACKUP_DIR, agentId, backupName)
68
+ const backupPath = join(itemDir, 'payload')
69
+ const metaPath = join(itemDir, 'meta.json')
45
70
  const st = statSync(p)
71
+ mkdirSync(itemDir, { recursive: true })
46
72
  if (st.isDirectory()) cpSync(p, backupPath, { recursive: true })
47
73
  else cpSync(p, backupPath)
48
74
  const meta = {
@@ -72,6 +98,7 @@ export async function restoreBackup(metaPath) {
72
98
  ? meta.originalPath.replace(/(\.[^.]+)?$/, '_restored_' + Date.now() + '$1')
73
99
  : meta.originalPath
74
100
  const st = statSync(meta.backupPath)
101
+ mkdirSync(dirname(dest), { recursive: true })
75
102
  if (st.isDirectory()) cpSync(meta.backupPath, dest, { recursive: true })
76
103
  else cpSync(meta.backupPath, dest)
77
104
  return { ok: true, restoredTo: dest }
@@ -84,8 +111,7 @@ export async function deleteBackup(metaPaths) {
84
111
  try {
85
112
  let backupPath = mp.replace(/\.meta\.json$/, '')
86
113
  try { const m = JSON.parse(readFileSync(mp, 'utf8')); backupPath = m.backupPath || backupPath } catch {}
87
- if (existsSync(backupPath)) rmSync(backupPath, { recursive: true, force: true })
88
- if (existsSync(mp)) rmSync(mp, { force: true })
114
+ removeStoredItem(mp, backupPath)
89
115
  results.push({ metaPath: mp, ok: true })
90
116
  } catch (e) { results.push({ metaPath: mp, ok: false, error: e.message }) }
91
117
  }
@@ -1,12 +1,37 @@
1
1
  import { homedir } from 'node:os'
2
2
  import { existsSync, mkdirSync, readdirSync, writeFileSync, readFileSync, statSync, rmSync, cpSync } from 'node:fs'
3
- import { join } from 'node:path'
3
+ import { basename, dirname, join } from 'node:path'
4
4
 
5
5
  const HOME = homedir()
6
6
  const TRASH_DIR = join(HOME, '.aisessions', 'trash')
7
7
 
8
8
  function ensureTrash() { mkdirSync(TRASH_DIR, { recursive: true }) }
9
9
 
10
+ function safePart(v, fallback = 'item') {
11
+ return String(v || fallback).replace(/[^a-z0-9._-]+/gi, '_').replace(/^_+|_+$/g, '').slice(0, 80) || fallback
12
+ }
13
+
14
+ function findMetaFiles(dir) {
15
+ const out = []
16
+ try {
17
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
18
+ const full = join(dir, e.name)
19
+ if (e.isDirectory()) out.push(...findMetaFiles(full))
20
+ else if (e.name.endsWith('.meta.json') || e.name === 'meta.json') out.push(full)
21
+ }
22
+ } catch {}
23
+ return out
24
+ }
25
+
26
+ function removeStoredItem(metaPath, payloadPath) {
27
+ if (basename(metaPath) === 'meta.json') {
28
+ rmSync(dirname(metaPath), { recursive: true, force: true })
29
+ return
30
+ }
31
+ if (payloadPath && existsSync(payloadPath)) rmSync(payloadPath, { recursive: true, force: true })
32
+ if (existsSync(metaPath)) rmSync(metaPath, { force: true })
33
+ }
34
+
10
35
  // items: array of { path, agent?, agentLabel?, project?, title? }
11
36
  // OR plain strings (backwards compat)
12
37
  function normaliseItems(raw) {
@@ -19,9 +44,7 @@ export async function listTrash(agent) {
19
44
  ensureTrash()
20
45
  const items = []
21
46
  try {
22
- for (const f of readdirSync(TRASH_DIR)) {
23
- if (!f.endsWith('.meta.json')) continue
24
- const mp = join(TRASH_DIR, f)
47
+ for (const mp of findMetaFiles(TRASH_DIR)) {
25
48
  try {
26
49
  const meta = JSON.parse(readFileSync(mp, 'utf8'))
27
50
  if (agent && agent !== 'all' && meta.agent && meta.agent !== agent) continue
@@ -40,10 +63,13 @@ export async function moveToTrash(rawItems) {
40
63
  try {
41
64
  if (!existsSync(p)) { results.push({ path: p, ok: false, error: 'not found' }); continue }
42
65
  const ts = Date.now()
43
- const trashName = ts + '_' + p.replace(/\//g, '_').replace(/^_+/, '').slice(-80)
44
- const trashPath = join(TRASH_DIR, trashName)
45
- const metaPath = join(TRASH_DIR, trashName + '.meta.json')
66
+ const agentId = safePart(item.agent || 'unknown')
67
+ const trashName = ts + '_' + safePart(item.title || p.split('/').pop())
68
+ const itemDir = join(TRASH_DIR, agentId, trashName)
69
+ const trashPath = join(itemDir, 'payload')
70
+ const metaPath = join(itemDir, 'meta.json')
46
71
  const st = statSync(p)
72
+ mkdirSync(itemDir, { recursive: true })
47
73
  if (st.isDirectory()) cpSync(p, trashPath, { recursive: true })
48
74
  else cpSync(p, trashPath)
49
75
  rmSync(p, { recursive: true, force: true })
@@ -70,10 +96,10 @@ export async function restoreFromTrash(metaPath) {
70
96
  if (!existsSync(meta.trashPath)) return { ok: false, error: 'trash file missing' }
71
97
  if (existsSync(meta.originalPath)) return { ok: false, error: 'destination already exists' }
72
98
  const st = statSync(meta.trashPath)
99
+ mkdirSync(dirname(meta.originalPath), { recursive: true })
73
100
  if (st.isDirectory()) cpSync(meta.trashPath, meta.originalPath, { recursive: true })
74
101
  else cpSync(meta.trashPath, meta.originalPath)
75
- rmSync(meta.trashPath, { recursive: true, force: true })
76
- rmSync(metaPath, { force: true })
102
+ removeStoredItem(metaPath, meta.trashPath)
77
103
  return { ok: true, restoredTo: meta.originalPath }
78
104
  } catch (e) { return { ok: false, error: e.message } }
79
105
  }
@@ -84,8 +110,7 @@ export async function purgeTrash(metaPaths) {
84
110
  try {
85
111
  let trashPath = mp.replace(/\.meta\.json$/, '')
86
112
  try { const m = JSON.parse(readFileSync(mp, 'utf8')); trashPath = m.trashPath || trashPath } catch {}
87
- if (existsSync(trashPath)) rmSync(trashPath, { recursive: true, force: true })
88
- if (existsSync(mp)) rmSync(mp, { force: true })
113
+ removeStoredItem(mp, trashPath)
89
114
  results.push({ metaPath: mp, ok: true })
90
115
  } catch (e) { results.push({ metaPath: mp, ok: false, error: e.message }) }
91
116
  }
package/src/ui/css.js CHANGED
@@ -278,7 +278,7 @@ html[data-theme="light"] .credit-pkg{color:rgba(24,24,20,.32)}
278
278
 
279
279
  .tbl-hdr,.tbl-row{
280
280
  display:grid;
281
- grid-template-columns:40px 80px 1fr 80px 80px 150px 82px;
281
+ grid-template-columns:40px 80px 1fr 80px 80px 150px 126px;
282
282
  align-items:center;gap:0 10px;padding:0 16px;
283
283
  }
284
284
  .tbl-hdr{
@@ -440,6 +440,52 @@ html[data-theme="light"] .credit-pkg{color:rgba(24,24,20,.32)}
440
440
  #toast.ok{border-color:rgba(51,204,51,.6);color:var(--success)}
441
441
  #toast.err{border-color:rgba(255,51,51,.6);color:var(--danger)}
442
442
 
443
+ /* ── MODAL / SESSION VIEWER ────────────────────────────────────────────────── */
444
+ .modal{
445
+ position:fixed;inset:0;z-index:100;
446
+ display:flex;align-items:center;justify-content:center;
447
+ background:rgba(0,0,0,.72);
448
+ padding:28px;
449
+ }
450
+ .modal[hidden]{display:none}
451
+ .modal-box{
452
+ width:min(980px,calc(100vw - 56px));
453
+ height:min(760px,calc(100vh - 56px));
454
+ display:flex;flex-direction:column;
455
+ border:2px solid var(--border2);
456
+ background:var(--surf);
457
+ }
458
+ .modal-head{
459
+ display:flex;align-items:center;justify-content:space-between;gap:16px;
460
+ padding:14px 16px;border-bottom:1px solid var(--border);
461
+ flex-shrink:0;
462
+ }
463
+ .modal-title{font-family:var(--logo);font-size:10px;color:var(--accent);letter-spacing:1px}
464
+ .modal-sub{font-size:15px;color:var(--muted);margin-top:5px;word-break:break-all}
465
+ .modal-body{flex:1;overflow:auto;padding:14px;background:var(--bg)}
466
+ .chat-msg{
467
+ border:1px solid var(--border);
468
+ background:var(--surf);
469
+ margin-bottom:10px;
470
+ }
471
+ .chat-role{
472
+ padding:7px 10px;border-bottom:1px solid var(--border);
473
+ color:var(--muted);font-size:14px;letter-spacing:1px;text-transform:uppercase;
474
+ }
475
+ .chat-text{
476
+ padding:10px;
477
+ white-space:pre-wrap;
478
+ word-break:break-word;
479
+ font-size:17px;
480
+ line-height:1.35;
481
+ }
482
+ .chat-raw{
483
+ white-space:pre-wrap;
484
+ word-break:break-word;
485
+ font-size:15px;
486
+ line-height:1.35;
487
+ }
488
+
443
489
  /* ── SCROLLBAR ───────────────────────────────────────────────────────────────── */
444
490
  ::-webkit-scrollbar{width:8px;height:8px}
445
491
  ::-webkit-scrollbar-track{background:var(--bg)}
package/src/ui/js.js CHANGED
@@ -65,7 +65,7 @@ document.addEventListener('click', function (e) {
65
65
  var nav = btn.dataset.nav || '';
66
66
  if (a === 'filter-agent') { setAgentFilter(ag); return; }
67
67
  if (a === 'switch-tab') { switchTab(nav); return; }
68
- if (a === 'agent-tab') { switchTab(nav); return; }
68
+ if (a === 'agent-tab') { switchAgentTab(ag, nav); return; }
69
69
  if (a === 'switch-tab-global') { setAgentFilter('all'); switchTab(nav); return; }
70
70
  if (a === 'toggle-theme') { toggleTheme(); return; }
71
71
  if (a === 'trash-one') { trashOne(p); return; }
@@ -74,6 +74,8 @@ document.addEventListener('click', function (e) {
74
74
  if (a === 'purge-trash') { purgeTrash(meta); return; }
75
75
  if (a === 'restore-backup') { restoreBackup(meta); return; }
76
76
  if (a === 'delete-backup') { deleteBackup(meta); return; }
77
+ if (a === 'view-session') { viewSession(p); return; }
78
+ if (a === 'close-modal') { closeModal(); return; }
77
79
  return;
78
80
  }
79
81
  var row = e.target.closest('.tbl-row');
@@ -156,10 +158,10 @@ function buildSidebar(agents, sessions) {
156
158
  '<span class="agent-dot" style="background:' + esc(a.color) + '"></span>' +
157
159
  esc(a.label) + '<span class="sb-count">' + (counts[a.id] || 0) + '</span></button>' +
158
160
  '<div class="sb-sub" id="sub-' + aid + '">' +
159
- '<button class="sb-sub-item" data-action="agent-tab" data-nav="sessions">[=] SESSIONS</button>' +
160
- '<button class="sb-sub-item" data-action="agent-tab" data-nav="usage">[$] USAGE</button>' +
161
- '<button class="sb-sub-item" data-action="agent-tab" data-nav="trash">[T] TRASH</button>' +
162
- '<button class="sb-sub-item" data-action="agent-tab" data-nav="backups">[B] BACKUPS</button>' +
161
+ '<button class="sb-sub-item" data-action="agent-tab" data-agent="' + aid + '" data-nav="sessions">[=] SESSIONS</button>' +
162
+ '<button class="sb-sub-item" data-action="agent-tab" data-agent="' + aid + '" data-nav="usage">[$] USAGE</button>' +
163
+ '<button class="sb-sub-item" data-action="agent-tab" data-agent="' + aid + '" data-nav="trash">[T] TRASH</button>' +
164
+ '<button class="sb-sub-item" data-action="agent-tab" data-agent="' + aid + '" data-nav="backups">[B] BACKUPS</button>' +
163
165
  '</div>';
164
166
  }).join('');
165
167
  }
@@ -187,6 +189,18 @@ function setAgentFilter(id) {
187
189
  switchTab('sessions');
188
190
  }
189
191
 
192
+ function switchAgentTab(id, tab) {
193
+ if (id && id !== agentFilter) {
194
+ agentFilter = id; PAGE = 0;
195
+ setSidebarActive(id);
196
+ document.querySelectorAll('.sb-sub').forEach(function(el) {
197
+ el.classList.toggle('show', el.id === 'sub-' + id);
198
+ });
199
+ updateContextLabel();
200
+ }
201
+ switchTab(tab || 'sessions');
202
+ }
203
+
190
204
  // ── CONTEXT LABEL (shows current filter scope in each panel) ──────────────────
191
205
  function updateContextLabel() {
192
206
  var label = agentFilter === 'all' ? 'ALL AGENTS' : agentLabel(agentFilter);
@@ -284,6 +298,7 @@ function rowHtml(s) {
284
298
  '<span class="col-size">' + esc(s.sizeLabel || '') + '</span>' +
285
299
  '<span class="col-date">' + esc(s.dateLabel || '') + '</span>' +
286
300
  '<span class="row-actions">' +
301
+ '<button class="act-btn" data-action="view-session" data-path="' + p + '" title="View chat">VIEW</button>' +
287
302
  '<button class="act-btn" data-action="backup-one" data-path="' + p + '" title="Backup">BAK</button>' +
288
303
  '<button class="act-btn del" data-action="trash-one" data-path="' + p + '" title="Trash">DEL</button>' +
289
304
  '</span></div>';
@@ -309,12 +324,15 @@ wireBtn('btn-trash', async function() {
309
324
  var ok = res.filter(function(r) { return r.ok; }).map(function(r) { return r.path; });
310
325
  ok.forEach(function(p) { ALL = ALL.filter(function(s) { return s.path !== p; }); selected.delete(p); });
311
326
  applyFilter(); toast(ok.length + ' MOVED TO TRASH', 'ok');
327
+ if (ok.length) switchTab('trash');
312
328
  });
313
329
  wireBtn('btn-backup', async function() {
314
330
  if (!selected.size) { toast('SELECT SESSIONS FIRST', 'err'); return; }
315
331
  var items = buildItems(Array.from(selected));
316
332
  var res = await post('/api/backup/create', { items: items, note: '' });
317
- toast(res.filter(function(r) { return r.ok; }).length + ' BACKUP(S) CREATED', 'ok');
333
+ var ok = res.filter(function(r) { return r.ok; }).length;
334
+ toast(ok + ' BACKUP(S) CREATED', 'ok');
335
+ if (ok) switchTab('backups');
318
336
  });
319
337
 
320
338
  // Enrich paths with session metadata for storage
@@ -332,12 +350,13 @@ function switchTab(tab) {
332
350
  document.querySelectorAll('.panel').forEach(function(p) { p.classList.toggle('active', p.id === tab + '-panel'); });
333
351
  // Mark active sub-item (agent-specific nav)
334
352
  document.querySelectorAll('.sb-sub-item').forEach(function(el) {
335
- el.classList.toggle('active', el.dataset.nav === tab);
353
+ el.classList.toggle('active', el.dataset.nav === tab && el.dataset.agent === agentFilter);
336
354
  });
337
355
  // Mark global tools active only when agentFilter is all
338
356
  document.querySelectorAll('.sb-item[data-action="switch-tab-global"]').forEach(function(el) {
339
357
  el.classList.toggle('active', el.dataset.nav === tab && agentFilter === 'all');
340
358
  });
359
+ if (tab === 'sessions') applyFilter();
341
360
  if (tab === 'usage') loadUsage(false);
342
361
  if (tab === 'trash') loadTrash(false);
343
362
  if (tab === 'backups') loadBackups(false);
@@ -353,17 +372,55 @@ async function trashOne(path) {
353
372
  var res = await post('/api/trash/move', { items: items });
354
373
  if (res[0] && res[0].ok) {
355
374
  ALL = ALL.filter(function(s) { return s.path !== path; }); selected.delete(path);
356
- applyFilter(); toast('MOVED TO TRASH', 'ok');
375
+ applyFilter(); toast('MOVED TO TRASH', 'ok'); switchTab('trash');
357
376
  } else toast('ERROR: ' + ((res[0] && res[0].error) || '?'), 'err');
358
377
  }
359
378
  async function backupOne(path) {
360
379
  if (!path) return;
361
380
  var items = buildItems([path]);
362
381
  var res = await post('/api/backup/create', { items: items, note: '' });
363
- if (res[0] && res[0].ok) toast('BACKUP CREATED', 'ok');
382
+ if (res[0] && res[0].ok) { toast('BACKUP CREATED', 'ok'); switchTab('backups'); }
364
383
  else toast('ERROR: ' + ((res[0] && res[0].error) || '?'), 'err');
365
384
  }
366
385
 
386
+ // ── SESSION VIEWER ───────────────────────────────────────────────────────────
387
+ function closeModal() {
388
+ var m = $('modal');
389
+ if (m) m.hidden = true;
390
+ }
391
+
392
+ async function viewSession(path) {
393
+ if (!path) return;
394
+ var modal = $('modal'), title = $('modal-title'), sub = $('modal-sub'), body = $('modal-body');
395
+ if (!modal || !body) return;
396
+ modal.hidden = false;
397
+ if (title) title.textContent = 'LOADING SESSION';
398
+ if (sub) sub.textContent = path;
399
+ body.innerHTML = '<div class="loading"><span class="loading-dots">LOADING CHAT</span></div>';
400
+ try {
401
+ var data = await fetch('/api/session?path=' + encodeURIComponent(path)).then(function(r) { return r.json(); });
402
+ if (!data.ok) {
403
+ body.innerHTML = '<div class="empty"><div class="e-icon">[!]</div><div>' + esc(data.error || 'Could not load session') + '</div></div>';
404
+ return;
405
+ }
406
+ if (title) title.textContent = 'SESSION HISTORY';
407
+ if (sub) sub.textContent = data.path + (data.truncated ? ' / preview truncated' : '');
408
+ var messages = data.messages || [];
409
+ if (messages.length) {
410
+ body.innerHTML = messages.slice(0, 200).map(function(m) {
411
+ return '<div class="chat-msg">' +
412
+ '<div class="chat-role">' + esc(m.index + ' / ' + m.role) + '</div>' +
413
+ '<div class="chat-text">' + esc(m.text) + '</div>' +
414
+ '</div>';
415
+ }).join('') + (messages.length > 200 ? '<div class="empty"><div>Showing first 200 entries</div></div>' : '');
416
+ } else {
417
+ body.innerHTML = '<pre class="chat-raw">' + esc(data.raw || '') + '</pre>';
418
+ }
419
+ } catch (e) {
420
+ body.innerHTML = '<div class="empty"><div class="e-icon">[!]</div><div>' + esc(e.message) + '</div></div>';
421
+ }
422
+ }
423
+
367
424
  // ── USAGE TAB ─────────────────────────────────────────────────────────────────
368
425
  async function loadUsage(force) {
369
426
  var key = agentFilter;
@@ -496,7 +553,7 @@ async function loadTrash(force) {
496
553
 
497
554
  async function restoreTrash(mp) {
498
555
  var res = await post('/api/trash/restore', { metaPath: mp });
499
- if (res.ok) { toast('RESTORED TO ' + res.restoredTo, 'ok'); loadTrash(true); }
556
+ if (res.ok) { toast('RESTORED TO ' + res.restoredTo, 'ok'); loadTrash(true); reloadSessions(); }
500
557
  else toast('ERROR: ' + (res.error || '?'), 'err');
501
558
  }
502
559
  async function purgeTrash(mp) {
@@ -539,7 +596,7 @@ async function loadBackups(force) {
539
596
 
540
597
  async function restoreBackup(mp) {
541
598
  var res = await post('/api/backup/restore', { metaPath: mp });
542
- if (res.ok) toast('RESTORED TO ' + res.restoredTo, 'ok');
599
+ if (res.ok) { toast('RESTORED TO ' + res.restoredTo, 'ok'); reloadSessions(); }
543
600
  else toast('ERROR: ' + (res.error || '?'), 'err');
544
601
  }
545
602
  async function deleteBackup(mp) {
@@ -549,6 +606,14 @@ async function deleteBackup(mp) {
549
606
  else toast('ERROR: ' + ((res[0] && res[0].error) || '?'), 'err');
550
607
  }
551
608
 
609
+ async function reloadSessions() {
610
+ try {
611
+ var sessions = await fetch('/api/sessions').then(function(r) { return r.json(); });
612
+ ALL = sessions;
613
+ applyFilter();
614
+ } catch {}
615
+ }
616
+
552
617
  // ── BOOT ──────────────────────────────────────────────────────────────────────
553
618
  init().catch(function(e) { console.error('[AM] fatal:', e); });
554
619
 
package/src/ui/render.js CHANGED
@@ -65,7 +65,7 @@ export function renderPage() {
65
65
  <span>X.com</span>
66
66
  </a>
67
67
  </div>
68
- <div class="credit-pkg">aisessions v1.1</div>
68
+ <div class="credit-pkg">aisessions v1.1.2</div>
69
69
  </div>
70
70
  </nav>
71
71
 
@@ -125,7 +125,7 @@ export function renderPage() {
125
125
 
126
126
  <!-- FOOTER -->
127
127
  <footer id="foot">
128
- <span>AISESSIONS v1.1</span>
128
+ <span>AISESSIONS v1.1.2</span>
129
129
  <span>&nbsp;|&nbsp;</span>
130
130
  <span id="foot-info">LOADING...</span>
131
131
  </footer>
@@ -133,6 +133,18 @@ export function renderPage() {
133
133
  </div><!-- /shell -->
134
134
 
135
135
  <div id="toast"></div>
136
+ <div id="modal" class="modal" hidden>
137
+ <div class="modal-box">
138
+ <div class="modal-head">
139
+ <div>
140
+ <div id="modal-title" class="modal-title">SESSION</div>
141
+ <div id="modal-sub" class="modal-sub"></div>
142
+ </div>
143
+ <button class="btn" data-action="close-modal">CLOSE</button>
144
+ </div>
145
+ <div id="modal-body" class="modal-body"></div>
146
+ </div>
147
+ </div>
136
148
  <script>${PAGE_JS}</script>
137
149
  </body>
138
150
  </html>`