aisessions 1.1.0 → 1.1.4

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
@@ -2,8 +2,13 @@
2
2
  import { createServer } from '../src/server.js'
3
3
  import { parseArgs } from 'node:util'
4
4
  import { exec } from 'node:child_process'
5
+ import pc from 'picocolors'
5
6
 
6
- const VERSION = '1.1.0'
7
+ const VERSION = '1.1.4'
8
+ const DEV_HANDLE = 's41r4j'
9
+ const GITHUB_URL = 'https://github.com/s41r4j/aisessions'
10
+ const ISSUE_URL = 'https://github.com/s41r4j/aisessions/issues'
11
+ const X_URL = 'https://x.com/s41r4j'
7
12
 
8
13
  const { values } = parseArgs({
9
14
  options: {
@@ -44,9 +49,49 @@ if (values.help) {
44
49
  const PORT = parseInt(values.port, 10) || 7878
45
50
  const server = createServer()
46
51
 
52
+ function printBanner(url) {
53
+ const art = [
54
+ ' _ _ ',
55
+ ' __ _ (_) ___ ___ ___ ___ ___(_) ___ _ __ ___ ',
56
+ ' / _` || |/ __|/ _ \\/ __|/ _ \\/ __| |/ _ \\| `_ \\/ __|',
57
+ '| (_| || |\\__ \\ __/\\__ \\ __/\\__ \\ | (_) | | | \\__ \\',
58
+ ' \\__,_||_||___/\\___||___/\\___||___/_|\\___/|_| |_|___/',
59
+ ]
60
+
61
+ console.log('')
62
+ art.forEach(line => console.log(pc.cyan(line)))
63
+ console.log('')
64
+ console.log(`${pc.bold(' aisessions')} ${pc.dim('v' + VERSION)} ${pc.green('ready')}`)
65
+ console.log(` ${pc.dim('Local UI:')} ${pc.underline(url)}`)
66
+ console.log(` ${pc.dim('Data store:')} ~/.aisessions/trash ~/.aisessions/backups`)
67
+ console.log(` ${pc.dim('Developer:')} @${DEV_HANDLE}`)
68
+ console.log(` ${pc.dim('GitHub:')} ${GITHUB_URL}`)
69
+ console.log(` ${pc.dim('X:')} ${X_URL}`)
70
+ console.log(` ${pc.dim('Report issue:')} ${ISSUE_URL}`)
71
+ console.log('')
72
+ }
73
+
74
+ function startStatusLine(url) {
75
+ if (!process.stdout.isTTY) return
76
+ const frames = ['-', '\\\\', '|', '/']
77
+ let i = 0
78
+ const timer = setInterval(() => {
79
+ const frame = frames[i++ % frames.length]
80
+ process.stdout.write(`\\r ${pc.cyan(frame)} ${pc.dim('running at')} ${url} ${pc.dim('Ctrl+C to stop')} `)
81
+ }, 1200)
82
+ process.once('SIGINT', () => {
83
+ clearInterval(timer)
84
+ process.stdout.clearLine?.(0)
85
+ process.stdout.cursorTo?.(0)
86
+ console.log(pc.dim(' stopped aisessions'))
87
+ process.exit(0)
88
+ })
89
+ }
90
+
47
91
  server.listen(PORT, '127.0.0.1', () => {
48
92
  const url = `http://127.0.0.1:${PORT}`
49
- console.log(`\n aisessions ready -> ${url}\n`)
93
+ printBanner(url)
94
+ startStatusLine(url)
50
95
 
51
96
  if (!values['no-open']) {
52
97
  const cmd = process.platform === 'darwin' ? `open "${url}"` :
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aisessions",
3
- "version": "1.1.0",
3
+ "version": "1.1.4",
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"
@@ -27,6 +27,7 @@
27
27
  ],
28
28
  "license": "MIT",
29
29
  "dependencies": {
30
- "ccusage": "^20.0.6"
30
+ "ccusage": "^20.0.6",
31
+ "picocolors": "^1.1.1"
31
32
  }
32
33
  }
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{
@@ -373,7 +373,7 @@ html[data-theme="light"] .credit-pkg{color:rgba(24,24,20,.32)}
373
373
  .ctx-banner .ctx-label{color:var(--accent);letter-spacing:1px}
374
374
 
375
375
  /* ── USAGE PANEL ─────────────────────────────────────────────────────────────── */
376
- #usage-panel{overflow-y:auto;display:block;padding:0}
376
+ #usage-panel{overflow-y:auto;padding:0}
377
377
 
378
378
  .stat-grid{
379
379
  display:grid;
@@ -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,13 @@ 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; }
79
+ if (a === 'select-all') { selectAllSessions(); return; }
80
+ if (a === 'select-none') { selectNoSessions(); return; }
81
+ if (a === 'toggle-group') { toggleGrouping(); return; }
82
+ if (a === 'trash-selected') { trashSelected(); return; }
83
+ if (a === 'backup-selected'){ backupSelected(); return; }
77
84
  return;
78
85
  }
79
86
  var row = e.target.closest('.tbl-row');
@@ -156,10 +163,10 @@ function buildSidebar(agents, sessions) {
156
163
  '<span class="agent-dot" style="background:' + esc(a.color) + '"></span>' +
157
164
  esc(a.label) + '<span class="sb-count">' + (counts[a.id] || 0) + '</span></button>' +
158
165
  '<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>' +
166
+ '<button class="sb-sub-item" data-action="agent-tab" data-agent="' + aid + '" data-nav="sessions">[=] SESSIONS</button>' +
167
+ '<button class="sb-sub-item" data-action="agent-tab" data-agent="' + aid + '" data-nav="usage">[$] USAGE</button>' +
168
+ '<button class="sb-sub-item" data-action="agent-tab" data-agent="' + aid + '" data-nav="trash">[T] TRASH</button>' +
169
+ '<button class="sb-sub-item" data-action="agent-tab" data-agent="' + aid + '" data-nav="backups">[B] BACKUPS</button>' +
163
170
  '</div>';
164
171
  }).join('');
165
172
  }
@@ -187,6 +194,18 @@ function setAgentFilter(id) {
187
194
  switchTab('sessions');
188
195
  }
189
196
 
197
+ function switchAgentTab(id, tab) {
198
+ if (id && id !== agentFilter) {
199
+ agentFilter = id; PAGE = 0;
200
+ setSidebarActive(id);
201
+ document.querySelectorAll('.sb-sub').forEach(function(el) {
202
+ el.classList.toggle('show', el.id === 'sub-' + id);
203
+ });
204
+ updateContextLabel();
205
+ }
206
+ switchTab(tab || 'sessions');
207
+ }
208
+
190
209
  // ── CONTEXT LABEL (shows current filter scope in each panel) ──────────────────
191
210
  function updateContextLabel() {
192
211
  var label = agentFilter === 'all' ? 'ALL AGENTS' : agentLabel(agentFilter);
@@ -284,38 +303,67 @@ function rowHtml(s) {
284
303
  '<span class="col-size">' + esc(s.sizeLabel || '') + '</span>' +
285
304
  '<span class="col-date">' + esc(s.dateLabel || '') + '</span>' +
286
305
  '<span class="row-actions">' +
306
+ '<button class="act-btn" data-action="view-session" data-path="' + p + '" title="View chat">VIEW</button>' +
287
307
  '<button class="act-btn" data-action="backup-one" data-path="' + p + '" title="Backup">BAK</button>' +
288
308
  '<button class="act-btn del" data-action="trash-one" data-path="' + p + '" title="Trash">DEL</button>' +
289
309
  '</span></div>';
290
310
  }
291
311
 
292
312
  // ── TOOLBAR ───────────────────────────────────────────────────────────────────
293
- function wireBtn(id, fn) { var el = $(id); if (el) el.addEventListener('click', fn); }
313
+ function selectAllSessions() {
314
+ filtered.forEach(function(s) { selected.add(s.path); });
315
+ renderSessions();
316
+ }
317
+
318
+ function selectNoSessions() {
319
+ selected.clear();
320
+ renderSessions();
321
+ }
294
322
 
295
- wireBtn('btn-sel-all', function() { filtered.forEach(function(s) { selected.add(s.path); }); renderSessions(); });
296
- wireBtn('btn-sel-none', function() { selected.clear(); renderSessions(); });
297
- wireBtn('btn-group', function() {
323
+ function toggleGrouping() {
298
324
  grouped = !grouped;
299
325
  var el = $('btn-group');
300
326
  if (el) { el.textContent = grouped ? 'FLAT VIEW' : 'GROUP VIEW'; el.classList.toggle('active-btn', !grouped); }
301
- PAGE = 0; renderSessions();
302
- });
327
+ PAGE = 0;
328
+ renderSessions();
329
+ }
303
330
 
304
- wireBtn('btn-trash', async function() {
331
+ async function trashSelected() {
305
332
  if (!selected.size) { toast('SELECT SESSIONS FIRST', 'err'); return; }
306
333
  if (!confirm('Move ' + selected.size + ' session(s) to trash?')) return;
307
334
  var items = buildItems(Array.from(selected));
308
- var res = await post('/api/trash/move', { items: items });
335
+ var res;
336
+ try { res = await post('/api/trash/move', { items: items }); }
337
+ catch (e) { toast('TRASH FAILED: ' + e.message, 'err', 5000); return; }
338
+ if (!Array.isArray(res)) { toast('TRASH FAILED: INVALID SERVER RESPONSE', 'err', 5000); return; }
309
339
  var ok = res.filter(function(r) { return r.ok; }).map(function(r) { return r.path; });
340
+ var fail = res.filter(function(r) { return !r.ok; });
310
341
  ok.forEach(function(p) { ALL = ALL.filter(function(s) { return s.path !== p; }); selected.delete(p); });
311
- applyFilter(); toast(ok.length + ' MOVED TO TRASH', 'ok');
312
- });
313
- wireBtn('btn-backup', async function() {
342
+ applyFilter();
343
+ if (ok.length) {
344
+ toast(ok.length + ' MOVED TO TRASH' + (fail.length ? ' / ' + fail.length + ' FAILED' : ''), fail.length ? 'err' : 'ok');
345
+ switchTab('trash');
346
+ } else {
347
+ toast('TRASH FAILED: ' + ((fail[0] && fail[0].error) || 'NO ITEMS MOVED'), 'err', 5000);
348
+ }
349
+ }
350
+
351
+ async function backupSelected() {
314
352
  if (!selected.size) { toast('SELECT SESSIONS FIRST', 'err'); return; }
315
353
  var items = buildItems(Array.from(selected));
316
- var res = await post('/api/backup/create', { items: items, note: '' });
317
- toast(res.filter(function(r) { return r.ok; }).length + ' BACKUP(S) CREATED', 'ok');
318
- });
354
+ var res;
355
+ try { res = await post('/api/backup/create', { items: items, note: '' }); }
356
+ catch (e) { toast('BACKUP FAILED: ' + e.message, 'err', 5000); return; }
357
+ if (!Array.isArray(res)) { toast('BACKUP FAILED: INVALID SERVER RESPONSE', 'err', 5000); return; }
358
+ var ok = res.filter(function(r) { return r.ok; });
359
+ var fail = res.filter(function(r) { return !r.ok; });
360
+ if (ok.length) {
361
+ toast(ok.length + ' BACKUP(S) CREATED' + (fail.length ? ' / ' + fail.length + ' FAILED' : ''), fail.length ? 'err' : 'ok');
362
+ switchTab('backups');
363
+ } else {
364
+ toast('BACKUP FAILED: ' + ((fail[0] && fail[0].error) || 'NO BACKUPS CREATED'), 'err', 5000);
365
+ }
366
+ }
319
367
 
320
368
  // Enrich paths with session metadata for storage
321
369
  function buildItems(paths) {
@@ -332,12 +380,13 @@ function switchTab(tab) {
332
380
  document.querySelectorAll('.panel').forEach(function(p) { p.classList.toggle('active', p.id === tab + '-panel'); });
333
381
  // Mark active sub-item (agent-specific nav)
334
382
  document.querySelectorAll('.sb-sub-item').forEach(function(el) {
335
- el.classList.toggle('active', el.dataset.nav === tab);
383
+ el.classList.toggle('active', el.dataset.nav === tab && el.dataset.agent === agentFilter);
336
384
  });
337
385
  // Mark global tools active only when agentFilter is all
338
386
  document.querySelectorAll('.sb-item[data-action="switch-tab-global"]').forEach(function(el) {
339
387
  el.classList.toggle('active', el.dataset.nav === tab && agentFilter === 'all');
340
388
  });
389
+ if (tab === 'sessions') applyFilter();
341
390
  if (tab === 'usage') loadUsage(false);
342
391
  if (tab === 'trash') loadTrash(false);
343
392
  if (tab === 'backups') loadBackups(false);
@@ -350,20 +399,64 @@ document.querySelectorAll('.tab').forEach(function(t) {
350
399
  async function trashOne(path) {
351
400
  if (!path || !confirm('Move to trash?')) return;
352
401
  var items = buildItems([path]);
353
- var res = await post('/api/trash/move', { items: items });
402
+ var res;
403
+ try { res = await post('/api/trash/move', { items: items }); }
404
+ catch (e) { toast('ERROR: ' + e.message, 'err', 5000); return; }
405
+ if (!Array.isArray(res)) { toast('ERROR: INVALID SERVER RESPONSE', 'err', 5000); return; }
354
406
  if (res[0] && res[0].ok) {
355
407
  ALL = ALL.filter(function(s) { return s.path !== path; }); selected.delete(path);
356
- applyFilter(); toast('MOVED TO TRASH', 'ok');
408
+ applyFilter(); toast('MOVED TO TRASH', 'ok'); switchTab('trash');
357
409
  } else toast('ERROR: ' + ((res[0] && res[0].error) || '?'), 'err');
358
410
  }
359
411
  async function backupOne(path) {
360
412
  if (!path) return;
361
413
  var items = buildItems([path]);
362
- var res = await post('/api/backup/create', { items: items, note: '' });
363
- if (res[0] && res[0].ok) toast('BACKUP CREATED', 'ok');
414
+ var res;
415
+ try { res = await post('/api/backup/create', { items: items, note: '' }); }
416
+ catch (e) { toast('ERROR: ' + e.message, 'err', 5000); return; }
417
+ if (!Array.isArray(res)) { toast('ERROR: INVALID SERVER RESPONSE', 'err', 5000); return; }
418
+ if (res[0] && res[0].ok) { toast('BACKUP CREATED', 'ok'); switchTab('backups'); }
364
419
  else toast('ERROR: ' + ((res[0] && res[0].error) || '?'), 'err');
365
420
  }
366
421
 
422
+ // ── SESSION VIEWER ───────────────────────────────────────────────────────────
423
+ function closeModal() {
424
+ var m = $('modal');
425
+ if (m) m.hidden = true;
426
+ }
427
+
428
+ async function viewSession(path) {
429
+ if (!path) return;
430
+ var modal = $('modal'), title = $('modal-title'), sub = $('modal-sub'), body = $('modal-body');
431
+ if (!modal || !body) return;
432
+ modal.hidden = false;
433
+ if (title) title.textContent = 'LOADING SESSION';
434
+ if (sub) sub.textContent = path;
435
+ body.innerHTML = '<div class="loading"><span class="loading-dots">LOADING CHAT</span></div>';
436
+ try {
437
+ var data = await fetch('/api/session?path=' + encodeURIComponent(path)).then(function(r) { return r.json(); });
438
+ if (!data.ok) {
439
+ body.innerHTML = '<div class="empty"><div class="e-icon">[!]</div><div>' + esc(data.error || 'Could not load session') + '</div></div>';
440
+ return;
441
+ }
442
+ if (title) title.textContent = 'SESSION HISTORY';
443
+ if (sub) sub.textContent = data.path + (data.truncated ? ' / preview truncated' : '');
444
+ var messages = data.messages || [];
445
+ if (messages.length) {
446
+ body.innerHTML = messages.slice(0, 200).map(function(m) {
447
+ return '<div class="chat-msg">' +
448
+ '<div class="chat-role">' + esc(m.index + ' / ' + m.role) + '</div>' +
449
+ '<div class="chat-text">' + esc(m.text) + '</div>' +
450
+ '</div>';
451
+ }).join('') + (messages.length > 200 ? '<div class="empty"><div>Showing first 200 entries</div></div>' : '');
452
+ } else {
453
+ body.innerHTML = '<pre class="chat-raw">' + esc(data.raw || '') + '</pre>';
454
+ }
455
+ } catch (e) {
456
+ body.innerHTML = '<div class="empty"><div class="e-icon">[!]</div><div>' + esc(e.message) + '</div></div>';
457
+ }
458
+ }
459
+
367
460
  // ── USAGE TAB ─────────────────────────────────────────────────────────────────
368
461
  async function loadUsage(force) {
369
462
  var key = agentFilter;
@@ -496,7 +589,7 @@ async function loadTrash(force) {
496
589
 
497
590
  async function restoreTrash(mp) {
498
591
  var res = await post('/api/trash/restore', { metaPath: mp });
499
- if (res.ok) { toast('RESTORED TO ' + res.restoredTo, 'ok'); loadTrash(true); }
592
+ if (res.ok) { toast('RESTORED TO ' + res.restoredTo, 'ok'); loadTrash(true); reloadSessions(); }
500
593
  else toast('ERROR: ' + (res.error || '?'), 'err');
501
594
  }
502
595
  async function purgeTrash(mp) {
@@ -539,7 +632,7 @@ async function loadBackups(force) {
539
632
 
540
633
  async function restoreBackup(mp) {
541
634
  var res = await post('/api/backup/restore', { metaPath: mp });
542
- if (res.ok) toast('RESTORED TO ' + res.restoredTo, 'ok');
635
+ if (res.ok) { toast('RESTORED TO ' + res.restoredTo, 'ok'); reloadSessions(); }
543
636
  else toast('ERROR: ' + (res.error || '?'), 'err');
544
637
  }
545
638
  async function deleteBackup(mp) {
@@ -549,6 +642,14 @@ async function deleteBackup(mp) {
549
642
  else toast('ERROR: ' + ((res[0] && res[0].error) || '?'), 'err');
550
643
  }
551
644
 
645
+ async function reloadSessions() {
646
+ try {
647
+ var sessions = await fetch('/api/sessions').then(function(r) { return r.json(); });
648
+ ALL = sessions;
649
+ applyFilter();
650
+ } catch {}
651
+ }
652
+
552
653
  // ── BOOT ──────────────────────────────────────────────────────────────────────
553
654
  init().catch(function(e) { console.error('[AM] fatal:', e); });
554
655
 
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.4</div>
69
69
  </div>
70
70
  </nav>
71
71
 
@@ -86,14 +86,14 @@ export function renderPage() {
86
86
  <!-- ── Sessions ── -->
87
87
  <div id="sessions-panel" class="panel active">
88
88
  <div id="toolbar">
89
- <button class="btn" id="btn-sel-all">SELECT ALL</button>
90
- <button class="btn" id="btn-sel-none">DESELECT</button>
91
- <button class="btn" id="btn-group">FLAT VIEW</button>
89
+ <button class="btn" id="btn-sel-all" data-action="select-all">SELECT ALL</button>
90
+ <button class="btn" id="btn-sel-none" data-action="select-none">DESELECT</button>
91
+ <button class="btn" id="btn-group" data-action="toggle-group">FLAT VIEW</button>
92
92
  <span class="spacer"></span>
93
93
  <span id="sel-info">...</span>
94
94
  <span class="spacer"></span>
95
- <button class="btn success" id="btn-backup">BACKUP SEL</button>
96
- <button class="btn danger" id="btn-trash">TRASH SEL</button>
95
+ <button class="btn success" id="btn-backup" data-action="backup-selected">BACKUP SEL</button>
96
+ <button class="btn danger" id="btn-trash" data-action="trash-selected">TRASH SEL</button>
97
97
  </div>
98
98
  <div id="tbl-wrap">
99
99
  <div class="tbl-hdr">
@@ -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.4</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>`