codedash-app 1.4.0 → 1.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codedash-app",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Termius-style browser dashboard for Claude Code sessions. View, search, resume, and delete sessions with a dark-themed UI.",
5
5
  "bin": {
6
6
  "codedash": "./bin/cli.js"
package/src/data.js CHANGED
@@ -157,7 +157,7 @@ function loadSessions() {
157
157
  // Enrich Claude sessions with detail file info
158
158
  for (const [sid, s] of Object.entries(sessions)) {
159
159
  if (s.tool !== 'claude') continue;
160
- const projectKey = s.project.replace(/\//g, '-').replace(/^-/, '');
160
+ const projectKey = s.project.replace(/[\/\.]/g, '-');
161
161
  const sessionFile = path.join(PROJECTS_DIR, projectKey, `${sid}.jsonl`);
162
162
  if (fs.existsSync(sessionFile)) {
163
163
  s.has_detail = true;
@@ -192,33 +192,33 @@ function loadSessions() {
192
192
  }
193
193
 
194
194
  function loadSessionDetail(sessionId, project) {
195
- const projectKey = project.replace(/\//g, '-').replace(/^-/, '');
196
- const sessionFile = path.join(PROJECTS_DIR, projectKey, `${sessionId}.jsonl`);
197
-
198
- if (!fs.existsSync(sessionFile)) {
199
- return { error: 'Session file not found', messages: [] };
200
- }
195
+ const found = findSessionFile(sessionId, project);
196
+ if (!found) return { error: 'Session file not found', messages: [] };
201
197
 
202
198
  const messages = [];
203
- const lines = fs.readFileSync(sessionFile, 'utf8').split('\n').filter(Boolean);
199
+ const lines = fs.readFileSync(found.file, 'utf8').split('\n').filter(Boolean);
204
200
 
205
201
  for (const line of lines) {
206
202
  try {
207
203
  const entry = JSON.parse(line);
208
- if (entry.type === 'user' || entry.type === 'assistant') {
209
- const msg = entry.message || {};
210
- let content = msg.content || '';
211
- if (Array.isArray(content)) {
212
- content = content
213
- .map(b => (typeof b === 'string' ? b : (b.type === 'text' ? b.text : '')))
214
- .filter(Boolean)
215
- .join('\n');
204
+
205
+ if (found.format === 'claude') {
206
+ if (entry.type === 'user' || entry.type === 'assistant') {
207
+ const content = extractContent((entry.message || {}).content);
208
+ if (content) {
209
+ messages.push({ role: entry.type, content: content.slice(0, 2000), uuid: entry.uuid || '' });
210
+ }
211
+ }
212
+ } else {
213
+ if (entry.type === 'response_item' && entry.payload) {
214
+ const role = entry.payload.role;
215
+ if (role === 'user' || role === 'assistant') {
216
+ const content = extractContent(entry.payload.content);
217
+ if (content && !content.startsWith('<permissions') && !content.startsWith('<environment_context') && !content.startsWith('<collaboration_mode')) {
218
+ messages.push({ role: role, content: content.slice(0, 2000), uuid: '' });
219
+ }
220
+ }
216
221
  }
217
- messages.push({
218
- role: entry.type,
219
- content: content.slice(0, 2000),
220
- uuid: entry.uuid || '',
221
- });
222
222
  }
223
223
  } catch {}
224
224
  }
@@ -230,7 +230,7 @@ function deleteSession(sessionId, project) {
230
230
  const deleted = [];
231
231
 
232
232
  // 1. Remove session JSONL file from project dir
233
- const projectKey = project.replace(/\//g, '-').replace(/^-/, '');
233
+ const projectKey = project.replace(/[\/\.]/g, '-');
234
234
  const sessionFile = path.join(PROJECTS_DIR, projectKey, `${sessionId}.jsonl`);
235
235
  if (fs.existsSync(sessionFile)) {
236
236
  fs.unlinkSync(sessionFile);
@@ -299,7 +299,7 @@ function getGitCommits(projectDir, fromTs, toTs) {
299
299
  }
300
300
 
301
301
  function exportSessionMarkdown(sessionId, project) {
302
- const projectKey = project.replace(/\//g, '-').replace(/^-/, '');
302
+ const projectKey = project.replace(/[\/\.]/g, '-');
303
303
  const sessionFile = path.join(PROJECTS_DIR, projectKey, `${sessionId}.jsonl`);
304
304
 
305
305
  if (!fs.existsSync(sessionFile)) {
@@ -330,6 +330,155 @@ function exportSessionMarkdown(sessionId, project) {
330
330
  return parts.join('');
331
331
  }
332
332
 
333
+ // ── Session Preview (first N messages, lightweight) ────────
334
+
335
+ function findSessionFile(sessionId, project) {
336
+ // Try Claude projects dir
337
+ if (project) {
338
+ const projectKey = project.replace(/[\/\.]/g, '-');
339
+ const claudeFile = path.join(PROJECTS_DIR, projectKey, `${sessionId}.jsonl`);
340
+ if (fs.existsSync(claudeFile)) return { file: claudeFile, format: 'claude' };
341
+ }
342
+
343
+ // Try all Claude project dirs
344
+ if (fs.existsSync(PROJECTS_DIR)) {
345
+ for (const proj of fs.readdirSync(PROJECTS_DIR)) {
346
+ const f = path.join(PROJECTS_DIR, proj, `${sessionId}.jsonl`);
347
+ if (fs.existsSync(f)) return { file: f, format: 'claude' };
348
+ }
349
+ }
350
+
351
+ // Try Codex sessions dir (walk year/month/day)
352
+ const codexSessionsDir = path.join(CODEX_DIR, 'sessions');
353
+ if (fs.existsSync(codexSessionsDir)) {
354
+ const walkDir = (dir) => {
355
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
356
+ const full = path.join(dir, entry.name);
357
+ if (entry.isDirectory()) {
358
+ const result = walkDir(full);
359
+ if (result) return result;
360
+ } else if (entry.name.includes(sessionId) && entry.name.endsWith('.jsonl')) {
361
+ return full;
362
+ }
363
+ }
364
+ return null;
365
+ };
366
+ const codexFile = walkDir(codexSessionsDir);
367
+ if (codexFile) return { file: codexFile, format: 'codex' };
368
+ }
369
+
370
+ return null;
371
+ }
372
+
373
+ function extractContent(raw) {
374
+ if (!raw) return '';
375
+ if (typeof raw === 'string') return raw;
376
+ if (Array.isArray(raw)) {
377
+ return raw
378
+ .map(b => (typeof b === 'string' ? b : (b.text || b.input_text || '')))
379
+ .filter(Boolean)
380
+ .join('\n');
381
+ }
382
+ return String(raw);
383
+ }
384
+
385
+ function getSessionPreview(sessionId, project, limit) {
386
+ limit = limit || 10;
387
+ const found = findSessionFile(sessionId, project);
388
+ if (!found) return [];
389
+
390
+ const messages = [];
391
+ const lines = fs.readFileSync(found.file, 'utf8').split('\n').filter(Boolean);
392
+
393
+ for (const line of lines) {
394
+ if (messages.length >= limit) break;
395
+ try {
396
+ const entry = JSON.parse(line);
397
+
398
+ if (found.format === 'claude') {
399
+ // Claude: {type: "user"|"assistant", message: {content: ...}}
400
+ if (entry.type === 'user' || entry.type === 'assistant') {
401
+ const content = extractContent((entry.message || {}).content);
402
+ if (content) {
403
+ messages.push({ role: entry.type, content: content.slice(0, 300) });
404
+ }
405
+ }
406
+ } else {
407
+ // Codex: {type: "response_item", payload: {role: "user"|"assistant", content: [...]}}
408
+ if (entry.type === 'response_item' && entry.payload) {
409
+ const role = entry.payload.role;
410
+ if (role === 'user' || role === 'assistant') {
411
+ const content = extractContent(entry.payload.content);
412
+ // Skip system-like messages
413
+ if (content && !content.startsWith('<permissions') && !content.startsWith('<environment_context') && !content.startsWith('<collaboration_mode')) {
414
+ messages.push({ role: role, content: content.slice(0, 300) });
415
+ }
416
+ }
417
+ }
418
+ }
419
+ } catch {}
420
+ }
421
+
422
+ return messages;
423
+ }
424
+
425
+ // ── Full-text search across all sessions ──────────────────
426
+
427
+ function searchFullText(query, sessions) {
428
+ if (!query || query.length < 2) return [];
429
+ const q = query.toLowerCase();
430
+ const results = [];
431
+
432
+ for (const s of sessions) {
433
+ if (s.tool !== 'claude' || !s.has_detail) continue;
434
+
435
+ const projectKey = s.project.replace(/[\/\.]/g, '-');
436
+ const sessionFile = path.join(PROJECTS_DIR, projectKey, `${s.id}.jsonl`);
437
+ if (!fs.existsSync(sessionFile)) continue;
438
+
439
+ try {
440
+ const data = fs.readFileSync(sessionFile, 'utf8');
441
+ // Quick check before parsing
442
+ if (data.toLowerCase().indexOf(q) === -1) continue;
443
+
444
+ // Find matching messages
445
+ const lines = data.split('\n').filter(Boolean);
446
+ const matches = [];
447
+ for (const line of lines) {
448
+ if (matches.length >= 3) break; // max 3 matches per session
449
+ try {
450
+ const entry = JSON.parse(line);
451
+ if (entry.type !== 'user' && entry.type !== 'assistant') continue;
452
+ const msg = entry.message || {};
453
+ let content = msg.content || '';
454
+ if (Array.isArray(content)) {
455
+ content = content
456
+ .map(b => (typeof b === 'string' ? b : (b.type === 'text' ? b.text : '')))
457
+ .filter(Boolean)
458
+ .join('\n');
459
+ }
460
+ if (content.toLowerCase().indexOf(q) >= 0) {
461
+ // Extract snippet around match
462
+ const idx = content.toLowerCase().indexOf(q);
463
+ const start = Math.max(0, idx - 50);
464
+ const end = Math.min(content.length, idx + q.length + 50);
465
+ matches.push({
466
+ role: entry.type,
467
+ snippet: (start > 0 ? '...' : '') + content.slice(start, end) + (end < content.length ? '...' : ''),
468
+ });
469
+ }
470
+ } catch {}
471
+ }
472
+
473
+ if (matches.length > 0) {
474
+ results.push({ sessionId: s.id, matches });
475
+ }
476
+ } catch {}
477
+ }
478
+
479
+ return results;
480
+ }
481
+
333
482
  // ── Exports ────────────────────────────────────────────────
334
483
 
335
484
  module.exports = {
@@ -338,7 +487,8 @@ module.exports = {
338
487
  deleteSession,
339
488
  getGitCommits,
340
489
  exportSessionMarkdown,
341
- // Expose constants for consumers that need them
490
+ getSessionPreview,
491
+ searchFullText,
342
492
  CLAUDE_DIR,
343
493
  CODEX_DIR,
344
494
  HISTORY_FILE,
@@ -288,6 +288,12 @@ function applyFilters() {
288
288
  function onSearch(val) {
289
289
  searchQuery = val;
290
290
  applyFilters();
291
+
292
+ // Trigger deep search after debounce
293
+ clearTimeout(deepSearchTimeout);
294
+ if (val && val.length >= 3) {
295
+ deepSearchTimeout = setTimeout(function() { deepSearch(val); }, 600);
296
+ }
291
297
  }
292
298
 
293
299
  function onTagFilter(val) {
@@ -379,12 +385,15 @@ function renderCard(s, idx) {
379
385
  html += '<span class="card-meta">' + escHtml(s.last_time || '') + '</span>';
380
386
  html += '<span class="card-id">' + s.id.slice(0, 8) + '</span>';
381
387
  // Tags
382
- if (tagHtml || true) {
383
- html += '<span class="card-tags">' + tagHtml;
384
- html += '<button class="tag-add-btn" onclick="showTagDropdown(event, \'' + s.id + '\')" title="Add tag">+</button>';
385
- html += '</span>';
388
+ html += '<span class="card-tags">' + tagHtml;
389
+ html += '<button class="tag-add-btn" onclick="showTagDropdown(event, \'' + s.id + '\')" title="Add tag">+</button>';
390
+ html += '</span>';
391
+ if (s.has_detail) {
392
+ html += '<button class="card-expand-btn" onclick="event.stopPropagation();toggleExpand(\'' + s.id + '\',\'' + escHtml(s.project || '').replace(/'/g, "\\'") + '\',this)" title="Preview messages">&#9662;</button>';
386
393
  }
387
394
  html += '</div>';
395
+ // Expandable preview area (hidden by default)
396
+ html += '<div class="card-preview-area" id="preview-' + s.id + '"></div>';
388
397
  html += '</div>';
389
398
  return html;
390
399
  }
@@ -425,6 +434,177 @@ function renderListCard(s, idx) {
425
434
  return html;
426
435
  }
427
436
 
437
+ // ── Card expand (inline preview) ──────────────────────────────
438
+
439
+ async function toggleExpand(sessionId, project, btn) {
440
+ var area = document.getElementById('preview-' + sessionId);
441
+ if (!area) return;
442
+
443
+ if (area.classList.contains('open')) {
444
+ area.classList.remove('open');
445
+ area.innerHTML = '';
446
+ btn.innerHTML = '&#9662;';
447
+ return;
448
+ }
449
+
450
+ btn.innerHTML = '&#8987;';
451
+ area.innerHTML = '<div class="loading">Loading...</div>';
452
+ area.classList.add('open');
453
+
454
+ try {
455
+ var resp = await fetch('/api/preview/' + sessionId + '?project=' + encodeURIComponent(project) + '&limit=10');
456
+ var messages = await resp.json();
457
+
458
+ if (messages.length === 0) {
459
+ area.innerHTML = '<div class="preview-empty">No messages</div>';
460
+ } else {
461
+ var html = '';
462
+ messages.forEach(function(m) {
463
+ var cls = m.role === 'user' ? 'preview-user' : 'preview-assistant';
464
+ var label = m.role === 'user' ? 'You' : 'AI';
465
+ html += '<div class="preview-msg ' + cls + '">';
466
+ html += '<span class="preview-role">' + label + '</span> ';
467
+ html += escHtml(m.content);
468
+ html += '</div>';
469
+ });
470
+ area.innerHTML = html;
471
+ }
472
+ btn.innerHTML = '&#9652;';
473
+ } catch (e) {
474
+ area.innerHTML = '<div class="preview-empty">Failed to load</div>';
475
+ btn.innerHTML = '&#9662;';
476
+ }
477
+ }
478
+
479
+ // ── Hover tooltip (show first messages on hover) ──────────────
480
+
481
+ var hoverTimer = null;
482
+ var hoverTooltip = null;
483
+
484
+ function initHoverPreview() {
485
+ document.addEventListener('mouseover', function(e) {
486
+ var card = e.target.closest('.card');
487
+ if (!card) { hideHoverTooltip(); return; }
488
+
489
+ var id = card.getAttribute('data-id');
490
+ if (!id) return;
491
+
492
+ clearTimeout(hoverTimer);
493
+ hoverTimer = setTimeout(function() {
494
+ var s = allSessions.find(function(x) { return x.id === id; });
495
+ if (!s || !s.has_detail) return;
496
+ showHoverTooltip(card, s);
497
+ }, 400); // 400ms delay
498
+ });
499
+
500
+ document.addEventListener('mouseout', function(e) {
501
+ var card = e.target.closest('.card');
502
+ if (!card) { clearTimeout(hoverTimer); hideHoverTooltip(); }
503
+ });
504
+ }
505
+
506
+ async function showHoverTooltip(card, session) {
507
+ hideHoverTooltip();
508
+
509
+ try {
510
+ var resp = await fetch('/api/preview/' + session.id + '?project=' + encodeURIComponent(session.project || '') + '&limit=6');
511
+ var messages = await resp.json();
512
+ if (messages.length === 0) return;
513
+
514
+ var tip = document.createElement('div');
515
+ tip.className = 'hover-tooltip';
516
+
517
+ var html = '';
518
+ messages.forEach(function(m) {
519
+ var label = m.role === 'user' ? 'You' : 'AI';
520
+ var cls = m.role === 'user' ? 'preview-user' : 'preview-assistant';
521
+ html += '<div class="preview-msg ' + cls + '">';
522
+ html += '<span class="preview-role">' + label + '</span> ';
523
+ html += escHtml(m.content.slice(0, 150));
524
+ if (m.content.length > 150) html += '...';
525
+ html += '</div>';
526
+ });
527
+ tip.innerHTML = html;
528
+
529
+ document.body.appendChild(tip);
530
+ hoverTooltip = tip;
531
+
532
+ // Position near card
533
+ var rect = card.getBoundingClientRect();
534
+ tip.style.top = Math.min(rect.bottom + 4, window.innerHeight - tip.offsetHeight - 8) + 'px';
535
+ tip.style.left = Math.max(8, rect.left) + 'px';
536
+ tip.style.maxWidth = Math.min(500, window.innerWidth - rect.left - 20) + 'px';
537
+
538
+ requestAnimationFrame(function() { tip.classList.add('visible'); });
539
+ } catch {}
540
+ }
541
+
542
+ function hideHoverTooltip() {
543
+ if (hoverTooltip) {
544
+ hoverTooltip.remove();
545
+ hoverTooltip = null;
546
+ }
547
+ }
548
+
549
+ // ── Deep search (full-text across session content) ────────────
550
+
551
+ var deepSearchCache = {};
552
+ var deepSearchTimeout = null;
553
+
554
+ async function deepSearch(query) {
555
+ if (!query || query.length < 3) return;
556
+ if (deepSearchCache[query]) {
557
+ applyDeepSearchResults(deepSearchCache[query]);
558
+ return;
559
+ }
560
+
561
+ try {
562
+ var resp = await fetch('/api/search?q=' + encodeURIComponent(query));
563
+ var results = await resp.json();
564
+ deepSearchCache[query] = results;
565
+ applyDeepSearchResults(results);
566
+ } catch {}
567
+ }
568
+
569
+ function applyDeepSearchResults(results) {
570
+ if (!results || results.length === 0) return;
571
+
572
+ // Highlight matching session IDs in filtered list
573
+ var matchIds = results.map(function(r) { return r.sessionId; });
574
+
575
+ // Boost matching sessions to top if not already visible
576
+ var boosted = [];
577
+ var rest = [];
578
+ filteredSessions.forEach(function(s) {
579
+ if (matchIds.indexOf(s.id) >= 0) {
580
+ s._deepMatch = results.find(function(r) { return r.sessionId === s.id; });
581
+ boosted.push(s);
582
+ } else {
583
+ rest.push(s);
584
+ }
585
+ });
586
+
587
+ // Also add sessions that weren't in filteredSessions but match
588
+ matchIds.forEach(function(id) {
589
+ if (!boosted.find(function(s) { return s.id === id; }) && !rest.find(function(s) { return s.id === id; })) {
590
+ var s = allSessions.find(function(x) { return x.id === id; });
591
+ if (s) {
592
+ s._deepMatch = results.find(function(r) { return r.sessionId === id; });
593
+ boosted.push(s);
594
+ }
595
+ }
596
+ });
597
+
598
+ filteredSessions = boosted.concat(rest);
599
+ render();
600
+
601
+ // Show deep search indicator
602
+ var stats = document.getElementById('stats');
603
+ if (stats && boosted.length > 0) {
604
+ stats.textContent += ' | ' + boosted.length + ' deep matches';
605
+ }
606
+ }
607
+
428
608
  function onCardClick(id, event) {
429
609
  if (selectMode) {
430
610
  toggleSelect(id, event);
@@ -1164,13 +1344,29 @@ async function checkForUpdates() {
1164
1344
  try {
1165
1345
  var resp = await fetch('/api/version');
1166
1346
  var data = await resp.json();
1347
+ var badge = document.getElementById('versionBadge');
1348
+
1349
+ if (badge) {
1350
+ badge.textContent = 'v' + data.current;
1351
+ }
1352
+
1167
1353
  if (data.updateAvailable) {
1354
+ if (badge) {
1355
+ badge.textContent = 'v' + data.current + ' → v' + data.latest;
1356
+ badge.classList.add('update-available');
1357
+ badge.title = 'Click to copy update command';
1358
+ badge.onclick = function() {
1359
+ navigator.clipboard.writeText('npm i -g codedash-app@latest').then(function() {
1360
+ showToast('Copied: npm i -g codedash-app@latest');
1361
+ });
1362
+ };
1363
+ }
1168
1364
  var banner = document.getElementById('updateBanner');
1169
1365
  var text = document.getElementById('updateText');
1170
1366
  if (banner && text) {
1171
- text.textContent = 'Update available: v' + data.current + ' v' + data.latest;
1367
+ text.textContent = 'v' + data.latest + ' available run: npm i -g codedash-app@latest';
1172
1368
  banner.style.display = 'flex';
1173
- banner.dataset.cmd = 'npm update -g codedash-app && codedash run';
1369
+ banner.dataset.cmd = 'npm i -g codedash-app@latest';
1174
1370
  }
1175
1371
  }
1176
1372
  } catch {}
@@ -1196,6 +1392,7 @@ function dismissUpdate() {
1196
1392
  loadSessions();
1197
1393
  loadTerminals();
1198
1394
  checkForUpdates();
1395
+ initHoverPreview();
1199
1396
 
1200
1397
  // Apply saved theme
1201
1398
  var savedTheme = localStorage.getItem('codedash-theme') || 'dark';
@@ -12,6 +12,7 @@
12
12
  <div class="sidebar-brand">
13
13
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
14
14
  codedash
15
+ <span class="version-badge" id="versionBadge"></span>
15
16
  </div>
16
17
  <div class="sidebar-item active" data-view="sessions">
17
18
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
@@ -100,6 +100,26 @@ body {
100
100
  display: flex;
101
101
  align-items: center;
102
102
  gap: 8px;
103
+ flex-wrap: wrap;
104
+ }
105
+
106
+ .version-badge {
107
+ font-size: 10px;
108
+ font-weight: 500;
109
+ color: var(--text-muted);
110
+ background: rgba(255,255,255,0.06);
111
+ padding: 1px 6px;
112
+ border-radius: 4px;
113
+ }
114
+
115
+ .version-badge.update-available {
116
+ color: var(--accent-green);
117
+ background: rgba(74, 222, 128, 0.15);
118
+ cursor: pointer;
119
+ }
120
+
121
+ .version-badge.update-available:hover {
122
+ background: rgba(74, 222, 128, 0.25);
103
123
  }
104
124
 
105
125
  .sidebar-item {
@@ -1370,6 +1390,104 @@ body {
1370
1390
  color: #fff;
1371
1391
  }
1372
1392
 
1393
+ /* ── Card expand preview ────────────────────────────────────── */
1394
+
1395
+ .card-expand-btn {
1396
+ background: none;
1397
+ border: 1px solid var(--border);
1398
+ border-radius: 4px;
1399
+ color: var(--text-muted);
1400
+ font-size: 12px;
1401
+ cursor: pointer;
1402
+ padding: 1px 6px;
1403
+ margin-left: auto;
1404
+ transition: all 0.15s;
1405
+ }
1406
+ .card-expand-btn:hover {
1407
+ color: var(--text-primary);
1408
+ border-color: var(--accent-blue);
1409
+ }
1410
+
1411
+ .card-preview-area {
1412
+ display: none;
1413
+ border-top: 1px solid var(--border);
1414
+ margin-top: 10px;
1415
+ padding-top: 10px;
1416
+ max-height: 300px;
1417
+ overflow-y: auto;
1418
+ animation: fadeIn 0.2s ease;
1419
+ }
1420
+
1421
+ .card-preview-area.open {
1422
+ display: block;
1423
+ }
1424
+
1425
+ @keyframes fadeIn {
1426
+ from { opacity: 0; transform: translateY(-4px); }
1427
+ to { opacity: 1; transform: translateY(0); }
1428
+ }
1429
+
1430
+ .preview-msg {
1431
+ font-size: 12px;
1432
+ line-height: 1.5;
1433
+ padding: 4px 8px;
1434
+ margin-bottom: 4px;
1435
+ border-radius: 6px;
1436
+ word-break: break-word;
1437
+ white-space: pre-wrap;
1438
+ }
1439
+
1440
+ .preview-user {
1441
+ background: rgba(96, 165, 250, 0.08);
1442
+ }
1443
+
1444
+ .preview-assistant {
1445
+ background: rgba(74, 222, 128, 0.06);
1446
+ }
1447
+
1448
+ .preview-role {
1449
+ font-weight: 600;
1450
+ font-size: 10px;
1451
+ text-transform: uppercase;
1452
+ letter-spacing: 0.3px;
1453
+ }
1454
+
1455
+ .preview-user .preview-role { color: var(--accent-blue); }
1456
+ .preview-assistant .preview-role { color: var(--accent-green); }
1457
+
1458
+ .preview-empty {
1459
+ color: var(--text-muted);
1460
+ font-size: 12px;
1461
+ padding: 8px 0;
1462
+ }
1463
+
1464
+ /* ── Hover tooltip ──────────────────────────────────────────── */
1465
+
1466
+ .hover-tooltip {
1467
+ position: fixed;
1468
+ background: var(--bg-secondary);
1469
+ border: 1px solid var(--border);
1470
+ border-radius: 10px;
1471
+ padding: 12px;
1472
+ z-index: 150;
1473
+ box-shadow: 0 8px 32px rgba(0,0,0,0.3);
1474
+ max-height: 280px;
1475
+ overflow-y: auto;
1476
+ opacity: 0;
1477
+ transform: translateY(4px);
1478
+ transition: opacity 0.15s, transform 0.15s;
1479
+ pointer-events: none;
1480
+ }
1481
+
1482
+ .hover-tooltip.visible {
1483
+ opacity: 1;
1484
+ transform: translateY(0);
1485
+ }
1486
+
1487
+ [data-theme="light"] .hover-tooltip {
1488
+ box-shadow: 0 8px 32px rgba(0,0,0,0.12);
1489
+ }
1490
+
1373
1491
  /* ── Update banner ──────────────────────────────────────────── */
1374
1492
 
1375
1493
  .update-banner {
package/src/server.js CHANGED
@@ -3,7 +3,7 @@ const http = require('http');
3
3
  const https = require('https');
4
4
  const { URL } = require('url');
5
5
  const { exec } = require('child_process');
6
- const { loadSessions, loadSessionDetail, deleteSession, getGitCommits, exportSessionMarkdown } = require('./data');
6
+ const { loadSessions, loadSessionDetail, deleteSession, getGitCommits, exportSessionMarkdown, getSessionPreview, searchFullText } = require('./data');
7
7
  const { detectTerminals, openInTerminal } = require('./terminals');
8
8
  const { getHTML } = require('./html');
9
9
 
@@ -104,6 +104,23 @@ function startServer(port, openBrowser = true) {
104
104
  json(res, commits);
105
105
  }
106
106
 
107
+ // ── Session preview ─────────────────────
108
+ else if (req.method === 'GET' && pathname.startsWith('/api/preview/')) {
109
+ const sessionId = pathname.split('/').pop();
110
+ const project = parsed.searchParams.get('project') || '';
111
+ const limit = parseInt(parsed.searchParams.get('limit') || '10');
112
+ const messages = getSessionPreview(sessionId, project, limit);
113
+ json(res, messages);
114
+ }
115
+
116
+ // ── Full-text search ──────────────────────
117
+ else if (req.method === 'GET' && pathname === '/api/search') {
118
+ const q = parsed.searchParams.get('q') || '';
119
+ const sessions = loadSessions();
120
+ const results = searchFullText(q, sessions);
121
+ json(res, results);
122
+ }
123
+
107
124
  // ── Version check ────────────────────────
108
125
  else if (req.method === 'GET' && pathname === '/api/version') {
109
126
  const pkg = require('../package.json');