claude-memory-layer 1.0.49 → 1.0.50

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.
@@ -30,9 +30,41 @@ async function loadProjects() {
30
30
  option.textContent = `${p.projectName} (${p.dbSizeHuman})`;
31
31
  select.appendChild(option);
32
32
  });
33
+
34
+ const savedProject = window.localStorage?.getItem('cml.dashboard.project') || '';
35
+ if (!state.currentProject && savedProject && state.projects.some(p => p.hash === savedProject)) {
36
+ state.currentProject = savedProject;
37
+ select.value = savedProject;
38
+ }
39
+ updateProjectScopeUI();
33
40
  } catch (error) {
34
41
  console.error('Failed to load projects:', error);
42
+ updateProjectScopeUI();
43
+ }
44
+ }
45
+
46
+ function updateProjectScopeUI() {
47
+ const label = document.getElementById('scope-context-label');
48
+ const detail = document.getElementById('scope-context-detail');
49
+ const disclosureBadge = document.getElementById('disclosure-scope-badge');
50
+ const empty = document.getElementById('global-empty-state');
51
+ const project = state.currentProject ? state.projects.find(p => p.hash === state.currentProject) : null;
52
+ const eventCount = state.stats?.storage?.eventCount || 0;
53
+ const sessionCount = state.stats?.sessions?.total || 0;
54
+ const vectorCount = state.stats?.storage?.vectorCount || 0;
55
+
56
+ if (project) {
57
+ if (label) label.textContent = `Scope: ${project.projectName}`;
58
+ if (detail) detail.textContent = `Project-local memory · ${project.dbSizeHuman || project.hash}`;
59
+ if (disclosureBadge) disclosureBadge.textContent = `Search → Expand → Source · Project-local: ${project.projectName}`;
60
+ if (empty) empty.hidden = true;
61
+ return;
35
62
  }
63
+
64
+ if (label) label.textContent = 'Scope: All projects';
65
+ if (detail) detail.textContent = 'Global aggregate view. Select a project for live project-local sessions and retrieval evidence.';
66
+ if (disclosureBadge) disclosureBadge.textContent = 'Search → Expand → Source · Global scope unless a project is selected';
67
+ if (empty) empty.hidden = !(eventCount === 0 && sessionCount === 0 && vectorCount === 0);
36
68
  }
37
69
 
38
70
  function setupEventListeners() {
@@ -108,6 +140,19 @@ function setupEventListeners() {
108
140
  await loadUserPromptsView();
109
141
  });
110
142
  }
143
+
144
+ const sessionRefresh = document.getElementById('session-refresh');
145
+ if (sessionRefresh) {
146
+ sessionRefresh.addEventListener('click', async () => {
147
+ await loadSessionInspectorView();
148
+ });
149
+ }
150
+ document.querySelectorAll('#session-snapshot-tabs .sort-btn').forEach(btn => {
151
+ btn.addEventListener('click', () => {
152
+ setSessionSnapshotTab(btn.dataset.sessionSnapshotTab || 'overview');
153
+ });
154
+ });
155
+
111
156
  const userPromptPrev = document.getElementById('user-prompt-prev');
112
157
  if (userPromptPrev) {
113
158
  userPromptPrev.addEventListener('click', async () => {
@@ -131,6 +176,8 @@ function setupEventListeners() {
131
176
  if (projectSelect) {
132
177
  projectSelect.addEventListener('change', async (e) => {
133
178
  state.currentProject = e.target.value;
179
+ if (window.localStorage) window.localStorage.setItem('cml.dashboard.project', state.currentProject || '');
180
+ updateProjectScopeUI();
134
181
  await refreshData();
135
182
  if (state.chartInstance) {
136
183
  state.chartInstance.destroy();
@@ -143,9 +190,10 @@ function setupEventListeners() {
143
190
  await initActivityChart();
144
191
  // Reload current view if not overview
145
192
  if (state.currentView !== 'overview') {
146
- switchView(state.currentView);
193
+ await switchView(state.currentView, { forceReload: true });
147
194
  }
148
195
  updateChatProjectScope();
196
+ updateProjectScopeUI();
149
197
  });
150
198
  }
151
199
 
@@ -160,6 +208,20 @@ function setupEventListeners() {
160
208
  vectorHealthRecoverBtn.addEventListener('click', recoverVectorHealth);
161
209
  }
162
210
 
211
+ const playgroundRunBtn = document.getElementById('playground-run-btn');
212
+ if (playgroundRunBtn) {
213
+ playgroundRunBtn.addEventListener('click', runPlaygroundDryRun);
214
+ }
215
+ const playgroundQuery = document.getElementById('playground-query');
216
+ if (playgroundQuery) {
217
+ playgroundQuery.addEventListener('keydown', (e) => {
218
+ if (e.key === 'Enter') {
219
+ e.preventDefault();
220
+ runPlaygroundDryRun();
221
+ }
222
+ });
223
+ }
224
+
163
225
  // Stat cards
164
226
  document.querySelectorAll('.stat-card[data-stat]').forEach(card => {
165
227
  card.addEventListener('click', () => {
@@ -22,6 +22,65 @@ function renderDisclosureStatus(message, isError = false) {
22
22
  status.style.color = isError ? 'var(--error)' : 'var(--text-muted)';
23
23
  }
24
24
 
25
+ function disclosureString(value) {
26
+ if (value === undefined || value === null || value === '') return null;
27
+ return String(value);
28
+ }
29
+
30
+ function normalizeDisclosureEventId(value) {
31
+ const raw = disclosureString(value);
32
+ if (!raw) return null;
33
+ return raw.startsWith('event:') ? raw.slice('event:'.length) : raw;
34
+ }
35
+
36
+ function getDisclosureJumpTarget(...candidates) {
37
+ for (const candidate of candidates) {
38
+ const target = getDisclosureJumpTargetFromCandidate(candidate);
39
+ if (target) return target;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ function getDisclosureJumpTargetFromCandidate(candidate) {
45
+ if (!candidate || typeof candidate !== 'object') return null;
46
+
47
+ const nestedCandidates = [];
48
+ if (candidate.primaryEvent) nestedCandidates.push(candidate.primaryEvent);
49
+ if (candidate.rawEvent) nestedCandidates.push(candidate.rawEvent);
50
+ if (candidate.event) nestedCandidates.push(candidate.event);
51
+ if (Array.isArray(candidate.rawEvents)) nestedCandidates.push(...candidate.rawEvents);
52
+ for (const nested of nestedCandidates) {
53
+ const target = getDisclosureJumpTargetFromCandidate(nested);
54
+ if (target) return target;
55
+ }
56
+
57
+ const metadata = candidate.metadata && typeof candidate.metadata === 'object' ? candidate.metadata : {};
58
+ const sessionId = disclosureString(candidate.sessionId || metadata.sessionId);
59
+ if (!sessionId) return null;
60
+
61
+ const eventId = normalizeDisclosureEventId(
62
+ candidate.eventId ||
63
+ metadata.eventId ||
64
+ candidate.id ||
65
+ candidate.sourceRef ||
66
+ (Array.isArray(candidate.eventIds) ? candidate.eventIds[0] : null)
67
+ );
68
+ return { sessionId, eventId };
69
+ }
70
+
71
+ function disclosureAttrArg(value) {
72
+ if (typeof jsAttrArg === 'function') return jsAttrArg(value);
73
+ return escapeHtml(JSON.stringify(String(value ?? '')));
74
+ }
75
+
76
+ function renderDisclosureJumpButton(target, label = 'Open in Sessions') {
77
+ if (!target?.sessionId) return '';
78
+ return `
79
+ <button type="button" class="inline-action-btn disclosure-session-jump" onclick="event.stopPropagation(); jumpToSession(${disclosureAttrArg(target.sessionId)}, ${disclosureAttrArg(target.eventId || '')})">
80
+ <i class="ri-corner-right-up-line"></i> ${escapeHtml(label)}
81
+ </button>`;
82
+ }
83
+
25
84
  async function handleDisclosureSearch() {
26
85
  const input = document.getElementById('disclosure-search-input');
27
86
  const query = (input?.value || '').trim();
@@ -35,6 +94,7 @@ async function handleDisclosureSearch() {
35
94
  state.isDisclosureLoading = true;
36
95
  state.disclosureResults = [];
37
96
  state.disclosureMeta = null;
97
+ state.disclosureQuery = query;
38
98
  state.disclosureSelectedId = null;
39
99
  state.disclosureExpansion = null;
40
100
  state.disclosureSource = null;
@@ -61,10 +121,14 @@ async function handleDisclosureSearch() {
61
121
 
62
122
  state.disclosureResults = data.results || [];
63
123
  state.disclosureMeta = data.meta || null;
124
+ state.isDisclosureLoading = false;
125
+ if (button) button.disabled = false;
64
126
  renderDisclosureStatus(`Search layer returned ${state.disclosureResults.length} result(s). Click a result to expand/source.`);
65
127
  renderDisclosureResults();
66
128
  } catch (error) {
67
129
  state.disclosureResults = [];
130
+ state.isDisclosureLoading = false;
131
+ if (button) button.disabled = false;
68
132
  renderDisclosureStatus(error.message || 'Disclosure search failed', true);
69
133
  renderDisclosureResults();
70
134
  } finally {
@@ -96,27 +160,39 @@ function renderDisclosureResults() {
96
160
  · fallback=${meta.fallbackApplied ? 'yes' : 'no'}
97
161
  </div>`;
98
162
 
163
+ const query = state.disclosureQuery || document.getElementById('disclosure-search-input')?.value || '';
99
164
  const resultHtml = state.disclosureResults.map((r, idx) => {
100
165
  const active = r.id === state.disclosureSelectedId ? ' active' : '';
101
166
  const score = Number(r.score || 0).toFixed(3);
102
167
  const reasons = (r.reasons || []).map(reason => `<span class="disclosure-chip">${escapeHtml(reason)}</span>`).join('');
103
168
  const provenance = renderDisclosureProvenance(r.metadata, ['sourceProjectHash', 'sourceEntryId', 'topics']);
169
+ const scope = getDisclosureScopeLabel(r);
170
+ const jumpTarget = getDisclosureJumpTarget(r);
171
+ const jumpButton = renderDisclosureJumpButton(jumpTarget);
104
172
  return `
105
- <button class="disclosure-result${active}" data-result-id="${escapeHtml(r.id)}">
173
+ <div class="disclosure-result${active}" data-result-id="${escapeHtml(r.id)}" role="button" tabindex="0">
106
174
  <div class="disclosure-result-head">
107
175
  <span class="event-type-badge">#${idx + 1} ${escapeHtml(r.resultType || 'result')}</span>
176
+ <span class="disclosure-scope-pill">${escapeHtml(scope)}</span>
108
177
  <span class="disclosure-score">score ${score}</span>
109
178
  </div>
110
179
  <div class="disclosure-title">${escapeHtml(r.title || r.sourceRef || r.id)}</div>
111
- <div class="disclosure-snippet">${escapeHtml(r.snippet || '(no snippet)')}</div>
180
+ <div class="disclosure-snippet" title="${escapeHtml(r.snippet || '(no snippet)')}">${highlightDisclosureText(r.snippet || '(no snippet)', query)}</div>
112
181
  ${provenance}
113
- <div class="disclosure-reasons">${reasons || '<span class="disclosure-chip">no_reason</span>'}</div>
114
- </button>`;
182
+ <div class="disclosure-rank-explain"><strong>Why this ranked</strong>${reasons || '<span class="disclosure-chip">no_reason</span>'}</div>
183
+ <div class="disclosure-result-cta"><span>Inspect evidence <i class="ri-arrow-right-line"></i></span>${jumpButton}</div>
184
+ </div>`;
115
185
  }).join('');
116
186
 
117
187
  container.innerHTML = metaHtml + resultHtml;
118
188
  container.querySelectorAll('.disclosure-result').forEach(btn => {
119
- btn.addEventListener('click', () => loadDisclosureDrilldown(btn.dataset.resultId));
189
+ const openResult = () => loadDisclosureDrilldown(btn.dataset.resultId);
190
+ btn.addEventListener('click', openResult);
191
+ btn.addEventListener('keydown', (event) => {
192
+ if (event.key !== 'Enter' && event.key !== ' ') return;
193
+ event.preventDefault();
194
+ openResult();
195
+ });
120
196
  });
121
197
  }
122
198
 
@@ -171,7 +247,7 @@ function renderDisclosureDrilldown(isLoading = false, message = null, isError =
171
247
  const surrounding = (expansion.surroundingFacts || []).map(f => `
172
248
  <div class="disclosure-context-item">
173
249
  <span class="event-type-badge">${escapeHtml(f.resultType || 'fact')}</span>
174
- <span>${escapeHtml(f.snippet || f.title || f.id || '')}</span>
250
+ <span>${escapeHtml(buildSafeDisclosurePreview(f.snippet || f.title || f.id || ''))}</span>
175
251
  </div>`).join('') || '<div class="disclosure-empty compact">No surrounding facts.</div>';
176
252
 
177
253
  const related = (expansion.relatedSources || []).map(s => `
@@ -183,33 +259,105 @@ function renderDisclosureDrilldown(isLoading = false, message = null, isError =
183
259
  const rawEvent = source?.primaryEvent || source?.rawEvents?.[0] || source?.rawEvent || source?.event || null;
184
260
  const isSharedSource = source?.sourceType === 'shared_troubleshooting';
185
261
  const sourcePreview = rawEvent
186
- ? `${rawEvent.eventType || rawEvent.type || 'event'} · ${rawEvent.timestamp || ''}\n${rawEvent.content || rawEvent.preview || ''}`
262
+ ? buildSafeSourcePreview(rawEvent)
187
263
  : isSharedSource
188
264
  ? 'No local raw events for this shared source.'
189
265
  : (source ? JSON.stringify(source, null, 2) : (message || 'Source layer returned no raw event.'));
266
+ const sourceSafetyNote = rawEvent && hasCompactionBoilerplate(rawEvent.content || rawEvent.preview || '')
267
+ ? '<div class="snapshot-note">Context compaction boilerplate hidden from the default evidence preview.</div>'
268
+ : '';
190
269
  const sourceProvenance = source?.metadata
191
270
  ? renderDisclosureProvenance(source.metadata, ['sourceProjectHash', 'sourceEntryId', 'topics', 'rootCause', 'solution', 'symptoms', 'confidence', 'usageCount'])
192
271
  : '';
193
272
  const sourceProvenanceBlock = sourceProvenance
194
273
  ? `<div class="modal-section-title">${isSharedSource ? 'Shared source provenance' : 'Source metadata'}</div>${sourceProvenance}`
195
274
  : '';
275
+ const sourceJumpButton = renderDisclosureJumpButton(
276
+ getDisclosureJumpTarget(source, rawEvent, expansion.target),
277
+ 'Open in Sessions'
278
+ );
196
279
 
197
280
  container.innerHTML = `
281
+ <div class="disclosure-stepper" aria-label="Search → Expand → Source">
282
+ <span class="active">1 Search result</span>
283
+ <span class="active">2 Expanded context</span>
284
+ <span class="active">3 Source evidence</span>
285
+ </div>
198
286
  <div class="disclosure-layer">
199
- <div class="modal-section-title">Expand layer</div>
200
- <div class="modal-content-block">${escapeHtml(expansion.expandedContext || expansion.target?.snippet || '')}</div>
287
+ <div class="modal-section-title">Search result</div>
288
+ <div class="modal-content-block">${escapeHtml(expansion.target?.title || expansion.target?.snippet || state.disclosureSelectedId || '')}</div>
289
+ <div class="modal-section-title">Expanded context</div>
290
+ <div class="modal-content-block">${escapeHtml(buildSafeDisclosurePreview(expansion.expandedContext || expansion.target?.snippet || ''))}</div>
201
291
  <div class="modal-section-title">Surrounding context</div>
202
292
  <div class="disclosure-context-list">${surrounding}</div>
203
293
  <div class="modal-section-title">Related sources</div>
204
294
  <div class="disclosure-reasons">${related}</div>
205
295
  </div>
206
296
  <div class="disclosure-layer">
207
- <div class="modal-section-title">Source layer</div>
297
+ <div class="modal-section-title">Source evidence</div>
298
+ <div class="disclosure-safe-label">Safe preview</div>
208
299
  <div class="modal-content-block">${escapeHtml(sourcePreview)}</div>
300
+ ${sourceJumpButton}
301
+ ${sourceSafetyNote}
302
+ ${rawEvent ? '<button class="sort-btn" type="button" disabled title="Raw transcript and metadata are intentionally hidden in this dashboard panel.">Show raw/meta text</button>' : ''}
209
303
  ${sourceProvenanceBlock}
210
304
  </div>`;
211
305
  }
212
306
 
307
+ function getDisclosureScopeLabel(result) {
308
+ const id = String(result?.id || result?.sourceRef || '');
309
+ if (id.startsWith('shared:') || String(result?.sourceRef || '').startsWith('shared:')) return 'Shared';
310
+ if (result?.metadata?.sourceProjectHash) return 'Project-local';
311
+ return state.currentProject ? 'Project-local' : 'Global';
312
+ }
313
+
314
+ function escapeRegExp(value) {
315
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
316
+ }
317
+
318
+ function highlightDisclosureText(value, query) {
319
+ let html = escapeHtml(String(value || ''));
320
+ const terms = String(query || '')
321
+ .split(/\s+/)
322
+ .map(term => term.trim())
323
+ .filter(term => term.length >= 2)
324
+ .slice(0, 5);
325
+ for (const term of terms) {
326
+ const escapedTerm = escapeRegExp(escapeHtml(term));
327
+ html = html.replace(new RegExp(`(${escapedTerm})`, 'ig'), '<mark>$1</mark>');
328
+ }
329
+ return html;
330
+ }
331
+
332
+ function hasCompactionBoilerplate(value) {
333
+ return /CONTEXT COMPACTION|REFERENCE ONLY/i.test(String(value || ''));
334
+ }
335
+
336
+ function buildSafeDisclosurePreview(value) {
337
+ const text = String(value || '');
338
+ if (!hasCompactionBoilerplate(text)) return text;
339
+ const lines = text.split(/\r?\n/);
340
+ const safeLines = [];
341
+ for (const line of lines) {
342
+ if (hasCompactionBoilerplate(line)) break;
343
+ safeLines.push(line);
344
+ }
345
+ const safeText = safeLines.join('\n').trim();
346
+ const note = '[context compaction handoff hidden from preview]';
347
+ return safeText ? `${safeText}\n${note}` : note;
348
+ }
349
+
350
+ function buildSafeSourcePreview(rawEvent) {
351
+ const rawContent = String(rawEvent.content || rawEvent.preview || '');
352
+ const safeLines = rawContent
353
+ .split(/\r?\n/)
354
+ .filter(line => !hasCompactionBoilerplate(line))
355
+ .slice(0, 8);
356
+ const header = `${rawEvent.eventType || rawEvent.type || 'event'} · ${rawEvent.timestamp || ''}`;
357
+ const body = safeLines.join('\n').slice(0, 1200);
358
+ return `${header}\n${body || '(safe preview empty)'}`;
359
+ }
360
+
213
361
  function renderDisclosureProvenance(metadata, allowedKeys = null) {
214
362
  if (!metadata) return '';
215
363
  const entries = Object.entries(metadata)
@@ -36,7 +36,7 @@ async function openDetailModal(eventId) {
36
36
  const ctx = data.context || [];
37
37
 
38
38
  const eventType = evt.eventType || 'unknown';
39
- const typeClass = `type-${eventType.toLowerCase().replace('_', '-')}`;
39
+ const typeClass = eventTypeBadgeClass(eventType);
40
40
  const time = new Date(evt.timestamp).toLocaleString();
41
41
  const adherenceBadge = renderAdherenceBadge(evt);
42
42
 
@@ -46,8 +46,8 @@ async function openDetailModal(eventId) {
46
46
  <div class="modal-section-title">Context (Surrounding Events)</div>
47
47
  <div class="modal-context-list">
48
48
  ${ctx.map(c => `
49
- <div class="modal-context-item" onclick="openDetailModal('${c.id}')">
50
- <span class="event-type-badge ${`type-${(c.eventType || '').toLowerCase().replace('_', '-')}`}" style="flex-shrink:0;">${c.eventType}</span>
49
+ <div class="modal-context-item" ${c.id ? `onclick="openDetailModal(${jsAttrArg(c.id)})"` : ''}>
50
+ <span class="event-type-badge ${eventTypeBadgeClass(c.eventType)}" style="flex-shrink:0;">${escapeHtml(c.eventType || 'event')}</span>
51
51
  <div style="flex:1; min-width:0;">
52
52
  <div style="font-size:12px; color:var(--text-muted); margin-bottom:4px;">${new Date(c.timestamp).toLocaleString()}</div>
53
53
  <div style="font-size:13px; color:var(--text-secondary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${escapeHtml(c.preview || '')}</div>
@@ -62,7 +62,7 @@ async function openDetailModal(eventId) {
62
62
  <div class="modal-meta">
63
63
  <div class="modal-meta-item">
64
64
  <i class="ri-price-tag-3-line"></i>
65
- <span class="event-type-badge ${typeClass}">${eventType}</span>
65
+ <span class="event-type-badge ${typeClass}">${escapeHtml(eventType)}</span>
66
66
  </div>
67
67
  ${adherenceBadge ? `<div class="modal-meta-item">${adherenceBadge}</div>` : ''}
68
68
  <div class="modal-meta-item">
@@ -71,8 +71,13 @@ async function openDetailModal(eventId) {
71
71
  </div>
72
72
  <div class="modal-meta-item">
73
73
  <i class="ri-chat-1-line"></i>
74
- Session: ${evt.sessionId ? evt.sessionId.slice(0, 12) + '...' : 'N/A'}
74
+ Session: ${evt.sessionId ? escapeHtml(evt.sessionId.slice(0, 12)) + '...' : 'N/A'}
75
75
  </div>
76
+ ${evt.sessionId ? `
77
+ <button type="button" class="inline-action-btn" onclick="jumpToSession(${jsAttrArg(evt.sessionId)}, ${jsAttrArg(evt.id || eventId)})">
78
+ <i class="ri-corner-right-up-line"></i> Open in Sessions
79
+ </button>
80
+ ` : ''}
76
81
  </div>
77
82
  <div class="modal-section-title">Content</div>
78
83
  <div class="modal-content-block">${escapeHtml(evt.content || '(empty)')}</div>
@@ -111,19 +116,28 @@ async function showEventsListModal() {
111
116
  }
112
117
 
113
118
  body.innerHTML = events.map(e => {
114
- const typeClass = `type-${(e.eventType || '').toLowerCase().replace('_', '-')}`;
119
+ const typeClass = eventTypeBadgeClass(e.eventType);
115
120
  const adherenceBadge = renderAdherenceBadge(e);
121
+ const eventArg = jsAttrArg(e.id || '');
122
+ const sessionArg = jsAttrArg(e.sessionId || '');
116
123
  return `
117
- <div class="modal-list-item" onclick="openDetailModal('${e.id}')">
124
+ <div class="modal-list-item" ${e.id ? `onclick="openDetailModal(${eventArg})"` : ''}>
118
125
  <div class="modal-list-info">
119
126
  <div class="title">
120
- <span class="event-type-badge ${typeClass}" style="margin-right:8px;">${e.eventType}</span>
127
+ <span class="event-type-badge ${typeClass}" style="margin-right:8px;">${escapeHtml(e.eventType || 'event')}</span>
121
128
  ${adherenceBadge}
122
129
  ${escapeHtml((e.preview || '').slice(0, 80))}
123
130
  </div>
124
- <div class="subtitle">${new Date(e.timestamp).toLocaleString()} | Session: ${(e.sessionId || '').slice(0, 12)}...</div>
131
+ <div class="subtitle">${new Date(e.timestamp).toLocaleString()} | Session: ${escapeHtml((e.sessionId || '').slice(0, 12))}...</div>
132
+ ${e.sessionId ? `
133
+ <div class="event-actions">
134
+ <button type="button" class="inline-action-btn" onclick="event.stopPropagation(); jumpToSession(${sessionArg}, ${eventArg})">
135
+ <i class="ri-corner-right-up-line"></i> Open in Sessions
136
+ </button>
137
+ </div>
138
+ ` : ''}
125
139
  </div>
126
- ${e.accessCount > 0 ? `<div class="modal-list-badge"><i class="ri-eye-line"></i> ${e.accessCount}</div>` : ''}
140
+ ${e.accessCount > 0 ? `<div class="modal-list-badge"><i class="ri-eye-line"></i> ${formatNumber(e.accessCount)}</div>` : ''}
127
141
  </div>
128
142
  `;
129
143
  }).join('');
@@ -152,12 +166,12 @@ async function showSessionsModal() {
152
166
  const started = new Date(s.startedAt).toLocaleString();
153
167
  const lastEvent = new Date(s.lastEventAt).toLocaleString();
154
168
  return `
155
- <div class="modal-list-item" onclick="showSessionDetailInModal('${s.id}')">
169
+ <div class="modal-list-item" onclick="showSessionDetailInModal(${jsAttrArg(s.id)})">
156
170
  <div class="modal-list-info">
157
- <div class="title"><i class="ri-chat-1-line" style="color:var(--accent-primary); margin-right:6px;"></i>${s.id.slice(0, 20)}...</div>
171
+ <div class="title"><i class="ri-chat-1-line" style="color:var(--accent-primary); margin-right:6px;"></i>${escapeHtml(s.id.slice(0, 20))}...</div>
158
172
  <div class="subtitle">Started: ${started} | Last: ${lastEvent}</div>
159
173
  </div>
160
- <div class="modal-list-badge">${s.eventCount} events</div>
174
+ <div class="modal-list-badge">${formatNumber(s.eventCount)} events</div>
161
175
  </div>
162
176
  `;
163
177
  }).join('');
@@ -172,7 +186,7 @@ async function showSessionDetailInModal(sessionId) {
172
186
  body.innerHTML = '<div style="text-align:center; padding:40px; color:var(--text-muted);">Loading session...</div>';
173
187
 
174
188
  try {
175
- const res = await fetch(apiUrl(`${API_BASE}/sessions/${sessionId}`));
189
+ const res = await fetch(apiUrl(`${API_BASE}/sessions/${encodeURIComponent(sessionId)}`));
176
190
  const data = await res.json();
177
191
  const session = data.session;
178
192
  const events = data.events || [];
@@ -180,7 +194,7 @@ async function showSessionDetailInModal(sessionId) {
180
194
 
181
195
  body.innerHTML = `
182
196
  <div class="modal-meta">
183
- <div class="modal-meta-item"><i class="ri-fingerprint-line"></i>${sessionId.slice(0, 20)}...</div>
197
+ <div class="modal-meta-item"><i class="ri-fingerprint-line"></i>${escapeHtml(sessionId.slice(0, 20))}...</div>
184
198
  <div class="modal-meta-item"><i class="ri-time-line"></i>${new Date(session.startedAt).toLocaleString()}</div>
185
199
  <div class="modal-meta-item"><i class="ri-file-list-3-line"></i>${session.eventCount} events</div>
186
200
  </div>
@@ -197,17 +211,24 @@ async function showSessionDetailInModal(sessionId) {
197
211
  </div>
198
212
  <div class="modal-section-title">Events</div>
199
213
  ${events.map(e => {
200
- const typeClass = `type-${(e.eventType || '').toLowerCase().replace('_', '-')}`;
214
+ const typeClass = eventTypeBadgeClass(e.eventType);
201
215
  const adherenceBadge = renderAdherenceBadge(e);
216
+ const eventArg = jsAttrArg(e.id || '');
217
+ const sessionArg = jsAttrArg(e.sessionId || sessionId);
202
218
  return `
203
- <div class="modal-list-item" onclick="closeAllModals(); openDetailModal('${e.id}')">
219
+ <div class="modal-list-item" ${e.id ? `onclick="closeAllModals(); openDetailModal(${eventArg})"` : ''}>
204
220
  <div class="modal-list-info">
205
221
  <div class="title">
206
- <span class="event-type-badge ${typeClass}" style="margin-right:8px;">${e.eventType}</span>
222
+ <span class="event-type-badge ${typeClass}" style="margin-right:8px;">${escapeHtml(e.eventType || 'event')}</span>
207
223
  ${adherenceBadge}
208
224
  ${escapeHtml((e.preview || '').slice(0, 80))}
209
225
  </div>
210
226
  <div class="subtitle">${new Date(e.timestamp).toLocaleString()}</div>
227
+ <div class="event-actions">
228
+ <button type="button" class="inline-action-btn" onclick="event.stopPropagation(); jumpToSession(${sessionArg}, ${eventArg})">
229
+ <i class="ri-corner-right-up-line"></i> Open in Sessions
230
+ </button>
231
+ </div>
211
232
  </div>
212
233
  </div>
213
234
  `;