agent-knowledge 1.0.4 → 1.0.5

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.
Files changed (3) hide show
  1. package/dist/ui/app.js +953 -953
  2. package/dist/ui/styles.css +1508 -1509
  3. package/package.json +1 -1
package/dist/ui/app.js CHANGED
@@ -1,953 +1,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) {
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) {
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
+ })();