@jhizzard/termdeck 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/cli/src/index.js +8 -0
- package/packages/client/public/app.js +471 -0
- package/packages/client/public/style.css +337 -0
- package/packages/server/src/index.js +102 -3
- package/packages/server/src/mnestra-bridge/index.js +1 -1
- package/packages/server/src/preflight.js +373 -0
- package/packages/server/src/rag.js +40 -0
- package/packages/server/src/session.js +8 -1
- package/packages/server/src/transcripts.js +290 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
|
|
5
5
|
"bin": {
|
|
6
6
|
"termdeck": "./packages/cli/src/index.js"
|
|
@@ -86,6 +86,7 @@ for (let i = 0; i < args.length; i++) {
|
|
|
86
86
|
|
|
87
87
|
// Load and start server
|
|
88
88
|
const { createServer, loadConfig } = require(path.join(__dirname, '..', '..', 'server', 'src', 'index.js'));
|
|
89
|
+
const { runPreflight, printHealthBanner } = require(path.join(__dirname, '..', '..', 'server', 'src', 'preflight'));
|
|
89
90
|
|
|
90
91
|
// Flag-driven env vars must be set BEFORE loadConfig() so any module that
|
|
91
92
|
// reads process.env at require-time sees them.
|
|
@@ -116,6 +117,13 @@ server.listen(port, host, async () => {
|
|
|
116
117
|
╚══════════════════════════════════════╝
|
|
117
118
|
`);
|
|
118
119
|
|
|
120
|
+
// Run preflight health checks (non-blocking — warn but don't prevent startup)
|
|
121
|
+
runPreflight(config).then((result) => {
|
|
122
|
+
printHealthBanner(result);
|
|
123
|
+
}).catch((err) => {
|
|
124
|
+
console.error(` \x1b[31m[health] Preflight failed: ${err.message}\x1b[0m\n`);
|
|
125
|
+
});
|
|
126
|
+
|
|
119
127
|
// Skip auto-open in Codespaces/CI (port forwarding handles it)
|
|
120
128
|
const isCodespaces = !!process.env.CODESPACES || !!process.env.GITHUB_CODESPACE_TOKEN;
|
|
121
129
|
const isCI = !!process.env.CI;
|
|
@@ -66,6 +66,12 @@
|
|
|
66
66
|
// Rumen insights badge + briefing (no-op when server reports enabled:false)
|
|
67
67
|
setupRumen();
|
|
68
68
|
|
|
69
|
+
// Health badge (Sprint 6 T4) — polls /api/health every 30s
|
|
70
|
+
setupHealthBadge();
|
|
71
|
+
|
|
72
|
+
// Transcript recovery UI (Sprint 6 T4) — depends on T3 endpoints
|
|
73
|
+
setupTranscriptUI();
|
|
74
|
+
|
|
69
75
|
// First-run onboarding tour. Fires on the first visit only; never again
|
|
70
76
|
// unless the user explicitly clicks "how this works" in the top toolbar.
|
|
71
77
|
try {
|
|
@@ -2311,5 +2317,470 @@
|
|
|
2311
2317
|
}
|
|
2312
2318
|
}, 30000);
|
|
2313
2319
|
|
|
2320
|
+
// ===== Health Badge (Sprint 6 T4) =====
|
|
2321
|
+
const healthState = {
|
|
2322
|
+
available: false, // false until first successful /api/health response
|
|
2323
|
+
pollTimer: null,
|
|
2324
|
+
dropdownOpen: false,
|
|
2325
|
+
lastResult: null
|
|
2326
|
+
};
|
|
2327
|
+
|
|
2328
|
+
function setupHealthBadge() {
|
|
2329
|
+
// Inject badge into topbar-stats, after the rumen badge
|
|
2330
|
+
const statsDiv = document.getElementById('globalStats');
|
|
2331
|
+
if (!statsDiv) return;
|
|
2332
|
+
|
|
2333
|
+
const badge = document.createElement('button');
|
|
2334
|
+
badge.type = 'button';
|
|
2335
|
+
badge.className = 'health-badge';
|
|
2336
|
+
badge.id = 'healthBadge';
|
|
2337
|
+
badge.title = 'Stack health';
|
|
2338
|
+
badge.setAttribute('aria-haspopup', 'true');
|
|
2339
|
+
badge.innerHTML = `<span class="hb-icon" aria-hidden="true">🛡</span> <span id="healthBadgeLabel">checking…</span>`;
|
|
2340
|
+
badge.style.display = 'none'; // hidden until first successful poll
|
|
2341
|
+
statsDiv.appendChild(badge);
|
|
2342
|
+
|
|
2343
|
+
// Dropdown
|
|
2344
|
+
const dropdown = document.createElement('div');
|
|
2345
|
+
dropdown.className = 'health-dropdown';
|
|
2346
|
+
dropdown.id = 'healthDropdown';
|
|
2347
|
+
dropdown.innerHTML = '<div class="hd-loading">Loading…</div>';
|
|
2348
|
+
document.body.appendChild(dropdown);
|
|
2349
|
+
|
|
2350
|
+
badge.addEventListener('click', (e) => {
|
|
2351
|
+
e.stopPropagation();
|
|
2352
|
+
toggleHealthDropdown();
|
|
2353
|
+
});
|
|
2354
|
+
document.addEventListener('click', (e) => {
|
|
2355
|
+
if (healthState.dropdownOpen && !dropdown.contains(e.target) && e.target !== badge) {
|
|
2356
|
+
closeHealthDropdown();
|
|
2357
|
+
}
|
|
2358
|
+
});
|
|
2359
|
+
|
|
2360
|
+
// Initial fetch + poll
|
|
2361
|
+
fetchHealth();
|
|
2362
|
+
healthState.pollTimer = setInterval(fetchHealth, 30000);
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
async function fetchHealth() {
|
|
2366
|
+
try {
|
|
2367
|
+
const res = await fetch(`${API}/api/health`);
|
|
2368
|
+
if (res.status === 404) {
|
|
2369
|
+
// Server doesn't have health endpoint — hide badge entirely
|
|
2370
|
+
hideHealthBadge();
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2373
|
+
if (!res.ok) {
|
|
2374
|
+
showHealthOffline();
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
const data = await res.json();
|
|
2378
|
+
healthState.available = true;
|
|
2379
|
+
healthState.lastResult = data;
|
|
2380
|
+
renderHealthBadge(data);
|
|
2381
|
+
} catch {
|
|
2382
|
+
showHealthOffline();
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
function hideHealthBadge() {
|
|
2387
|
+
healthState.available = false;
|
|
2388
|
+
const badge = document.getElementById('healthBadge');
|
|
2389
|
+
if (badge) badge.style.display = 'none';
|
|
2390
|
+
if (healthState.pollTimer) {
|
|
2391
|
+
clearInterval(healthState.pollTimer);
|
|
2392
|
+
healthState.pollTimer = null;
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
function showHealthOffline() {
|
|
2397
|
+
healthState.available = true;
|
|
2398
|
+
healthState.lastResult = null;
|
|
2399
|
+
const badge = document.getElementById('healthBadge');
|
|
2400
|
+
if (!badge) return;
|
|
2401
|
+
badge.style.display = '';
|
|
2402
|
+
badge.className = 'health-badge hb-red';
|
|
2403
|
+
document.getElementById('healthBadgeLabel').textContent = 'Health: offline';
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
function renderHealthBadge(data) {
|
|
2407
|
+
const badge = document.getElementById('healthBadge');
|
|
2408
|
+
if (!badge) return;
|
|
2409
|
+
badge.style.display = '';
|
|
2410
|
+
|
|
2411
|
+
const checks = data.checks || [];
|
|
2412
|
+
const total = checks.length;
|
|
2413
|
+
const passed = checks.filter(c => c.ok).length;
|
|
2414
|
+
const allOk = passed === total && total > 0;
|
|
2415
|
+
|
|
2416
|
+
if (allOk) {
|
|
2417
|
+
badge.className = 'health-badge hb-green';
|
|
2418
|
+
document.getElementById('healthBadgeLabel').textContent = 'Stack: OK';
|
|
2419
|
+
} else if (total === 0) {
|
|
2420
|
+
badge.className = 'health-badge hb-amber';
|
|
2421
|
+
document.getElementById('healthBadgeLabel').textContent = 'Stack: ?';
|
|
2422
|
+
} else {
|
|
2423
|
+
badge.className = 'health-badge hb-red';
|
|
2424
|
+
document.getElementById('healthBadgeLabel').textContent = `Stack: ${passed}/${total}`;
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
// Update dropdown content
|
|
2428
|
+
renderHealthDropdown(data);
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
function renderHealthDropdown(data) {
|
|
2432
|
+
const dropdown = document.getElementById('healthDropdown');
|
|
2433
|
+
if (!dropdown) return;
|
|
2434
|
+
const checks = data.checks || [];
|
|
2435
|
+
if (checks.length === 0) {
|
|
2436
|
+
dropdown.innerHTML = '<div class="hd-empty">No health checks reported</div>';
|
|
2437
|
+
return;
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
let html = '';
|
|
2441
|
+
for (const check of checks) {
|
|
2442
|
+
const icon = check.ok ? '✓' : '✗';
|
|
2443
|
+
const cls = check.ok ? 'hd-ok' : 'hd-fail';
|
|
2444
|
+
const name = check.name || 'Unknown';
|
|
2445
|
+
const detail = check.detail || '';
|
|
2446
|
+
const remediation = check.ok ? '' : (check.remediation ? `<div class="hd-remediation">${escapeHtml(check.remediation)}</div>` : '');
|
|
2447
|
+
html += `<div class="hd-check ${cls}">
|
|
2448
|
+
<span class="hd-icon">${icon}</span>
|
|
2449
|
+
<span class="hd-name">${escapeHtml(name)}</span>
|
|
2450
|
+
<span class="hd-dots"></span>
|
|
2451
|
+
<span class="hd-status">${check.ok ? 'OK' : 'FAIL'}</span>
|
|
2452
|
+
<span class="hd-detail">${escapeHtml(detail)}</span>
|
|
2453
|
+
${remediation}
|
|
2454
|
+
</div>`;
|
|
2455
|
+
}
|
|
2456
|
+
dropdown.innerHTML = html;
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
function escapeHtml(str) {
|
|
2460
|
+
const div = document.createElement('div');
|
|
2461
|
+
div.textContent = str;
|
|
2462
|
+
return div.innerHTML;
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
function toggleHealthDropdown() {
|
|
2466
|
+
if (healthState.dropdownOpen) {
|
|
2467
|
+
closeHealthDropdown();
|
|
2468
|
+
} else {
|
|
2469
|
+
openHealthDropdown();
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
function openHealthDropdown() {
|
|
2474
|
+
const badge = document.getElementById('healthBadge');
|
|
2475
|
+
const dropdown = document.getElementById('healthDropdown');
|
|
2476
|
+
if (!badge || !dropdown) return;
|
|
2477
|
+
|
|
2478
|
+
const rect = badge.getBoundingClientRect();
|
|
2479
|
+
dropdown.style.top = `${rect.bottom + 4}px`;
|
|
2480
|
+
dropdown.style.left = `${Math.max(8, rect.left - 100)}px`;
|
|
2481
|
+
dropdown.classList.add('open');
|
|
2482
|
+
healthState.dropdownOpen = true;
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
function closeHealthDropdown() {
|
|
2486
|
+
const dropdown = document.getElementById('healthDropdown');
|
|
2487
|
+
if (dropdown) dropdown.classList.remove('open');
|
|
2488
|
+
healthState.dropdownOpen = false;
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
// ===== Transcript Recovery UI (Sprint 6 T4) =====
|
|
2492
|
+
const transcriptState = {
|
|
2493
|
+
available: false,
|
|
2494
|
+
modalOpen: false,
|
|
2495
|
+
view: 'recent', // 'recent' | 'search' | 'replay'
|
|
2496
|
+
recentData: null,
|
|
2497
|
+
searchResults: null,
|
|
2498
|
+
replaySession: null,
|
|
2499
|
+
replayData: null
|
|
2500
|
+
};
|
|
2501
|
+
|
|
2502
|
+
function setupTranscriptUI() {
|
|
2503
|
+
// Inject "Transcripts" button into topbar-right, before the "status" button
|
|
2504
|
+
const topbarRight = document.querySelector('.topbar-right');
|
|
2505
|
+
const btnStatus = document.getElementById('btn-status');
|
|
2506
|
+
if (!topbarRight || !btnStatus) return;
|
|
2507
|
+
|
|
2508
|
+
const btn = document.createElement('button');
|
|
2509
|
+
btn.id = 'btn-transcripts';
|
|
2510
|
+
btn.textContent = 'transcripts';
|
|
2511
|
+
btn.title = 'Session transcript recovery';
|
|
2512
|
+
btn.style.display = 'none'; // hidden until we confirm endpoints exist
|
|
2513
|
+
topbarRight.insertBefore(btn, btnStatus);
|
|
2514
|
+
|
|
2515
|
+
// Create the modal
|
|
2516
|
+
const modal = document.createElement('div');
|
|
2517
|
+
modal.className = 'transcript-modal';
|
|
2518
|
+
modal.id = 'transcriptModal';
|
|
2519
|
+
modal.innerHTML = `
|
|
2520
|
+
<div class="transcript-backdrop" id="transcriptBackdrop"></div>
|
|
2521
|
+
<div class="transcript-card">
|
|
2522
|
+
<header>
|
|
2523
|
+
<h3>Session Transcripts</h3>
|
|
2524
|
+
<div class="transcript-tabs">
|
|
2525
|
+
<button class="transcript-tab active" data-view="recent">Recent</button>
|
|
2526
|
+
<button class="transcript-tab" data-view="search">Search</button>
|
|
2527
|
+
</div>
|
|
2528
|
+
</header>
|
|
2529
|
+
<div class="transcript-search-bar" id="transcriptSearchBar" style="display:none">
|
|
2530
|
+
<input type="text" id="transcriptSearchInput" placeholder="Search transcript content…" class="ctrl-input" />
|
|
2531
|
+
</div>
|
|
2532
|
+
<div class="transcript-body" id="transcriptBody">
|
|
2533
|
+
<div class="transcript-loading">Checking transcript endpoints…</div>
|
|
2534
|
+
</div>
|
|
2535
|
+
<footer>
|
|
2536
|
+
<button class="transcript-back" id="transcriptBack" style="display:none">← Back</button>
|
|
2537
|
+
<button class="rm-close" id="transcriptClose">Close</button>
|
|
2538
|
+
</footer>
|
|
2539
|
+
</div>
|
|
2540
|
+
`;
|
|
2541
|
+
document.body.appendChild(modal);
|
|
2542
|
+
|
|
2543
|
+
// Events
|
|
2544
|
+
btn.addEventListener('click', openTranscriptModal);
|
|
2545
|
+
document.getElementById('transcriptBackdrop').addEventListener('click', closeTranscriptModal);
|
|
2546
|
+
document.getElementById('transcriptClose').addEventListener('click', closeTranscriptModal);
|
|
2547
|
+
document.getElementById('transcriptBack').addEventListener('click', transcriptGoBack);
|
|
2548
|
+
|
|
2549
|
+
modal.addEventListener('keydown', (e) => {
|
|
2550
|
+
if (e.key === 'Escape') { e.preventDefault(); closeTranscriptModal(); }
|
|
2551
|
+
});
|
|
2552
|
+
|
|
2553
|
+
// Tab switching
|
|
2554
|
+
modal.querySelectorAll('.transcript-tab').forEach(tab => {
|
|
2555
|
+
tab.addEventListener('click', () => {
|
|
2556
|
+
const view = tab.dataset.view;
|
|
2557
|
+
transcriptSwitchView(view);
|
|
2558
|
+
});
|
|
2559
|
+
});
|
|
2560
|
+
|
|
2561
|
+
// Search input
|
|
2562
|
+
let searchDebounce = null;
|
|
2563
|
+
document.getElementById('transcriptSearchInput').addEventListener('input', (e) => {
|
|
2564
|
+
clearTimeout(searchDebounce);
|
|
2565
|
+
searchDebounce = setTimeout(() => {
|
|
2566
|
+
const q = e.target.value.trim();
|
|
2567
|
+
if (q.length >= 2) transcriptSearch(q);
|
|
2568
|
+
}, 400);
|
|
2569
|
+
});
|
|
2570
|
+
|
|
2571
|
+
// Probe for endpoint availability
|
|
2572
|
+
probeTranscriptEndpoints();
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
async function probeTranscriptEndpoints() {
|
|
2576
|
+
try {
|
|
2577
|
+
const res = await fetch(`${API}/api/transcripts/recent?minutes=1`);
|
|
2578
|
+
if (res.status === 404) {
|
|
2579
|
+
// Endpoints not available — keep button hidden
|
|
2580
|
+
transcriptState.available = false;
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
// Endpoint exists (even if empty)
|
|
2584
|
+
transcriptState.available = true;
|
|
2585
|
+
const btn = document.getElementById('btn-transcripts');
|
|
2586
|
+
if (btn) btn.style.display = '';
|
|
2587
|
+
} catch {
|
|
2588
|
+
transcriptState.available = false;
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
function openTranscriptModal() {
|
|
2593
|
+
if (!transcriptState.available) return;
|
|
2594
|
+
transcriptState.modalOpen = true;
|
|
2595
|
+
document.getElementById('transcriptModal').classList.add('open');
|
|
2596
|
+
transcriptSwitchView('recent');
|
|
2597
|
+
fetchRecentTranscripts();
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
function closeTranscriptModal() {
|
|
2601
|
+
transcriptState.modalOpen = false;
|
|
2602
|
+
document.getElementById('transcriptModal').classList.remove('open');
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
function transcriptGoBack() {
|
|
2606
|
+
if (transcriptState.view === 'replay') {
|
|
2607
|
+
transcriptState.replaySession = null;
|
|
2608
|
+
transcriptState.replayData = null;
|
|
2609
|
+
// Go back to whichever list view was active
|
|
2610
|
+
transcriptSwitchView(transcriptState.searchResults ? 'search' : 'recent');
|
|
2611
|
+
if (transcriptState.view === 'recent') renderRecentTranscripts();
|
|
2612
|
+
else renderSearchResults();
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
function transcriptSwitchView(view) {
|
|
2617
|
+
transcriptState.view = view;
|
|
2618
|
+
const tabs = document.querySelectorAll('.transcript-tab');
|
|
2619
|
+
tabs.forEach(t => t.classList.toggle('active', t.dataset.view === view));
|
|
2620
|
+
const searchBar = document.getElementById('transcriptSearchBar');
|
|
2621
|
+
const backBtn = document.getElementById('transcriptBack');
|
|
2622
|
+
searchBar.style.display = view === 'search' ? '' : 'none';
|
|
2623
|
+
backBtn.style.display = view === 'replay' ? '' : 'none';
|
|
2624
|
+
|
|
2625
|
+
if (view === 'recent') fetchRecentTranscripts();
|
|
2626
|
+
if (view === 'search') {
|
|
2627
|
+
const input = document.getElementById('transcriptSearchInput');
|
|
2628
|
+
input.focus();
|
|
2629
|
+
if (transcriptState.searchResults) renderSearchResults();
|
|
2630
|
+
else document.getElementById('transcriptBody').innerHTML = '<div class="transcript-empty">Type to search transcript content</div>';
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
async function fetchRecentTranscripts() {
|
|
2635
|
+
const body = document.getElementById('transcriptBody');
|
|
2636
|
+
body.innerHTML = '<div class="transcript-loading">Loading recent transcripts…</div>';
|
|
2637
|
+
try {
|
|
2638
|
+
const res = await fetch(`${API}/api/transcripts/recent?minutes=60`);
|
|
2639
|
+
if (!res.ok) throw new Error('fetch failed');
|
|
2640
|
+
const data = await res.json();
|
|
2641
|
+
transcriptState.recentData = data;
|
|
2642
|
+
renderRecentTranscripts();
|
|
2643
|
+
} catch {
|
|
2644
|
+
body.innerHTML = '<div class="transcript-empty">Failed to load transcripts</div>';
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
function renderRecentTranscripts() {
|
|
2649
|
+
const body = document.getElementById('transcriptBody');
|
|
2650
|
+
const data = transcriptState.recentData;
|
|
2651
|
+
if (!data || !data.sessions || data.sessions.length === 0) {
|
|
2652
|
+
body.innerHTML = '<div class="transcript-empty">No recent transcript activity</div>';
|
|
2653
|
+
return;
|
|
2654
|
+
}
|
|
2655
|
+
let html = '';
|
|
2656
|
+
for (const sess of data.sessions) {
|
|
2657
|
+
const id = sess.sessionId || sess.session_id || 'unknown';
|
|
2658
|
+
const shortId = id.slice(0, 8);
|
|
2659
|
+
const type = sess.type || 'shell';
|
|
2660
|
+
const project = sess.project || '';
|
|
2661
|
+
const lines = sess.lines || sess.preview || [];
|
|
2662
|
+
const lineCount = sess.totalLines || lines.length;
|
|
2663
|
+
html += `<div class="transcript-session" data-session-id="${escapeHtml(id)}">
|
|
2664
|
+
<div class="ts-header">
|
|
2665
|
+
<span class="ts-id">${escapeHtml(shortId)}</span>
|
|
2666
|
+
<span class="ts-type">${escapeHtml(type)}</span>
|
|
2667
|
+
${project ? `<span class="ts-project">${escapeHtml(project)}</span>` : ''}
|
|
2668
|
+
<span class="ts-lines">${lineCount} lines</span>
|
|
2669
|
+
</div>
|
|
2670
|
+
<pre class="ts-preview">${escapeHtml(lines.slice(-6).join('\n'))}</pre>
|
|
2671
|
+
</div>`;
|
|
2672
|
+
}
|
|
2673
|
+
body.innerHTML = html;
|
|
2674
|
+
|
|
2675
|
+
// Click to replay
|
|
2676
|
+
body.querySelectorAll('.transcript-session').forEach(el => {
|
|
2677
|
+
el.addEventListener('click', () => {
|
|
2678
|
+
const sid = el.dataset.sessionId;
|
|
2679
|
+
loadTranscriptReplay(sid);
|
|
2680
|
+
});
|
|
2681
|
+
});
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
async function transcriptSearch(query) {
|
|
2685
|
+
const body = document.getElementById('transcriptBody');
|
|
2686
|
+
body.innerHTML = '<div class="transcript-loading">Searching…</div>';
|
|
2687
|
+
try {
|
|
2688
|
+
const res = await fetch(`${API}/api/transcripts/search?q=${encodeURIComponent(query)}`);
|
|
2689
|
+
if (!res.ok) throw new Error('search failed');
|
|
2690
|
+
const data = await res.json();
|
|
2691
|
+
transcriptState.searchResults = data;
|
|
2692
|
+
renderSearchResults();
|
|
2693
|
+
} catch {
|
|
2694
|
+
body.innerHTML = '<div class="transcript-empty">Search failed</div>';
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
function renderSearchResults() {
|
|
2699
|
+
const body = document.getElementById('transcriptBody');
|
|
2700
|
+
const data = transcriptState.searchResults;
|
|
2701
|
+
if (!data || !data.results || data.results.length === 0) {
|
|
2702
|
+
body.innerHTML = '<div class="transcript-empty">No matches found</div>';
|
|
2703
|
+
return;
|
|
2704
|
+
}
|
|
2705
|
+
let html = '';
|
|
2706
|
+
for (const result of data.results) {
|
|
2707
|
+
const id = result.sessionId || result.session_id || 'unknown';
|
|
2708
|
+
const shortId = id.slice(0, 8);
|
|
2709
|
+
const line = result.line || result.content || '';
|
|
2710
|
+
const ts = result.timestamp ? new Date(result.timestamp).toLocaleTimeString() : '';
|
|
2711
|
+
html += `<div class="transcript-result" data-session-id="${escapeHtml(id)}">
|
|
2712
|
+
<div class="tr-meta">
|
|
2713
|
+
<span class="tr-session">${escapeHtml(shortId)}</span>
|
|
2714
|
+
${ts ? `<span class="tr-time">${escapeHtml(ts)}</span>` : ''}
|
|
2715
|
+
</div>
|
|
2716
|
+
<pre class="tr-line">${highlightMatch(escapeHtml(line), escapeHtml(document.getElementById('transcriptSearchInput').value))}</pre>
|
|
2717
|
+
</div>`;
|
|
2718
|
+
}
|
|
2719
|
+
body.innerHTML = html;
|
|
2720
|
+
|
|
2721
|
+
// Click to replay
|
|
2722
|
+
body.querySelectorAll('.transcript-result').forEach(el => {
|
|
2723
|
+
el.addEventListener('click', () => {
|
|
2724
|
+
const sid = el.dataset.sessionId;
|
|
2725
|
+
loadTranscriptReplay(sid);
|
|
2726
|
+
});
|
|
2727
|
+
});
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
function highlightMatch(text, query) {
|
|
2731
|
+
if (!query) return text;
|
|
2732
|
+
try {
|
|
2733
|
+
const re = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
|
2734
|
+
return text.replace(re, '<mark class="tr-highlight">$1</mark>');
|
|
2735
|
+
} catch {
|
|
2736
|
+
return text;
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
async function loadTranscriptReplay(sessionId) {
|
|
2741
|
+
transcriptState.view = 'replay';
|
|
2742
|
+
transcriptState.replaySession = sessionId;
|
|
2743
|
+
const body = document.getElementById('transcriptBody');
|
|
2744
|
+
const backBtn = document.getElementById('transcriptBack');
|
|
2745
|
+
const searchBar = document.getElementById('transcriptSearchBar');
|
|
2746
|
+
backBtn.style.display = '';
|
|
2747
|
+
searchBar.style.display = 'none';
|
|
2748
|
+
body.innerHTML = '<div class="transcript-loading">Loading full transcript…</div>';
|
|
2749
|
+
|
|
2750
|
+
try {
|
|
2751
|
+
const res = await fetch(`${API}/api/transcripts/${encodeURIComponent(sessionId)}`);
|
|
2752
|
+
if (!res.ok) throw new Error('fetch failed');
|
|
2753
|
+
const data = await res.json();
|
|
2754
|
+
transcriptState.replayData = data;
|
|
2755
|
+
renderTranscriptReplay(data);
|
|
2756
|
+
} catch {
|
|
2757
|
+
body.innerHTML = '<div class="transcript-empty">Failed to load transcript</div>';
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
function renderTranscriptReplay(data) {
|
|
2762
|
+
const body = document.getElementById('transcriptBody');
|
|
2763
|
+
const content = data.content || data.lines?.join('\n') || '';
|
|
2764
|
+
const sessionId = transcriptState.replaySession || 'unknown';
|
|
2765
|
+
body.innerHTML = `
|
|
2766
|
+
<div class="transcript-replay-header">
|
|
2767
|
+
<span class="tr-replay-id">Session: ${escapeHtml(sessionId.slice(0, 12))}</span>
|
|
2768
|
+
<button class="transcript-copy" id="transcriptCopyBtn">Copy to clipboard</button>
|
|
2769
|
+
</div>
|
|
2770
|
+
<pre class="transcript-replay-content">${escapeHtml(content)}</pre>
|
|
2771
|
+
`;
|
|
2772
|
+
document.getElementById('transcriptCopyBtn').addEventListener('click', () => {
|
|
2773
|
+
navigator.clipboard.writeText(content).then(() => {
|
|
2774
|
+
const btn = document.getElementById('transcriptCopyBtn');
|
|
2775
|
+
btn.textContent = 'Copied!';
|
|
2776
|
+
btn.classList.add('copied');
|
|
2777
|
+
setTimeout(() => {
|
|
2778
|
+
btn.textContent = 'Copy to clipboard';
|
|
2779
|
+
btn.classList.remove('copied');
|
|
2780
|
+
}, 2000);
|
|
2781
|
+
}).catch(() => {});
|
|
2782
|
+
});
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2314
2785
|
// Boot
|
|
2315
2786
|
init();
|