agent-knowledge 1.0.6 → 1.0.8

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/dist/ui/app.js CHANGED
@@ -1,953 +1,967 @@
1
- /* agent-knowledge dashboard — vanilla JS SPA */
2
- (function () {
3
- 'use strict';
4
-
5
- // ── State ──────────────────────────────────────────────────────────────────
6
- const state = {
7
- activeTab: 'knowledge',
8
- knowledge: { entries: [], activeCategory: 'all' },
9
- search: {
10
- query: '',
11
- results: [],
12
- role: 'all',
13
- scope: 'all',
14
- ranked: true,
15
- semantic: false,
16
- loading: false,
17
- },
18
- sessions: { list: [], projectFilter: '', loading: false },
19
- embeddings: { stats: null, loading: false },
20
- panel: { open: false, type: null, data: null },
21
- stats: { knowledgeCount: 0, sessionCount: 0 },
22
- connected: false,
23
- };
24
-
25
- // ── DOM refs ───────────────────────────────────────────────────────────────
26
- const $ = (id) => document.getElementById(id);
27
- const el = {
28
- tabs: {
29
- knowledge: $('tab-knowledge'),
30
- search: $('tab-search'),
31
- sessions: $('tab-sessions'),
32
- embeddings: $('tab-embeddings'),
33
- },
34
- views: {
35
- knowledge: $('view-knowledge'),
36
- search: $('view-search'),
37
- sessions: $('view-sessions'),
38
- embeddings: $('view-embeddings'),
39
- },
40
- knowledgeGrid: $('knowledge-grid'),
41
- knowledgeEmpty: $('knowledge-empty'),
42
- knowledgeCategories: $('knowledge-categories'),
43
- searchInput: $('search-input'),
44
- searchResults: $('search-results'),
45
- searchEmpty: $('search-empty'),
46
- searchRoleFilters: $('search-role-filters'),
47
- modeRanked: $('mode-ranked'),
48
- modeSemantic: $('mode-semantic'),
49
- modeRegex: $('mode-regex'),
50
- sessionsList: $('sessions-list'),
51
- sessionsEmpty: $('sessions-empty'),
52
- sessionProjectFilter: $('session-project-filter'),
53
- searchScopes: $('search-scopes'),
54
- sidePanel: $('side-panel'),
55
- panelTitle: $('panel-title'),
56
- panelBody: $('panel-body'),
57
- panelClose: $('panel-close'),
58
- connectionStatus: $('connection-status'),
59
- statKnowledge: $('stat-knowledge'),
60
- statSessions: $('stat-sessions'),
61
- statVectors: $('stat-vectors'),
62
- embeddingsStatsGrid: $('embedding-stats-grid'),
63
- embeddingsEmpty: $('embeddings-empty'),
64
- embeddingsStatus: $('embeddings-status'),
65
- themeToggle: $('theme-toggle'),
66
- version: $('version'),
67
- loadingOverlay: $('loading-overlay'),
68
- toastContainer: $('toast-container'),
69
- contentWrapper: $('content-wrapper'),
70
- };
71
-
72
- // ── Utilities ──────────────────────────────────────────────────────────────
73
-
74
- function esc(str) {
75
- const d = document.createElement('div');
76
- d.textContent = str;
77
- return d.innerHTML;
78
- }
79
-
80
- function debounce(fn, ms) {
81
- let t;
82
- return (...args) => {
83
- clearTimeout(t);
84
- t = setTimeout(() => fn(...args), ms);
85
- };
86
- }
87
-
88
- function relativeTime(dateStr) {
89
- if (!dateStr) return '';
90
- const now = Date.now();
91
- const then = new Date(dateStr).getTime();
92
- const diff = now - then;
93
- if (diff < 0) return 'just now';
94
- const secs = Math.floor(diff / 1000);
95
- if (secs < 60) return 'just now';
96
- const mins = Math.floor(secs / 60);
97
- if (mins < 60) return `${mins}m ago`;
98
- const hrs = Math.floor(mins / 60);
99
- if (hrs < 24) return `${hrs}h ago`;
100
- const days = Math.floor(hrs / 24);
101
- if (days < 30) return `${days}d ago`;
102
- const months = Math.floor(days / 30);
103
- if (months < 12) return `${months}mo ago`;
104
- return `${Math.floor(months / 12)}y ago`;
105
- }
106
-
107
- function renderMd(raw) {
108
- if (!raw) return '';
109
- try {
110
- const html = marked.parse(raw, { breaks: true, gfm: true });
111
- const clean = DOMPurify.sanitize(html, { ADD_TAGS: ['pre', 'code'] });
112
- const wrapper = document.createElement('div');
113
- wrapper.innerHTML = clean;
114
- wrapper.querySelectorAll('pre code').forEach((block) => {
115
- try {
116
- hljs.highlightElement(block);
117
- } catch (_) {
118
- /* noop */
119
- }
120
- });
121
- return wrapper.innerHTML;
122
- } catch {
123
- return esc(raw);
124
- }
125
- }
126
-
127
- function highlightExcerpt(text, query) {
128
- if (!text || !query) return esc(text || '');
129
- try {
130
- const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
131
- const re = new RegExp(`(${escaped})`, 'gi');
132
- return esc(text).replace(re, '<mark>$1</mark>');
133
- } catch {
134
- return esc(text);
135
- }
136
- }
137
-
138
- function formatScore(score, metadata) {
139
- if (score == null) return '';
140
- const pct = Math.min(Math.max(score * 100, 0), 100);
141
- const tfidf = metadata?.tfidfScore;
142
- const semantic = metadata?.semanticScore;
143
- const recency = metadata?.recencyMultiplier;
144
- const alpha = metadata?.blendAlpha;
145
-
146
- let tooltip = `Score: ${score.toFixed(3)}`;
147
- if (tfidf != null && semantic != null) {
148
- tooltip += ` (TF-IDF: ${tfidf.toFixed(2)} \u00d7 ${((alpha || 0.3) * 100).toFixed(0)}% + Semantic: ${semantic.toFixed(2)} \u00d7 ${((1 - (alpha || 0.3)) * 100).toFixed(0)}%)`;
149
- }
150
- if (recency != null) tooltip += ` \u00d7 recency ${(recency * 100).toFixed(0)}%`;
151
-
152
- const recencyTag =
153
- recency != null && recency < 0.95
154
- ? `<span class="recency-tag" title="Recency: ${(recency * 100).toFixed(0)}%">${(recency * 100).toFixed(0)}%</span>`
155
- : '';
156
- const scoreType =
157
- tfidf != null && semantic != null
158
- ? '<span class="score-type hybrid">hybrid</span>'
159
- : tfidf != null
160
- ? '<span class="score-type tfidf">tf-idf</span>'
161
- : '';
162
- return `<div class="score-bar" title="${tooltip}">
163
- <div class="score-fill" style="width:${pct}%"></div>
164
- <span class="score-label">${score.toFixed(2)}${recencyTag}${scoreType}</span>
165
- </div>`;
166
- }
167
-
168
- function toast(msg, type = 'info', duration = 4000) {
169
- const t = document.createElement('div');
170
- t.className = `toast toast-${type}`;
171
- const icons = { info: 'info', success: 'check_circle', error: 'error', warning: 'warning' };
172
- t.innerHTML = `<span class="material-symbols-outlined toast-icon">${icons[type] || 'info'}</span>
173
- <span class="toast-msg">${esc(msg)}</span>`;
174
- el.toastContainer.appendChild(t);
175
- requestAnimationFrame(() => t.classList.add('show'));
176
- setTimeout(() => {
177
- t.classList.remove('show');
178
- t.addEventListener('transitionend', () => t.remove());
179
- }, duration);
180
- }
181
-
182
- // ── API ────────────────────────────────────────────────────────────────────
183
-
184
- async function api(path) {
185
- const res = await fetch(`/api${path}`);
186
- if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`);
187
- return res.json();
188
- }
189
-
190
- // ── WebSocket ──────────────────────────────────────────────────────────────
191
-
192
- let ws = null;
193
- let wsRetry = null;
194
-
195
- function wsConnect() {
196
- const proto = location.protocol === 'https:' ? 'wss' : 'ws';
197
- ws = new WebSocket(`${proto}://${location.host}`);
198
-
199
- ws.addEventListener('open', () => {
200
- state.connected = true;
201
- updateConnectionStatus();
202
- el.loadingOverlay.classList.add('hidden');
203
- if (wsRetry) {
204
- clearTimeout(wsRetry);
205
- wsRetry = null;
206
- }
207
- });
208
-
209
- ws.addEventListener('message', (evt) => {
210
- try {
211
- const msg = JSON.parse(evt.data);
212
- handleWsMessage(msg);
213
- } catch {
214
- /* ignore non-json */
215
- }
216
- });
217
-
218
- ws.addEventListener('close', () => {
219
- state.connected = false;
220
- updateConnectionStatus();
221
- scheduleReconnect();
222
- });
223
-
224
- ws.addEventListener('error', () => {
225
- state.connected = false;
226
- updateConnectionStatus();
227
- });
228
- }
229
-
230
- function scheduleReconnect() {
231
- if (wsRetry) return;
232
- wsRetry = setTimeout(() => {
233
- wsRetry = null;
234
- wsConnect();
235
- }, 3000);
236
- }
237
-
238
- function handleWsMessage(msg) {
239
- switch (msg.type) {
240
- case 'reload':
241
- location.reload();
242
- return;
243
- case 'state':
244
- if (msg.knowledge) {
245
- state.knowledge.entries = msg.knowledge;
246
- renderKnowledge();
247
- }
248
- if (msg.sessions) {
249
- state.sessions.list = msg.sessions;
250
- renderSessions();
251
- }
252
- if (msg.stats) {
253
- state.stats.knowledgeCount = msg.stats.knowledge_entries || 0;
254
- state.stats.sessionCount = msg.stats.session_count || 0;
255
- if (msg.stats.version) el.version.textContent = 'v' + msg.stats.version;
256
- updateStats();
257
- }
258
- el.loadingOverlay.classList.add('hidden');
259
- break;
260
- case 'knowledge:update':
261
- case 'knowledge:change':
262
- loadKnowledge();
263
- break;
264
- case 'session:update':
265
- case 'session:new':
266
- loadSessions();
267
- break;
268
- case 'stats':
269
- if (msg.data) {
270
- if (msg.data.knowledgeCount != null) state.stats.knowledgeCount = msg.data.knowledgeCount;
271
- if (msg.data.sessionCount != null) state.stats.sessionCount = msg.data.sessionCount;
272
- updateStats();
273
- }
274
- break;
275
- case 'version':
276
- if (msg.data) el.version.textContent = msg.data;
277
- break;
278
- default:
279
- break;
280
- }
281
- }
282
-
283
- function updateConnectionStatus() {
284
- const s = el.connectionStatus;
285
- if (state.connected) {
286
- s.className = 'status-badge connected';
287
- s.textContent = 'Connected';
288
- } else {
289
- s.className = 'status-badge disconnected';
290
- s.textContent = 'Disconnected';
291
- }
292
- }
293
-
294
- // ── Stats ──────────────────────────────────────────────────────────────────
295
-
296
- function updateStats() {
297
- el.statKnowledge.querySelector('.stat-value').textContent = state.stats.knowledgeCount;
298
- el.statSessions.querySelector('.stat-value').textContent = state.stats.sessionCount;
299
- }
300
-
301
- // ── Theme ──────────────────────────────────────────────────────────────────
302
-
303
- function initTheme() {
304
- const saved = localStorage.getItem('agent-knowledge-theme');
305
- const theme = saved || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
306
- applyTheme(theme);
307
- }
308
-
309
- function applyTheme(theme) {
310
- document.documentElement.setAttribute('data-theme', theme);
311
- localStorage.setItem('agent-knowledge-theme', theme);
312
- const icon = el.themeToggle.querySelector('.theme-icon');
313
- if (icon) icon.textContent = theme === 'dark' ? 'light_mode' : 'dark_mode';
314
- }
315
-
316
- function toggleTheme() {
317
- const cur = document.documentElement.getAttribute('data-theme') || 'light';
318
- applyTheme(cur === 'dark' ? 'light' : 'dark');
319
- }
320
-
321
- // ── Tabs ───────────────────────────────────────────────────────────────────
322
-
323
- function switchTab(name) {
324
- if (state.activeTab === name) return;
325
- state.activeTab = name;
326
-
327
- Object.keys(el.tabs).forEach((k) => {
328
- const active = k === name;
329
- el.tabs[k].classList.toggle('active', active);
330
- el.tabs[k].setAttribute('aria-selected', active);
331
- });
332
-
333
- Object.keys(el.views).forEach((k) => {
334
- const active = k === name;
335
- el.views[k].classList.toggle('active', active);
336
- el.views[k].hidden = !active;
337
- });
338
-
339
- // Close side panel on tab switch
340
- if (state.panel.open) closePanel();
341
-
342
- if (name === 'knowledge' && state.knowledge.entries.length === 0) loadKnowledge();
343
- if (name === 'sessions' && state.sessions.list.length === 0) loadSessions();
344
- if (name === 'embeddings') loadEmbeddingStats();
345
- }
346
-
347
- // ── Knowledge ──────────────────────────────────────────────────────────────
348
-
349
- async function loadKnowledge() {
350
- try {
351
- const data = await api('/knowledge');
352
- const entries = Array.isArray(data) ? data : data.entries || [];
353
- state.knowledge.entries = entries;
354
- state.stats.knowledgeCount = entries.length;
355
- updateStats();
356
- renderKnowledge();
357
- } catch (err) {
358
- toast(`Failed to load knowledge: ${err.message}`, 'error');
359
- }
360
- }
361
-
362
- function renderKnowledge() {
363
- const cat = state.knowledge.activeCategory;
364
- const filtered =
365
- cat === 'all'
366
- ? state.knowledge.entries
367
- : state.knowledge.entries.filter((e) => e.category === cat);
368
-
369
- if (filtered.length === 0) {
370
- el.knowledgeGrid.innerHTML = '';
371
- el.knowledgeEmpty.classList.remove('hidden');
372
- return;
373
- }
374
-
375
- el.knowledgeEmpty.classList.add('hidden');
376
- el.knowledgeGrid.innerHTML = filtered
377
- .map((entry) => {
378
- const cat = entry.category || 'notes';
379
- const catIcons = {
380
- projects: 'code',
381
- people: 'group',
382
- decisions: 'gavel',
383
- workflows: 'account_tree',
384
- notes: 'sticky_note_2',
385
- };
386
- const icon = catIcons[cat] || 'article';
387
- const title = entry.title || entry.path || entry.id || 'Untitled';
388
- const preview = entry.preview || entry.excerpt || '';
389
- const tags = (entry.tags || [])
390
- .slice(0, 3)
391
- .map((t) => `<span class="card-tag">${esc(t)}</span>`)
392
- .join('');
393
- const time = relativeTime(entry.updated || entry.created);
394
-
395
- return `<div class="knowledge-card" data-path="${esc(entry.path || entry.id || '')}" tabindex="0" role="button">
396
- <span class="card-category" data-cat="${esc(cat)}">
397
- <span class="material-symbols-outlined" style="font-size:14px">${icon}</span>
398
- ${esc(cat)}
399
- </span>
400
- ${time ? `<span class="card-date">${time}</span>` : ''}
401
- <div class="card-title">${esc(title)}</div>
402
- ${tags ? `<div class="card-tags">${tags}</div>` : ''}
403
- </div>`;
404
- })
405
- .join('');
406
-
407
- el.knowledgeGrid.querySelectorAll('.knowledge-card').forEach((card) => {
408
- card.addEventListener('click', () => openKnowledgePanel(card.dataset.path));
409
- card.addEventListener('keydown', (e) => {
410
- if (e.key === 'Enter') openKnowledgePanel(card.dataset.path);
411
- });
412
- });
413
- }
414
-
415
- async function openKnowledgePanel(path) {
416
- if (!path) return;
417
- try {
418
- const data = await api(`/knowledge/${encodeURIComponent(path)}`);
419
- const title = data.title || data.path || path;
420
- const content = data.content || data.body || '';
421
- openPanel('knowledge', { title, content, meta: data });
422
- } catch (err) {
423
- toast(`Failed to load entry: ${err.message}`, 'error');
424
- }
425
- }
426
-
427
- // ── Search ─────────────────────────────────────────────────────────────────
428
-
429
- const doSearch = debounce(async () => {
430
- const q = state.search.query.trim();
431
- if (!q) {
432
- state.search.results = [];
433
- renderSearchResults();
434
- return;
435
- }
436
- state.search.loading = true;
437
- renderSearchResults();
438
- try {
439
- const params = new URLSearchParams({ q });
440
- let endpoint;
441
- if (state.search.scope !== 'all') {
442
- params.set('scope', state.search.scope);
443
- params.set('semantic', state.search.semantic);
444
- endpoint = `/sessions/recall?${params}`;
445
- } else {
446
- if (state.search.role !== 'all') params.set('role', state.search.role);
447
- params.set('ranked', state.search.ranked);
448
- params.set('semantic', state.search.semantic);
449
- endpoint = `/sessions/search?${params}`;
450
- }
451
- const data = await api(endpoint);
452
- state.search.results = Array.isArray(data) ? data : data.results || [];
453
- } catch (err) {
454
- toast(`Search failed: ${err.message}`, 'error');
455
- state.search.results = [];
456
- }
457
- state.search.loading = false;
458
- renderSearchResults();
459
- }, 300);
460
-
461
- function renderSearchResults() {
462
- const { results, loading, query } = state.search;
463
-
464
- if (loading) {
465
- el.searchResults.innerHTML =
466
- '<div class="loading-inline"><div class="loading-spinner small"></div><span>Searching...</span></div>';
467
- el.searchEmpty.classList.add('hidden');
468
- return;
469
- }
470
-
471
- if (!query.trim()) {
472
- el.searchResults.innerHTML = '';
473
- el.searchEmpty.classList.remove('hidden');
474
- return;
475
- }
476
-
477
- if (results.length === 0) {
478
- el.searchResults.innerHTML = '';
479
- el.searchEmpty.querySelector('.empty-text').textContent = 'No results found';
480
- el.searchEmpty.querySelector('.empty-hint').textContent = `No matches for "${query}"`;
481
- el.searchEmpty.classList.remove('hidden');
482
- return;
483
- }
484
-
485
- el.searchEmpty.classList.add('hidden');
486
- el.searchResults.innerHTML = results
487
- .map((r) => {
488
- const sessionId = r.id || r.sessionId || r.session_id || '';
489
- const excerpt = r.excerpt || r.text || r.content || '';
490
- const role = r.role || '';
491
- const score = r.score;
492
- const meta = r.metadata;
493
- const project = r.project || '';
494
- const time = relativeTime(r.timestamp || r.date);
495
- const roleIcon = role === 'user' ? 'person' : role === 'assistant' ? 'smart_toy' : 'chat';
496
-
497
- return `<div class="result-item" data-session-id="${esc(sessionId)}" tabindex="0" role="button">
498
- <div class="result-meta">
499
- <span class="role-badge" data-role="${esc(role)}"><span class="material-symbols-outlined" style="font-size:12px">${roleIcon}</span> ${esc(role)}</span>
500
- ${project ? `<span class="result-project">${esc(project)}</span>` : ''}
501
- ${time ? `<span class="result-date">${time}</span>` : ''}
502
- ${score != null ? `<span class="score-container">${formatScore(score, meta)}</span>` : ''}
503
- </div>
504
- <div class="result-excerpt">${highlightExcerpt(excerpt, query)}</div>
505
- </div>`;
506
- })
507
- .join('');
508
-
509
- el.searchResults.querySelectorAll('.result-item').forEach((card) => {
510
- card.addEventListener('click', () => openSessionPanel(card.dataset.sessionId));
511
- card.addEventListener('keydown', (e) => {
512
- if (e.key === 'Enter') openSessionPanel(card.dataset.sessionId);
513
- });
514
- });
515
- }
516
-
517
- // ── Sessions ───────────────────────────────────────────────────────────────
518
-
519
- async function loadSessions() {
520
- if (state.sessions.loading) return;
521
- state.sessions.loading = true;
522
- try {
523
- const data = await api('/sessions');
524
- const list = Array.isArray(data) ? data : data.sessions || [];
525
- state.sessions.list = list;
526
- state.stats.sessionCount = list.length;
527
- updateStats();
528
- populateProjectFilter(list);
529
- renderSessions();
530
- } catch (err) {
531
- toast(`Failed to load sessions: ${err.message}`, 'error');
532
- }
533
- state.sessions.loading = false;
534
- }
535
-
536
- function populateProjectFilter(sessions) {
537
- const projects = [...new Set(sessions.map((s) => s.project).filter(Boolean))].sort();
538
- const sel = el.sessionProjectFilter;
539
- const current = sel.value;
540
- sel.innerHTML =
541
- '<option value="">All projects</option>' +
542
- projects.map((p) => `<option value="${esc(p)}">${esc(p)}</option>`).join('');
543
- sel.value = current;
544
- }
545
-
546
- function renderSessions() {
547
- const filter = state.sessions.projectFilter;
548
- const list = filter
549
- ? state.sessions.list.filter((s) => s.project === filter)
550
- : state.sessions.list;
551
-
552
- if (list.length === 0) {
553
- el.sessionsList.innerHTML = '';
554
- el.sessionsEmpty.classList.remove('hidden');
555
- return;
556
- }
557
-
558
- el.sessionsEmpty.classList.add('hidden');
559
- el.sessionsList.innerHTML = list
560
- .map((s) => {
561
- const id = s.sessionId || s.id || '';
562
- const project = s.project || '';
563
- const branch = s.branch || s.gitBranch || s.git_branch || '';
564
- const count = s.messageCount || s.message_count || s.count || 0;
565
- const date = s.startTime || s.date || s.created || s.startedAt || '';
566
- const preview = s.preview || '';
567
- const time = relativeTime(date);
568
- const dateStr = date
569
- ? new Date(date).toLocaleDateString(undefined, {
570
- month: 'short',
571
- day: 'numeric',
572
- year: 'numeric',
573
- })
574
- : '';
575
-
576
- return `<div class="session-card" data-session-id="${esc(id)}" tabindex="0" role="button">
577
- <div class="session-header">
578
- <span class="session-project">${esc(project)}</span>
579
- <span class="session-date">${dateStr || time || ''}</span>
580
- </div>
581
- <div class="session-meta">
582
- ${branch ? `<span class="session-meta-item"><span class="material-symbols-outlined">alt_route</span>${esc(branch)}</span>` : ''}
583
- <span class="session-meta-item"><span class="material-symbols-outlined">chat</span>${count} messages</span>
584
- <span class="session-meta-item"><span class="material-symbols-outlined">tag</span>${esc(id.slice(0, 8))}</span>
585
- </div>
586
- ${preview ? `<div class="session-preview">${esc(preview)}</div>` : ''}
587
- </div>`;
588
- })
589
- .join('');
590
-
591
- el.sessionsList.querySelectorAll('.session-card').forEach((card) => {
592
- card.addEventListener('click', () => openSessionPanel(card.dataset.sessionId));
593
- card.addEventListener('keydown', (e) => {
594
- if (e.key === 'Enter') openSessionPanel(card.dataset.sessionId);
595
- });
596
- });
597
- }
598
-
599
- async function openSessionPanel(sessionId) {
600
- if (!sessionId) return;
601
- try {
602
- const [session, summary] = await Promise.allSettled([
603
- api(`/sessions/${encodeURIComponent(sessionId)}`),
604
- api(`/sessions/${encodeURIComponent(sessionId)}/summary`),
605
- ]);
606
- const sData = session.status === 'fulfilled' ? session.value : {};
607
- const sumData = summary.status === 'fulfilled' ? summary.value : null;
608
- openPanel('session', { session: sData, summary: sumData, sessionId });
609
- } catch (err) {
610
- toast(`Failed to load session: ${err.message}`, 'error');
611
- }
612
- }
613
-
614
- // ── Embeddings ─────────────────────────────────────────────────────────────
615
-
616
- async function loadEmbeddingStats() {
617
- try {
618
- const data = await api('/index-status');
619
- state.embeddings.stats = data;
620
- renderEmbeddingStats();
621
- el.statVectors.querySelector('.stat-value').textContent = data.totalEntries || 0;
622
- } catch {
623
- state.embeddings.stats = null;
624
- renderEmbeddingStats();
625
- }
626
- }
627
-
628
- function renderEmbeddingStats() {
629
- const stats = state.embeddings.stats;
630
- if (!stats || !stats.totalEntries) {
631
- el.embeddingsStatsGrid.innerHTML = '';
632
- el.embeddingsStatus.style.display = 'none';
633
- el.embeddingsEmpty.classList.remove('hidden');
634
- return;
635
- }
636
-
637
- el.embeddingsEmpty.classList.add('hidden');
638
- el.embeddingsStatus.style.display = '';
639
-
640
- const cards = [
641
- { label: 'Provider', value: stats.provider || 'Not configured', detail: '' },
642
- { label: 'Dimensions', value: stats.dimensions || 0, detail: 'vector size' },
643
- { label: 'Total Entries', value: stats.totalEntries || 0, detail: 'indexed chunks' },
644
- { label: 'Knowledge', value: stats.knowledgeEntries || 0, detail: 'knowledge vectors' },
645
- { label: 'Sessions', value: stats.sessionEntries || 0, detail: 'session vectors' },
646
- { label: 'DB Size', value: (stats.dbSizeMB || 0).toFixed(1) + ' MB', detail: 'on disk' },
647
- ];
648
-
649
- el.embeddingsStatsGrid.innerHTML = cards
650
- .map(
651
- (c) => `
652
- <div class="embedding-stat-card">
653
- <span class="stat-label">${esc(c.label)}</span>
654
- <span class="stat-number">${esc(String(c.value))}</span>
655
- ${c.detail ? `<span class="stat-detail">${esc(c.detail)}</span>` : ''}
656
- </div>
657
- `,
658
- )
659
- .join('');
660
- }
661
-
662
- // ── Side Panel ─────────────────────────────────────────────────────────────
663
-
664
- function openPanel(type, data) {
665
- state.panel = { open: true, type, data };
666
- el.sidePanel.hidden = false;
667
- requestAnimationFrame(() => el.sidePanel.classList.add('open'));
668
- el.contentWrapper.classList.add('panel-visible');
669
-
670
- if (type === 'knowledge') {
671
- renderKnowledgePanel(data);
672
- } else if (type === 'session') {
673
- renderSessionPanel(data);
674
- }
675
- }
676
-
677
- function closePanel() {
678
- state.panel = { open: false, type: null, data: null };
679
- el.sidePanel.classList.remove('open');
680
- el.contentWrapper.classList.remove('panel-visible');
681
- el.sidePanel.addEventListener(
682
- 'transitionend',
683
- function handler() {
684
- if (!state.panel.open) el.sidePanel.hidden = true;
685
- el.sidePanel.removeEventListener('transitionend', handler);
686
- },
687
- { once: true },
688
- );
689
- // Fallback: hide after 400ms if transitionend doesn't fire
690
- setTimeout(() => {
691
- if (!state.panel.open) el.sidePanel.hidden = true;
692
- }, 400);
693
- }
694
-
695
- function stripFrontmatter(content) {
696
- const match = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/);
697
- return match ? match[1].trim() : content;
698
- }
699
-
700
- function renderKnowledgePanel(data) {
701
- el.panelTitle.innerHTML = `<span class="material-symbols-outlined panel-icon">article</span>${esc(data.title)}`;
702
- const meta = data.meta || {};
703
- const entry = meta.entry || meta;
704
- const category = entry.category || meta.category || '';
705
- const tags = entry.tags || meta.tags || [];
706
- const updated = entry.updated || meta.updated || '';
707
-
708
- let metaHtml = '<div class="panel-meta">';
709
- if (category)
710
- metaHtml += `<span class="card-category" data-cat="${esc(category)}">${esc(category)}</span>`;
711
- if (tags.length)
712
- metaHtml += tags.map((t) => `<span class="card-tag">${esc(t)}</span>`).join('');
713
- if (updated) metaHtml += `<span class="panel-meta-date">${esc(updated)}</span>`;
714
- metaHtml += '</div>';
715
-
716
- const body = stripFrontmatter(data.content);
717
- el.panelBody.innerHTML = metaHtml + `<div class="prose">${renderMd(body)}</div>`;
718
- }
719
-
720
- function renderSessionPanel(data) {
721
- const s = data.session || {};
722
- const summary = data.summary;
723
- const id = data.sessionId || s.id || '';
724
- const meta = s.meta || s;
725
-
726
- el.panelTitle.innerHTML = `<span class="material-symbols-outlined panel-icon">terminal</span>Session ${esc(id.slice(0, 8))}`;
727
-
728
- let html = '<div class="panel-meta">';
729
- if (meta.cwd)
730
- html += `<span class="panel-meta-item"><span class="material-symbols-outlined" style="font-size:14px">folder</span>${esc(meta.cwd)}</span>`;
731
- if (meta.branch)
732
- html += `<span class="panel-meta-item"><span class="material-symbols-outlined" style="font-size:14px">alt_route</span>${esc(meta.branch)}</span>`;
733
- if (meta.startTime && meta.startTime !== 'unknown')
734
- html += `<span class="panel-meta-item"><span class="material-symbols-outlined" style="font-size:14px">schedule</span>${new Date(meta.startTime).toLocaleString()}</span>`;
735
- if (meta.messageCount)
736
- html += `<span class="panel-meta-item"><span class="material-symbols-outlined" style="font-size:14px">chat</span>${meta.messageCount} messages</span>`;
737
- html += '</div>';
738
-
739
- if (summary && summary.topics && summary.topics.length) {
740
- html +=
741
- '<div class="panel-section"><h4 class="panel-section-title">Topics</h4><ul class="panel-topics">';
742
- summary.topics.slice(0, 8).forEach((t) => {
743
- html += `<li>${esc(t.content)}</li>`;
744
- });
745
- html += '</ul></div>';
746
- }
747
-
748
- const messages = s.messages || s.conversation || [];
749
- // Filter out noisy messages (tool JSON, base64, system reminders)
750
- const cleanMessages = messages.filter((m) => {
751
- const t = (m.content || '').trimStart();
752
- if (t.startsWith('[{') || t.startsWith('{"')) return false;
753
- if (t.includes('tool_use_id') || t.includes('tool_result')) return false;
754
- if (t.includes('base64') || t.includes('media_type')) return false;
755
- if (t.includes('<system-reminder>')) return false;
756
- if (t.length < 3) return false;
757
- return true;
758
- });
759
-
760
- if (cleanMessages.length) {
761
- html += '<div class="chat-messages">';
762
- cleanMessages.forEach((m) => {
763
- const role = m.role || 'unknown';
764
- const text = m.content || m.text || '';
765
- const isUser = role === 'user';
766
- const cls = isUser ? 'chat-msg user' : 'chat-msg assistant';
767
- const roleIcon = isUser ? 'person' : 'smart_toy';
768
- const truncated = text.length > 1500 ? text.slice(0, 1500) + '...' : text;
769
- const time = m.timestamp ? relativeTime(m.timestamp) : '';
770
-
771
- html += `<div class="${cls}">
772
- <div class="chat-msg-role"><span class="material-symbols-outlined" style="font-size:14px">${roleIcon}</span> ${esc(role)} ${time ? `<span class="chat-msg-time">${time}</span>` : ''}</div>
773
- <div>${isUser ? esc(truncated) : renderMd(truncated)}</div>
774
- </div>`;
775
- });
776
- html += '</div>';
777
- } else {
778
- html +=
779
- '<div class="empty-state"><span class="material-symbols-outlined empty-icon">chat_bubble</span><div class="empty-text">No messages available</div></div>';
780
- }
781
-
782
- el.panelBody.innerHTML = html;
783
- }
784
-
785
- // ── Event Binding ──────────────────────────────────────────────────────────
786
-
787
- function bindEvents() {
788
- // Tabs
789
- Object.keys(el.tabs).forEach((k) => {
790
- el.tabs[k].addEventListener('click', () => switchTab(k));
791
- });
792
-
793
- // Theme
794
- el.themeToggle.addEventListener('click', toggleTheme);
795
-
796
- // Panel close
797
- el.panelClose.addEventListener('click', closePanel);
798
-
799
- // Knowledge category chips
800
- el.knowledgeCategories.addEventListener('click', (e) => {
801
- const chip = e.target.closest('.chip');
802
- if (!chip) return;
803
- el.knowledgeCategories.querySelectorAll('.chip').forEach((c) => c.classList.remove('active'));
804
- chip.classList.add('active');
805
- state.knowledge.activeCategory = chip.dataset.category;
806
- renderKnowledge();
807
- });
808
-
809
- // Search input
810
- el.searchInput.addEventListener('input', () => {
811
- state.search.query = el.searchInput.value;
812
- doSearch();
813
- });
814
-
815
- // Search role filters
816
- el.searchRoleFilters.addEventListener('click', (e) => {
817
- const chip = e.target.closest('.chip');
818
- if (!chip) return;
819
- el.searchRoleFilters.querySelectorAll('.chip').forEach((c) => c.classList.remove('active'));
820
- chip.classList.add('active');
821
- state.search.role = chip.dataset.role;
822
- if (state.search.query.trim()) doSearch();
823
- });
824
-
825
- function setSearchMode(mode) {
826
- el.modeRanked.classList.toggle('active', mode === 'ranked');
827
- el.modeSemantic.classList.toggle('active', mode === 'semantic');
828
- el.modeRegex.classList.toggle('active', mode === 'regex');
829
- state.search.ranked = mode !== 'regex';
830
- state.search.semantic = mode === 'semantic';
831
- if (state.search.query.trim()) doSearch();
832
- }
833
-
834
- el.modeRanked.addEventListener('click', () => setSearchMode('ranked'));
835
- el.modeSemantic.addEventListener('click', () => setSearchMode('semantic'));
836
- el.modeRegex.addEventListener('click', () => setSearchMode('regex'));
837
-
838
- // Session project filter
839
- el.sessionProjectFilter.addEventListener('change', () => {
840
- state.sessions.projectFilter = el.sessionProjectFilter.value;
841
- renderSessions();
842
- });
843
-
844
- // Search scope chips
845
- el.searchScopes.addEventListener('click', (e) => {
846
- const chip = e.target.closest('.chip');
847
- if (!chip) return;
848
- el.searchScopes.querySelectorAll('.chip').forEach((c) => c.classList.remove('active'));
849
- chip.classList.add('active');
850
- state.search.scope = chip.dataset.scope;
851
- if (state.search.query.trim()) doSearch();
852
- });
853
-
854
- // Keyboard shortcuts
855
- document.addEventListener('keydown', (e) => {
856
- // Escape closes panel
857
- if (e.key === 'Escape' && state.panel.open) {
858
- e.preventDefault();
859
- closePanel();
860
- return;
861
- }
862
-
863
- // / or Ctrl+K focuses search
864
- if ((e.key === '/' || (e.ctrlKey && e.key === 'k')) && !isInputFocused()) {
865
- e.preventDefault();
866
- switchTab('search');
867
- el.searchInput.focus();
868
- }
869
- });
870
-
871
- // Click outside panel to close
872
- el.contentWrapper.addEventListener('click', (e) => {
873
- if (state.panel.open && !el.sidePanel.contains(e.target)) {
874
- // Only close if clicking on the main content area backdrop
875
- }
876
- });
877
- }
878
-
879
- function isInputFocused() {
880
- const active = document.activeElement;
881
- if (!active) return false;
882
- const tag = active.tagName.toLowerCase();
883
- return tag === 'input' || tag === 'textarea' || tag === 'select' || active.isContentEditable;
884
- }
885
-
886
- // ── Init ───────────────────────────────────────────────────────────────────
887
-
888
- async function init() {
889
- initTheme();
890
- bindEvents();
891
- wsConnect();
892
-
893
- // Load initial data in parallel
894
- try {
895
- await Promise.allSettled([loadKnowledge(), loadSessions(), loadEmbeddingStats()]);
896
- } catch {
897
- // individual loaders handle their own errors
898
- }
899
-
900
- // Hide loading overlay after a short delay if ws hasn't connected
901
- setTimeout(() => {
902
- el.loadingOverlay.classList.add('hidden');
903
- }, 5000);
904
- }
905
-
906
- // Panel resize handle (drag to resize like agent-tasks)
907
- function initPanelResize() {
908
- const panel = document.getElementById('side-panel');
909
- if (!panel) return;
910
- const handle = document.createElement('div');
911
- handle.className = 'panel-resize-handle';
912
- panel.appendChild(handle);
913
-
914
- let isResizing = false;
915
- let startX = 0;
916
- let startWidth = 0;
917
-
918
- handle.addEventListener('mousedown', (e) => {
919
- isResizing = true;
920
- startX = e.clientX;
921
- startWidth = panel.offsetWidth;
922
- document.body.style.cursor = 'col-resize';
923
- document.body.style.userSelect = 'none';
924
- e.preventDefault();
925
- });
926
-
927
- document.addEventListener('mousemove', (e) => {
928
- if (!isResizing) return;
929
- const dx = startX - e.clientX;
930
- const newWidth = Math.max(320, Math.min(startWidth + dx, window.innerWidth * 0.8));
931
- panel.style.width = newWidth + 'px';
932
- panel.style.minWidth = newWidth + 'px';
933
- });
934
-
935
- document.addEventListener('mouseup', () => {
936
- if (!isResizing) return;
937
- isResizing = false;
938
- document.body.style.cursor = '';
939
- document.body.style.userSelect = '';
940
- });
941
- }
942
-
943
- // Start
944
- if (document.readyState === 'loading') {
945
- document.addEventListener('DOMContentLoaded', () => {
946
- init();
947
- initPanelResize();
948
- });
949
- } else {
950
- init();
951
- initPanelResize();
952
- }
953
- })();
1
+ /* agent-knowledge dashboard — vanilla JS SPA */
2
+ (function () {
3
+ 'use strict';
4
+
5
+ // ── State ──────────────────────────────────────────────────────────────────
6
+ const state = {
7
+ activeTab: 'knowledge',
8
+ knowledge: { entries: [], activeCategory: 'all' },
9
+ search: {
10
+ query: '',
11
+ results: [],
12
+ role: 'all',
13
+ scope: 'all',
14
+ ranked: true,
15
+ semantic: false,
16
+ loading: false,
17
+ },
18
+ sessions: { list: [], projectFilter: '', loading: false },
19
+ embeddings: { stats: null, loading: false },
20
+ panel: { open: false, type: null, data: null },
21
+ stats: { knowledgeCount: 0, sessionCount: 0 },
22
+ connected: false,
23
+ };
24
+
25
+ // ── DOM refs ───────────────────────────────────────────────────────────────
26
+ const $ = (id) => document.getElementById(id);
27
+ const el = {
28
+ tabs: {
29
+ knowledge: $('tab-knowledge'),
30
+ search: $('tab-search'),
31
+ sessions: $('tab-sessions'),
32
+ embeddings: $('tab-embeddings'),
33
+ },
34
+ views: {
35
+ knowledge: $('view-knowledge'),
36
+ search: $('view-search'),
37
+ sessions: $('view-sessions'),
38
+ embeddings: $('view-embeddings'),
39
+ },
40
+ knowledgeGrid: $('knowledge-grid'),
41
+ knowledgeEmpty: $('knowledge-empty'),
42
+ knowledgeCategories: $('knowledge-categories'),
43
+ searchInput: $('search-input'),
44
+ searchResults: $('search-results'),
45
+ searchEmpty: $('search-empty'),
46
+ searchRoleFilters: $('search-role-filters'),
47
+ modeRanked: $('mode-ranked'),
48
+ modeSemantic: $('mode-semantic'),
49
+ modeRegex: $('mode-regex'),
50
+ sessionsList: $('sessions-list'),
51
+ sessionsEmpty: $('sessions-empty'),
52
+ sessionProjectFilter: $('session-project-filter'),
53
+ searchScopes: $('search-scopes'),
54
+ sidePanel: $('side-panel'),
55
+ panelTitle: $('panel-title'),
56
+ panelBody: $('panel-body'),
57
+ panelClose: $('panel-close'),
58
+ connectionStatus: $('connection-status'),
59
+ statKnowledge: $('stat-knowledge'),
60
+ statSessions: $('stat-sessions'),
61
+ statVectors: $('stat-vectors'),
62
+ embeddingsStatsGrid: $('embedding-stats-grid'),
63
+ embeddingsEmpty: $('embeddings-empty'),
64
+ embeddingsStatus: $('embeddings-status'),
65
+ themeToggle: $('theme-toggle'),
66
+ version: $('version'),
67
+ loadingOverlay: $('loading-overlay'),
68
+ toastContainer: $('toast-container'),
69
+ contentWrapper: $('content-wrapper'),
70
+ };
71
+
72
+ // ── Utilities ──────────────────────────────────────────────────────────────
73
+
74
+ function esc(str) {
75
+ const d = document.createElement('div');
76
+ d.textContent = str;
77
+ return d.innerHTML;
78
+ }
79
+
80
+ function debounce(fn, ms) {
81
+ let t;
82
+ return (...args) => {
83
+ clearTimeout(t);
84
+ t = setTimeout(() => fn(...args), ms);
85
+ };
86
+ }
87
+
88
+ function relativeTime(dateStr) {
89
+ if (!dateStr) return '';
90
+ const now = Date.now();
91
+ const then = new Date(dateStr).getTime();
92
+ const diff = now - then;
93
+ if (diff < 0) return 'just now';
94
+ const secs = Math.floor(diff / 1000);
95
+ if (secs < 60) return 'just now';
96
+ const mins = Math.floor(secs / 60);
97
+ if (mins < 60) return `${mins}m ago`;
98
+ const hrs = Math.floor(mins / 60);
99
+ if (hrs < 24) return `${hrs}h ago`;
100
+ const days = Math.floor(hrs / 24);
101
+ if (days < 30) return `${days}d ago`;
102
+ const months = Math.floor(days / 30);
103
+ if (months < 12) return `${months}mo ago`;
104
+ return `${Math.floor(months / 12)}y ago`;
105
+ }
106
+
107
+ function renderMd(raw) {
108
+ if (!raw) return '';
109
+ try {
110
+ const html = marked.parse(raw, { breaks: true, gfm: true });
111
+ const clean = DOMPurify.sanitize(html, { ADD_TAGS: ['pre', 'code'] });
112
+ const wrapper = document.createElement('div');
113
+ wrapper.innerHTML = clean;
114
+ wrapper.querySelectorAll('pre code').forEach((block) => {
115
+ try {
116
+ hljs.highlightElement(block);
117
+ } catch (_) {
118
+ /* noop */
119
+ }
120
+ });
121
+ return wrapper.innerHTML;
122
+ } catch {
123
+ return esc(raw);
124
+ }
125
+ }
126
+
127
+ function highlightExcerpt(text, query) {
128
+ if (!text || !query) return esc(text || '');
129
+ try {
130
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
131
+ const re = new RegExp(`(${escaped})`, 'gi');
132
+ return esc(text).replace(re, '<mark>$1</mark>');
133
+ } catch {
134
+ return esc(text);
135
+ }
136
+ }
137
+
138
+ function formatScore(score, metadata) {
139
+ if (score == null) return '';
140
+ const pct = Math.min(Math.max(score * 100, 0), 100);
141
+ const tfidf = metadata?.tfidfScore;
142
+ const semantic = metadata?.semanticScore;
143
+ const recency = metadata?.recencyMultiplier;
144
+ const alpha = metadata?.blendAlpha;
145
+
146
+ let tooltip = `Score: ${score.toFixed(3)}`;
147
+ if (tfidf != null && semantic != null) {
148
+ tooltip += ` (TF-IDF: ${tfidf.toFixed(2)} \u00d7 ${((alpha || 0.3) * 100).toFixed(0)}% + Semantic: ${semantic.toFixed(2)} \u00d7 ${((1 - (alpha || 0.3)) * 100).toFixed(0)}%)`;
149
+ }
150
+ if (recency != null) tooltip += ` \u00d7 recency ${(recency * 100).toFixed(0)}%`;
151
+
152
+ const recencyTag =
153
+ recency != null && recency < 0.95
154
+ ? `<span class="recency-tag" title="Recency: ${(recency * 100).toFixed(0)}%">${(recency * 100).toFixed(0)}%</span>`
155
+ : '';
156
+ const scoreType =
157
+ tfidf != null && semantic != null
158
+ ? '<span class="score-type hybrid">hybrid</span>'
159
+ : tfidf != null
160
+ ? '<span class="score-type tfidf">tf-idf</span>'
161
+ : '';
162
+ return `<div class="score-bar" title="${tooltip}">
163
+ <div class="score-fill" style="width:${pct}%"></div>
164
+ <span class="score-label">${score.toFixed(2)}${recencyTag}${scoreType}</span>
165
+ </div>`;
166
+ }
167
+
168
+ function toast(msg, type = 'info', duration = 4000) {
169
+ const t = document.createElement('div');
170
+ t.className = `toast toast-${type}`;
171
+ const icons = { info: 'info', success: 'check_circle', error: 'error', warning: 'warning' };
172
+ t.innerHTML = `<span class="material-symbols-outlined toast-icon">${icons[type] || 'info'}</span>
173
+ <span class="toast-msg">${esc(msg)}</span>`;
174
+ el.toastContainer.appendChild(t);
175
+ requestAnimationFrame(() => t.classList.add('show'));
176
+ setTimeout(() => {
177
+ t.classList.remove('show');
178
+ t.addEventListener('transitionend', () => t.remove());
179
+ }, duration);
180
+ }
181
+
182
+ // ── API ────────────────────────────────────────────────────────────────────
183
+
184
+ async function api(path) {
185
+ const res = await fetch(`/api${path}`);
186
+ if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`);
187
+ return res.json();
188
+ }
189
+
190
+ // ── WebSocket ──────────────────────────────────────────────────────────────
191
+
192
+ let ws = null;
193
+ let wsRetry = null;
194
+
195
+ function wsConnect() {
196
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
197
+ ws = new WebSocket(`${proto}://${location.host}`);
198
+
199
+ ws.addEventListener('open', () => {
200
+ state.connected = true;
201
+ updateConnectionStatus();
202
+ el.loadingOverlay.classList.add('hidden');
203
+ if (wsRetry) {
204
+ clearTimeout(wsRetry);
205
+ wsRetry = null;
206
+ }
207
+ });
208
+
209
+ ws.addEventListener('message', (evt) => {
210
+ try {
211
+ const msg = JSON.parse(evt.data);
212
+ handleWsMessage(msg);
213
+ } catch {
214
+ /* ignore non-json */
215
+ }
216
+ });
217
+
218
+ ws.addEventListener('close', () => {
219
+ state.connected = false;
220
+ updateConnectionStatus();
221
+ scheduleReconnect();
222
+ });
223
+
224
+ ws.addEventListener('error', () => {
225
+ state.connected = false;
226
+ updateConnectionStatus();
227
+ });
228
+ }
229
+
230
+ function scheduleReconnect() {
231
+ if (wsRetry) return;
232
+ wsRetry = setTimeout(() => {
233
+ wsRetry = null;
234
+ wsConnect();
235
+ }, 3000);
236
+ }
237
+
238
+ function handleWsMessage(msg) {
239
+ switch (msg.type) {
240
+ case 'reload':
241
+ location.reload();
242
+ return;
243
+ case 'state':
244
+ if (msg.knowledge) {
245
+ state.knowledge.entries = msg.knowledge;
246
+ renderKnowledge();
247
+ }
248
+ if (msg.sessions) {
249
+ state.sessions.list = msg.sessions;
250
+ renderSessions();
251
+ }
252
+ if (msg.stats) {
253
+ state.stats.knowledgeCount = msg.stats.knowledge_entries || 0;
254
+ state.stats.sessionCount = msg.stats.session_count || 0;
255
+ if (msg.stats.version) el.version.textContent = 'v' + msg.stats.version;
256
+ updateStats();
257
+ }
258
+ el.loadingOverlay.classList.add('hidden');
259
+ break;
260
+ case 'knowledge:update':
261
+ case 'knowledge:change':
262
+ loadKnowledge();
263
+ break;
264
+ case 'session:update':
265
+ case 'session:new':
266
+ loadSessions();
267
+ break;
268
+ case 'stats':
269
+ if (msg.data) {
270
+ if (msg.data.knowledgeCount != null) state.stats.knowledgeCount = msg.data.knowledgeCount;
271
+ if (msg.data.sessionCount != null) state.stats.sessionCount = msg.data.sessionCount;
272
+ updateStats();
273
+ }
274
+ break;
275
+ case 'version':
276
+ if (msg.data) el.version.textContent = msg.data;
277
+ break;
278
+ default:
279
+ break;
280
+ }
281
+ }
282
+
283
+ function updateConnectionStatus() {
284
+ const s = el.connectionStatus;
285
+ if (state.connected) {
286
+ s.className = 'status-badge connected';
287
+ s.textContent = 'Connected';
288
+ } else {
289
+ s.className = 'status-badge disconnected';
290
+ s.textContent = 'Disconnected';
291
+ }
292
+ }
293
+
294
+ // ── Stats ──────────────────────────────────────────────────────────────────
295
+
296
+ function updateStats() {
297
+ el.statKnowledge.querySelector('.stat-value').textContent = state.stats.knowledgeCount;
298
+ el.statSessions.querySelector('.stat-value').textContent = state.stats.sessionCount;
299
+ }
300
+
301
+ // ── Theme ──────────────────────────────────────────────────────────────────
302
+
303
+ function initTheme() {
304
+ const saved = localStorage.getItem('agent-knowledge-theme');
305
+ const theme = saved || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
306
+ applyTheme(theme);
307
+ }
308
+
309
+ function applyTheme(theme) {
310
+ document.documentElement.setAttribute('data-theme', theme);
311
+ localStorage.setItem('agent-knowledge-theme', theme);
312
+ const icon = el.themeToggle.querySelector('.theme-icon');
313
+ if (icon) icon.textContent = theme === 'dark' ? 'light_mode' : 'dark_mode';
314
+ }
315
+
316
+ function toggleTheme() {
317
+ const cur = document.documentElement.getAttribute('data-theme') || 'light';
318
+ applyTheme(cur === 'dark' ? 'light' : 'dark');
319
+ }
320
+
321
+ // ── Tabs ───────────────────────────────────────────────────────────────────
322
+
323
+ function switchTab(name, updateHash = true) {
324
+ if (state.activeTab === name) return;
325
+ state.activeTab = name;
326
+ if (updateHash) location.hash = '#' + name;
327
+
328
+ Object.keys(el.tabs).forEach((k) => {
329
+ const active = k === name;
330
+ el.tabs[k].classList.toggle('active', active);
331
+ el.tabs[k].setAttribute('aria-selected', active);
332
+ });
333
+
334
+ Object.keys(el.views).forEach((k) => {
335
+ const active = k === name;
336
+ el.views[k].classList.toggle('active', active);
337
+ el.views[k].hidden = !active;
338
+ });
339
+
340
+ // Close side panel on tab switch
341
+ if (state.panel.open) closePanel();
342
+
343
+ if (name === 'knowledge' && state.knowledge.entries.length === 0) loadKnowledge();
344
+ if (name === 'sessions' && state.sessions.list.length === 0) loadSessions();
345
+ if (name === 'embeddings') loadEmbeddingStats();
346
+ }
347
+
348
+ // ── Knowledge ──────────────────────────────────────────────────────────────
349
+
350
+ async function loadKnowledge() {
351
+ try {
352
+ const data = await api('/knowledge');
353
+ const entries = Array.isArray(data) ? data : data.entries || [];
354
+ state.knowledge.entries = entries;
355
+ state.stats.knowledgeCount = entries.length;
356
+ updateStats();
357
+ renderKnowledge();
358
+ } catch (err) {
359
+ toast(`Failed to load knowledge: ${err.message}`, 'error');
360
+ }
361
+ }
362
+
363
+ function renderKnowledge() {
364
+ const cat = state.knowledge.activeCategory;
365
+ const filtered =
366
+ cat === 'all'
367
+ ? state.knowledge.entries
368
+ : state.knowledge.entries.filter((e) => e.category === cat);
369
+
370
+ if (filtered.length === 0) {
371
+ el.knowledgeGrid.innerHTML = '';
372
+ el.knowledgeEmpty.classList.remove('hidden');
373
+ return;
374
+ }
375
+
376
+ el.knowledgeEmpty.classList.add('hidden');
377
+ el.knowledgeGrid.innerHTML = filtered
378
+ .map((entry) => {
379
+ const cat = entry.category || 'notes';
380
+ const catIcons = {
381
+ projects: 'code',
382
+ people: 'group',
383
+ decisions: 'gavel',
384
+ workflows: 'account_tree',
385
+ notes: 'sticky_note_2',
386
+ };
387
+ const icon = catIcons[cat] || 'article';
388
+ const title = entry.title || entry.path || entry.id || 'Untitled';
389
+ const preview = entry.preview || entry.excerpt || '';
390
+ const tags = (entry.tags || [])
391
+ .slice(0, 3)
392
+ .map((t) => `<span class="card-tag">${esc(t)}</span>`)
393
+ .join('');
394
+ const time = relativeTime(entry.updated || entry.created);
395
+
396
+ return `<div class="knowledge-card" data-path="${esc(entry.path || entry.id || '')}" tabindex="0" role="button">
397
+ <span class="card-category" data-cat="${esc(cat)}">
398
+ <span class="material-symbols-outlined" style="font-size:14px">${icon}</span>
399
+ ${esc(cat)}
400
+ </span>
401
+ ${time ? `<span class="card-date">${time}</span>` : ''}
402
+ <div class="card-title">${esc(title)}</div>
403
+ ${tags ? `<div class="card-tags">${tags}</div>` : ''}
404
+ </div>`;
405
+ })
406
+ .join('');
407
+
408
+ el.knowledgeGrid.querySelectorAll('.knowledge-card').forEach((card) => {
409
+ card.addEventListener('click', () => openKnowledgePanel(card.dataset.path));
410
+ card.addEventListener('keydown', (e) => {
411
+ if (e.key === 'Enter') openKnowledgePanel(card.dataset.path);
412
+ });
413
+ });
414
+ }
415
+
416
+ async function openKnowledgePanel(path) {
417
+ if (!path) return;
418
+ try {
419
+ const data = await api(`/knowledge/${encodeURIComponent(path)}`);
420
+ const title = data.title || data.path || path;
421
+ const content = data.content || data.body || '';
422
+ openPanel('knowledge', { title, content, meta: data });
423
+ } catch (err) {
424
+ toast(`Failed to load entry: ${err.message}`, 'error');
425
+ }
426
+ }
427
+
428
+ // ── Search ─────────────────────────────────────────────────────────────────
429
+
430
+ const doSearch = debounce(async () => {
431
+ const q = state.search.query.trim();
432
+ if (!q) {
433
+ state.search.results = [];
434
+ renderSearchResults();
435
+ return;
436
+ }
437
+ state.search.loading = true;
438
+ renderSearchResults();
439
+ try {
440
+ const params = new URLSearchParams({ q });
441
+ let endpoint;
442
+ if (state.search.scope !== 'all') {
443
+ params.set('scope', state.search.scope);
444
+ params.set('semantic', state.search.semantic);
445
+ endpoint = `/sessions/recall?${params}`;
446
+ } else {
447
+ if (state.search.role !== 'all') params.set('role', state.search.role);
448
+ params.set('ranked', state.search.ranked);
449
+ params.set('semantic', state.search.semantic);
450
+ endpoint = `/sessions/search?${params}`;
451
+ }
452
+ const data = await api(endpoint);
453
+ state.search.results = Array.isArray(data) ? data : data.results || [];
454
+ } catch (err) {
455
+ toast(`Search failed: ${err.message}`, 'error');
456
+ state.search.results = [];
457
+ }
458
+ state.search.loading = false;
459
+ renderSearchResults();
460
+ }, 300);
461
+
462
+ function renderSearchResults() {
463
+ const { results, loading, query } = state.search;
464
+
465
+ if (loading) {
466
+ el.searchResults.innerHTML =
467
+ '<div class="loading-inline"><div class="loading-spinner small"></div><span>Searching...</span></div>';
468
+ el.searchEmpty.classList.add('hidden');
469
+ return;
470
+ }
471
+
472
+ if (!query.trim()) {
473
+ el.searchResults.innerHTML = '';
474
+ el.searchEmpty.classList.remove('hidden');
475
+ return;
476
+ }
477
+
478
+ if (results.length === 0) {
479
+ el.searchResults.innerHTML = '';
480
+ el.searchEmpty.querySelector('.empty-text').textContent = 'No results found';
481
+ el.searchEmpty.querySelector('.empty-hint').textContent = `No matches for "${query}"`;
482
+ el.searchEmpty.classList.remove('hidden');
483
+ return;
484
+ }
485
+
486
+ el.searchEmpty.classList.add('hidden');
487
+ el.searchResults.innerHTML = results
488
+ .map((r) => {
489
+ const sessionId = r.id || r.sessionId || r.session_id || '';
490
+ const excerpt = r.excerpt || r.text || r.content || '';
491
+ const role = r.role || '';
492
+ const score = r.score;
493
+ const meta = r.metadata;
494
+ const project = r.project || '';
495
+ const time = relativeTime(r.timestamp || r.date);
496
+ const roleIcon = role === 'user' ? 'person' : role === 'assistant' ? 'smart_toy' : 'chat';
497
+
498
+ return `<div class="result-item" data-session-id="${esc(sessionId)}" tabindex="0" role="button">
499
+ <div class="result-meta">
500
+ <span class="role-badge" data-role="${esc(role)}"><span class="material-symbols-outlined" style="font-size:12px">${roleIcon}</span> ${esc(role)}</span>
501
+ ${project ? `<span class="result-project">${esc(project)}</span>` : ''}
502
+ ${time ? `<span class="result-date">${time}</span>` : ''}
503
+ ${score != null ? `<span class="score-container">${formatScore(score, meta)}</span>` : ''}
504
+ </div>
505
+ <div class="result-excerpt">${highlightExcerpt(excerpt, query)}</div>
506
+ </div>`;
507
+ })
508
+ .join('');
509
+
510
+ el.searchResults.querySelectorAll('.result-item').forEach((card) => {
511
+ card.addEventListener('click', () => openSessionPanel(card.dataset.sessionId));
512
+ card.addEventListener('keydown', (e) => {
513
+ if (e.key === 'Enter') openSessionPanel(card.dataset.sessionId);
514
+ });
515
+ });
516
+ }
517
+
518
+ // ── Sessions ───────────────────────────────────────────────────────────────
519
+
520
+ async function loadSessions() {
521
+ if (state.sessions.loading) return;
522
+ state.sessions.loading = true;
523
+ try {
524
+ const data = await api('/sessions');
525
+ const list = Array.isArray(data) ? data : data.sessions || [];
526
+ state.sessions.list = list;
527
+ state.stats.sessionCount = list.length;
528
+ updateStats();
529
+ populateProjectFilter(list);
530
+ renderSessions();
531
+ } catch (err) {
532
+ toast(`Failed to load sessions: ${err.message}`, 'error');
533
+ }
534
+ state.sessions.loading = false;
535
+ }
536
+
537
+ function populateProjectFilter(sessions) {
538
+ const projects = [...new Set(sessions.map((s) => s.project).filter(Boolean))].sort();
539
+ const sel = el.sessionProjectFilter;
540
+ const current = sel.value;
541
+ sel.innerHTML =
542
+ '<option value="">All projects</option>' +
543
+ projects.map((p) => `<option value="${esc(p)}">${esc(p)}</option>`).join('');
544
+ sel.value = current;
545
+ }
546
+
547
+ function renderSessions() {
548
+ const filter = state.sessions.projectFilter;
549
+ const list = filter
550
+ ? state.sessions.list.filter((s) => s.project === filter)
551
+ : state.sessions.list;
552
+
553
+ if (list.length === 0) {
554
+ el.sessionsList.innerHTML = '';
555
+ el.sessionsEmpty.classList.remove('hidden');
556
+ return;
557
+ }
558
+
559
+ el.sessionsEmpty.classList.add('hidden');
560
+ el.sessionsList.innerHTML = list
561
+ .map((s) => {
562
+ const id = s.sessionId || s.id || '';
563
+ const project = s.project || '';
564
+ const branch = s.branch || s.gitBranch || s.git_branch || '';
565
+ const count = s.messageCount || s.message_count || s.count || 0;
566
+ const date = s.startTime || s.date || s.created || s.startedAt || '';
567
+ const preview = s.preview || '';
568
+ const time = relativeTime(date);
569
+ const dateStr = date
570
+ ? new Date(date).toLocaleDateString(undefined, {
571
+ month: 'short',
572
+ day: 'numeric',
573
+ year: 'numeric',
574
+ })
575
+ : '';
576
+
577
+ return `<div class="session-card" data-session-id="${esc(id)}" tabindex="0" role="button">
578
+ <div class="session-header">
579
+ <span class="session-project">${esc(project)}</span>
580
+ <span class="session-date">${dateStr || time || ''}</span>
581
+ </div>
582
+ <div class="session-meta">
583
+ ${branch ? `<span class="session-meta-item"><span class="material-symbols-outlined">alt_route</span>${esc(branch)}</span>` : ''}
584
+ <span class="session-meta-item"><span class="material-symbols-outlined">chat</span>${count} messages</span>
585
+ <span class="session-meta-item"><span class="material-symbols-outlined">tag</span>${esc(id.slice(0, 8))}</span>
586
+ </div>
587
+ ${preview ? `<div class="session-preview">${esc(preview)}</div>` : ''}
588
+ </div>`;
589
+ })
590
+ .join('');
591
+
592
+ el.sessionsList.querySelectorAll('.session-card').forEach((card) => {
593
+ card.addEventListener('click', () => openSessionPanel(card.dataset.sessionId));
594
+ card.addEventListener('keydown', (e) => {
595
+ if (e.key === 'Enter') openSessionPanel(card.dataset.sessionId);
596
+ });
597
+ });
598
+ }
599
+
600
+ async function openSessionPanel(sessionId) {
601
+ if (!sessionId) return;
602
+ try {
603
+ const [session, summary] = await Promise.allSettled([
604
+ api(`/sessions/${encodeURIComponent(sessionId)}`),
605
+ api(`/sessions/${encodeURIComponent(sessionId)}/summary`),
606
+ ]);
607
+ const sData = session.status === 'fulfilled' ? session.value : {};
608
+ const sumData = summary.status === 'fulfilled' ? summary.value : null;
609
+ openPanel('session', { session: sData, summary: sumData, sessionId });
610
+ } catch (err) {
611
+ toast(`Failed to load session: ${err.message}`, 'error');
612
+ }
613
+ }
614
+
615
+ // ── Embeddings ─────────────────────────────────────────────────────────────
616
+
617
+ async function loadEmbeddingStats() {
618
+ try {
619
+ const data = await api('/index-status');
620
+ state.embeddings.stats = data;
621
+ renderEmbeddingStats();
622
+ el.statVectors.querySelector('.stat-value').textContent = data.totalEntries || 0;
623
+ } catch {
624
+ state.embeddings.stats = null;
625
+ renderEmbeddingStats();
626
+ }
627
+ }
628
+
629
+ function renderEmbeddingStats() {
630
+ const stats = state.embeddings.stats;
631
+ if (!stats || !stats.totalEntries) {
632
+ el.embeddingsStatsGrid.innerHTML = '';
633
+ el.embeddingsStatus.style.display = 'none';
634
+ el.embeddingsEmpty.classList.remove('hidden');
635
+ return;
636
+ }
637
+
638
+ el.embeddingsEmpty.classList.add('hidden');
639
+ el.embeddingsStatus.style.display = '';
640
+
641
+ const cards = [
642
+ { label: 'Provider', value: stats.provider || 'Not configured', detail: '' },
643
+ { label: 'Dimensions', value: stats.dimensions || 0, detail: 'vector size' },
644
+ { label: 'Total Entries', value: stats.totalEntries || 0, detail: 'indexed chunks' },
645
+ { label: 'Knowledge', value: stats.knowledgeEntries || 0, detail: 'knowledge vectors' },
646
+ { label: 'Sessions', value: stats.sessionEntries || 0, detail: 'session vectors' },
647
+ { label: 'DB Size', value: (stats.dbSizeMB || 0).toFixed(1) + ' MB', detail: 'on disk' },
648
+ ];
649
+
650
+ el.embeddingsStatsGrid.innerHTML = cards
651
+ .map(
652
+ (c) => `
653
+ <div class="embedding-stat-card">
654
+ <span class="stat-label">${esc(c.label)}</span>
655
+ <span class="stat-number">${esc(String(c.value))}</span>
656
+ ${c.detail ? `<span class="stat-detail">${esc(c.detail)}</span>` : ''}
657
+ </div>
658
+ `,
659
+ )
660
+ .join('');
661
+ }
662
+
663
+ // ── Side Panel ─────────────────────────────────────────────────────────────
664
+
665
+ function openPanel(type, data) {
666
+ state.panel = { open: true, type, data };
667
+ el.sidePanel.hidden = false;
668
+ requestAnimationFrame(() => el.sidePanel.classList.add('open'));
669
+ el.contentWrapper.classList.add('panel-visible');
670
+
671
+ if (type === 'knowledge') {
672
+ renderKnowledgePanel(data);
673
+ } else if (type === 'session') {
674
+ renderSessionPanel(data);
675
+ }
676
+ }
677
+
678
+ function closePanel() {
679
+ state.panel = { open: false, type: null, data: null };
680
+ el.sidePanel.classList.remove('open');
681
+ el.contentWrapper.classList.remove('panel-visible');
682
+ el.sidePanel.addEventListener(
683
+ 'transitionend',
684
+ function handler() {
685
+ if (!state.panel.open) el.sidePanel.hidden = true;
686
+ el.sidePanel.removeEventListener('transitionend', handler);
687
+ },
688
+ { once: true },
689
+ );
690
+ // Fallback: hide after 400ms if transitionend doesn't fire
691
+ setTimeout(() => {
692
+ if (!state.panel.open) el.sidePanel.hidden = true;
693
+ }, 400);
694
+ }
695
+
696
+ function stripFrontmatter(content) {
697
+ const match = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/);
698
+ return match ? match[1].trim() : content;
699
+ }
700
+
701
+ function renderKnowledgePanel(data) {
702
+ el.panelTitle.innerHTML = `<span class="material-symbols-outlined panel-icon">article</span>${esc(data.title)}`;
703
+ const meta = data.meta || {};
704
+ const entry = meta.entry || meta;
705
+ const category = entry.category || meta.category || '';
706
+ const tags = entry.tags || meta.tags || [];
707
+ const updated = entry.updated || meta.updated || '';
708
+
709
+ let metaHtml = '<div class="panel-meta">';
710
+ if (category)
711
+ metaHtml += `<span class="card-category" data-cat="${esc(category)}">${esc(category)}</span>`;
712
+ if (tags.length)
713
+ metaHtml += tags.map((t) => `<span class="card-tag">${esc(t)}</span>`).join('');
714
+ if (updated) metaHtml += `<span class="panel-meta-date">${esc(updated)}</span>`;
715
+ metaHtml += '</div>';
716
+
717
+ const body = stripFrontmatter(data.content);
718
+ el.panelBody.innerHTML = metaHtml + `<div class="prose">${renderMd(body)}</div>`;
719
+ }
720
+
721
+ function renderSessionPanel(data) {
722
+ const s = data.session || {};
723
+ const summary = data.summary;
724
+ const id = data.sessionId || s.id || '';
725
+ const meta = s.meta || s;
726
+
727
+ el.panelTitle.innerHTML = `<span class="material-symbols-outlined panel-icon">terminal</span>Session ${esc(id.slice(0, 8))}`;
728
+
729
+ let html = '<div class="panel-meta">';
730
+ if (meta.cwd)
731
+ html += `<span class="panel-meta-item"><span class="material-symbols-outlined" style="font-size:14px">folder</span>${esc(meta.cwd)}</span>`;
732
+ if (meta.branch)
733
+ html += `<span class="panel-meta-item"><span class="material-symbols-outlined" style="font-size:14px">alt_route</span>${esc(meta.branch)}</span>`;
734
+ if (meta.startTime && meta.startTime !== 'unknown')
735
+ html += `<span class="panel-meta-item"><span class="material-symbols-outlined" style="font-size:14px">schedule</span>${new Date(meta.startTime).toLocaleString()}</span>`;
736
+ if (meta.messageCount)
737
+ html += `<span class="panel-meta-item"><span class="material-symbols-outlined" style="font-size:14px">chat</span>${meta.messageCount} messages</span>`;
738
+ html += '</div>';
739
+
740
+ if (summary && summary.topics && summary.topics.length) {
741
+ html +=
742
+ '<div class="panel-section"><h4 class="panel-section-title">Topics</h4><ul class="panel-topics">';
743
+ summary.topics.slice(0, 8).forEach((t) => {
744
+ html += `<li>${esc(t.content)}</li>`;
745
+ });
746
+ html += '</ul></div>';
747
+ }
748
+
749
+ const messages = s.messages || s.conversation || [];
750
+ // Filter out noisy messages (tool JSON, base64, system reminders)
751
+ const cleanMessages = messages.filter((m) => {
752
+ const t = (m.content || '').trimStart();
753
+ if (t.startsWith('[{') || t.startsWith('{"')) return false;
754
+ if (t.includes('tool_use_id') || t.includes('tool_result')) return false;
755
+ if (t.includes('base64') || t.includes('media_type')) return false;
756
+ if (t.includes('<system-reminder>')) return false;
757
+ if (t.length < 3) return false;
758
+ return true;
759
+ });
760
+
761
+ if (cleanMessages.length) {
762
+ html += '<div class="chat-messages">';
763
+ cleanMessages.forEach((m) => {
764
+ const role = m.role || 'unknown';
765
+ const text = m.content || m.text || '';
766
+ const isUser = role === 'user';
767
+ const cls = isUser ? 'chat-msg user' : 'chat-msg assistant';
768
+ const roleIcon = isUser ? 'person' : 'smart_toy';
769
+ const truncated = text.length > 1500 ? text.slice(0, 1500) + '...' : text;
770
+ const time = m.timestamp ? relativeTime(m.timestamp) : '';
771
+
772
+ html += `<div class="${cls}">
773
+ <div class="chat-msg-role"><span class="material-symbols-outlined" style="font-size:14px">${roleIcon}</span> ${esc(role)} ${time ? `<span class="chat-msg-time">${time}</span>` : ''}</div>
774
+ <div>${isUser ? esc(truncated) : renderMd(truncated)}</div>
775
+ </div>`;
776
+ });
777
+ html += '</div>';
778
+ } else {
779
+ html +=
780
+ '<div class="empty-state"><span class="material-symbols-outlined empty-icon">chat_bubble</span><div class="empty-text">No messages available</div></div>';
781
+ }
782
+
783
+ el.panelBody.innerHTML = html;
784
+ }
785
+
786
+ // ── Event Binding ──────────────────────────────────────────────────────────
787
+
788
+ function bindEvents() {
789
+ // Tabs
790
+ Object.keys(el.tabs).forEach((k) => {
791
+ el.tabs[k].addEventListener('click', () => switchTab(k));
792
+ });
793
+
794
+ // Theme
795
+ el.themeToggle.addEventListener('click', toggleTheme);
796
+
797
+ // Panel close
798
+ el.panelClose.addEventListener('click', closePanel);
799
+
800
+ // Knowledge category chips
801
+ el.knowledgeCategories.addEventListener('click', (e) => {
802
+ const chip = e.target.closest('.chip');
803
+ if (!chip) return;
804
+ el.knowledgeCategories.querySelectorAll('.chip').forEach((c) => c.classList.remove('active'));
805
+ chip.classList.add('active');
806
+ state.knowledge.activeCategory = chip.dataset.category;
807
+ renderKnowledge();
808
+ });
809
+
810
+ // Search input
811
+ el.searchInput.addEventListener('input', () => {
812
+ state.search.query = el.searchInput.value;
813
+ doSearch();
814
+ });
815
+
816
+ // Search role filters
817
+ el.searchRoleFilters.addEventListener('click', (e) => {
818
+ const chip = e.target.closest('.chip');
819
+ if (!chip) return;
820
+ el.searchRoleFilters.querySelectorAll('.chip').forEach((c) => c.classList.remove('active'));
821
+ chip.classList.add('active');
822
+ state.search.role = chip.dataset.role;
823
+ if (state.search.query.trim()) doSearch();
824
+ });
825
+
826
+ function setSearchMode(mode) {
827
+ el.modeRanked.classList.toggle('active', mode === 'ranked');
828
+ el.modeSemantic.classList.toggle('active', mode === 'semantic');
829
+ el.modeRegex.classList.toggle('active', mode === 'regex');
830
+ state.search.ranked = mode !== 'regex';
831
+ state.search.semantic = mode === 'semantic';
832
+ if (state.search.query.trim()) doSearch();
833
+ }
834
+
835
+ el.modeRanked.addEventListener('click', () => setSearchMode('ranked'));
836
+ el.modeSemantic.addEventListener('click', () => setSearchMode('semantic'));
837
+ el.modeRegex.addEventListener('click', () => setSearchMode('regex'));
838
+
839
+ // Session project filter
840
+ el.sessionProjectFilter.addEventListener('change', () => {
841
+ state.sessions.projectFilter = el.sessionProjectFilter.value;
842
+ renderSessions();
843
+ });
844
+
845
+ // Search scope chips
846
+ el.searchScopes.addEventListener('click', (e) => {
847
+ const chip = e.target.closest('.chip');
848
+ if (!chip) return;
849
+ el.searchScopes.querySelectorAll('.chip').forEach((c) => c.classList.remove('active'));
850
+ chip.classList.add('active');
851
+ state.search.scope = chip.dataset.scope;
852
+ if (state.search.query.trim()) doSearch();
853
+ });
854
+
855
+ // Keyboard shortcuts
856
+ document.addEventListener('keydown', (e) => {
857
+ // Escape closes panel
858
+ if (e.key === 'Escape' && state.panel.open) {
859
+ e.preventDefault();
860
+ closePanel();
861
+ return;
862
+ }
863
+
864
+ // / or Ctrl+K focuses search
865
+ if ((e.key === '/' || (e.ctrlKey && e.key === 'k')) && !isInputFocused()) {
866
+ e.preventDefault();
867
+ switchTab('search');
868
+ el.searchInput.focus();
869
+ }
870
+ });
871
+
872
+ // Click outside panel to close
873
+ el.contentWrapper.addEventListener('click', (e) => {
874
+ if (state.panel.open && !el.sidePanel.contains(e.target)) {
875
+ // Only close if clicking on the main content area backdrop
876
+ }
877
+ });
878
+ }
879
+
880
+ function isInputFocused() {
881
+ const active = document.activeElement;
882
+ if (!active) return false;
883
+ const tag = active.tagName.toLowerCase();
884
+ return tag === 'input' || tag === 'textarea' || tag === 'select' || active.isContentEditable;
885
+ }
886
+
887
+ // ── Init ───────────────────────────────────────────────────────────────────
888
+
889
+ async function init() {
890
+ initTheme();
891
+ bindEvents();
892
+ wsConnect();
893
+
894
+ // Restore tab from URL hash
895
+ const hash = location.hash.replace('#', '');
896
+ const validTabs = Object.keys(el.tabs);
897
+ if (hash && validTabs.includes(hash)) {
898
+ switchTab(hash, false);
899
+ }
900
+
901
+ // Listen for back/forward navigation
902
+ window.addEventListener('hashchange', () => {
903
+ const h = location.hash.replace('#', '');
904
+ if (h && validTabs.includes(h)) switchTab(h, false);
905
+ });
906
+
907
+ // Load initial data in parallel
908
+ try {
909
+ await Promise.allSettled([loadKnowledge(), loadEmbeddingStats()]);
910
+ } catch {
911
+ // individual loaders handle their own errors
912
+ }
913
+
914
+ // Hide loading overlay after a short delay if ws hasn't connected
915
+ setTimeout(() => {
916
+ el.loadingOverlay.classList.add('hidden');
917
+ }, 5000);
918
+ }
919
+
920
+ // Panel resize handle (drag to resize like agent-tasks)
921
+ function initPanelResize() {
922
+ const panel = document.getElementById('side-panel');
923
+ if (!panel) return;
924
+ const handle = document.createElement('div');
925
+ handle.className = 'panel-resize-handle';
926
+ panel.appendChild(handle);
927
+
928
+ let isResizing = false;
929
+ let startX = 0;
930
+ let startWidth = 0;
931
+
932
+ handle.addEventListener('mousedown', (e) => {
933
+ isResizing = true;
934
+ startX = e.clientX;
935
+ startWidth = panel.offsetWidth;
936
+ document.body.style.cursor = 'col-resize';
937
+ document.body.style.userSelect = 'none';
938
+ e.preventDefault();
939
+ });
940
+
941
+ document.addEventListener('mousemove', (e) => {
942
+ if (!isResizing) return;
943
+ const dx = startX - e.clientX;
944
+ const newWidth = Math.max(320, Math.min(startWidth + dx, window.innerWidth * 0.8));
945
+ panel.style.width = newWidth + 'px';
946
+ panel.style.minWidth = newWidth + 'px';
947
+ });
948
+
949
+ document.addEventListener('mouseup', () => {
950
+ if (!isResizing) return;
951
+ isResizing = false;
952
+ document.body.style.cursor = '';
953
+ document.body.style.userSelect = '';
954
+ });
955
+ }
956
+
957
+ // Start
958
+ if (document.readyState === 'loading') {
959
+ document.addEventListener('DOMContentLoaded', () => {
960
+ init();
961
+ initPanelResize();
962
+ });
963
+ } else {
964
+ init();
965
+ initPanelResize();
966
+ }
967
+ })();