Update frontend/dist/assets/council.js

#4
Files changed (1) hide show
  1. frontend/dist/assets/council.js +291 -377
frontend/dist/assets/council.js CHANGED
@@ -1,363 +1,285 @@
1
- /* ============================================================
2
- ELYSIUM — council.js
3
- Council overlay + Node-detail popover.
4
-
5
- Implements:
6
- Council slide-in panel with per-agent cards
7
- Combined-track playback ( / ⏸)
8
- Per-agent mini play buttons
9
- • Speaking-agent highlight synced to audio time
10
- Node-detail popover (Task 3) — fetches /api/node/:id and
11
- falls back to local node payload if backend not reachable
12
- All DOM access is defensive — no "classList of null" crashes
13
- ============================================================ */
14
- (() => {
15
- 'use strict';
16
-
17
- const AGENT_COLORS = {
18
- THE_BUILDER: '#ff4fa3',
19
- THE_GUARDIAN: '#a76bff',
20
- THE_ORACLE: '#19d6ff',
21
- THE_WEAVER: '#ff80c4',
22
- THE_WILDCARD: '#5cffae',
23
- DYNAMIC: '#ffb840',
24
- };
25
- window.agentColor = (a) => AGENT_COLORS[a] || '#a76bff';
26
-
27
- const $ = (id) => document.getElementById(id);
28
- const safe = (fn) => { try { return fn(); } catch (e) { console.warn(e); } };
29
-
30
- function escapeHtml(s) {
31
- return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
32
- '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
33
- }[c]));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  }
35
 
36
- let _audio = null;
37
- let _agentAudios = [];
38
- let _currentAgents = [];
39
-
40
- /* =========================================================
41
- RENDER COUNCIL (called from boot.js handleResponse)
42
- ========================================================= */
43
- window.renderCouncil = function (resp, runtime) {
44
- const ov = $('council-overlay');
45
- const body = $('co-body');
46
- const synth = $('co-synth');
47
- const count = $('co-count');
48
- const audio = $('debate-audio');
49
- if (!ov || !body) return;
50
- _audio = audio;
51
-
52
- const cd = (resp && resp.council_deliberation) || {};
53
- const agents = Array.isArray(cd.agent_outputs) ? cd.agent_outputs : [];
54
- _currentAgents = agents;
55
- _agentAudios = (runtime && Array.isArray(runtime.per_agent_audio))
56
- ? runtime.per_agent_audio : [];
57
-
58
- // No agents → hide overlay + pill
59
- if (!agents.length) {
60
- ov.classList.remove('show');
61
- setTimeout(() => ov.classList.add('hidden'), 400);
62
- const pill = $('council-pill'); if (pill) pill.classList.add('hidden');
63
- if (synth) synth.textContent = '';
64
- if (audio) { try { audio.pause(); } catch {} audio.removeAttribute('src'); audio.style.display='none'; }
65
- if (count) count.textContent = '+0';
66
- return;
67
- }
68
-
69
- ov.classList.remove('hidden');
70
- requestAnimationFrame(() => ov.classList.add('show'));
71
-
72
- if (count) count.textContent = `+${agents.length}`;
73
- const pillCount = $('pill-count');
74
- if (pillCount) pillCount.textContent = agents.length;
75
-
76
- body.innerHTML = agents.map((a, i) => {
77
- if (!a) return '';
78
- const c = window.agentColor(a.archetype);
79
- const audioInfo = _agentAudios[i] || {};
80
- const hasAudio = !!audioInfo.audio_url;
81
- const archetype = (a.archetype || 'DYNAMIC').replace(/^THE_/, '');
82
- return `
83
- <div class="agent-card" style="border-color:${c}; --card-glow:${c}33; animation-delay:${i * 80}ms"
84
- data-aid="${escapeHtml(a.agent_id)}" data-idx="${i}">
85
- <div class="row1">
86
- <span class="ad-dot" style="background:${c};color:${c}"></span>
87
- <span class="name" style="color:${c}">${escapeHtml(a.agent_name || 'Agent')}</span>
88
- <span class="archetype">${escapeHtml(archetype)}</span>
89
- ${a.veto_triggered ? '<span class="veto">VETO</span>' : ''}
90
- </div>
91
- <div class="thinking">${escapeHtml(a.thinking || '')}</div>
92
- <div class="stance">${escapeHtml(a.tts_speech_text || a.stance || '')}</div>
93
- <div class="footer-row">
94
- <div class="conf">confidence ${(typeof a.confidence === 'number' ? a.confidence : 0.8).toFixed(2)}</div>
95
- ${hasAudio ? `<button class="play-mini" data-aurl="${audioInfo.audio_url}" data-idx="${i}" title="Play this agent's voice">▶</button>` : ''}
96
- </div>
97
- </div>`;
98
- }).join('');
99
-
100
- if (synth) synth.textContent = cd.final_synthesis || '';
101
-
102
- // Wire per-agent mini-play buttons
103
- if (audio) {
104
- body.querySelectorAll('.play-mini').forEach(btn => {
105
- btn.onclick = (ev) => {
106
- ev.stopPropagation();
107
- const url = btn.dataset.aurl;
108
- const idx = +btn.dataset.idx;
109
- if (audio.src && audio.src.endsWith(url) && !audio.paused) {
110
- audio.pause();
111
- btn.classList.remove('playing');
112
- btn.textContent = '▶';
113
- return;
114
- }
115
- body.querySelectorAll('.play-mini.playing').forEach(b => {
116
- b.classList.remove('playing'); b.textContent = '▶';
117
- });
118
- audio.dataset.combined = '0';
119
- audio.src = url;
120
- audio.play().catch(() => {});
121
- btn.classList.add('playing');
122
- btn.textContent = '⏸';
123
- highlightAgent(idx);
124
- audio.onended = () => {
125
- btn.classList.remove('playing'); btn.textContent = '▶';
126
- clearHighlight();
127
- };
128
- };
129
- });
130
-
131
- // Combined audio drama (if backend produced one)
132
- if (runtime && runtime.audio_url) {
133
- audio.style.display = 'block';
134
- audio.src = runtime.audio_url;
135
- audio.dataset.combined = '1';
136
- const playBtn = $('co-play');
137
- const pauseBtn = $('co-pause');
138
- if (playBtn) playBtn.disabled = false;
139
- if (pauseBtn) pauseBtn.disabled = true;
140
- audio.play().then(() => {
141
- if (playBtn) playBtn.disabled = true;
142
- if (pauseBtn) pauseBtn.disabled = false;
143
- }).catch(() => {});
144
-
145
- const cards = body.querySelectorAll('.agent-card');
146
- let idx = 0;
147
- if (cards[0]) cards[0].classList.add('speaking');
148
- audio.ontimeupdate = () => {
149
- if (!audio.duration || audio.dataset.combined !== '1') return;
150
- const target = Math.min(cards.length - 1,
151
- Math.floor((audio.currentTime / audio.duration) * cards.length));
152
- if (target !== idx) {
153
- cards[idx]?.classList.remove('speaking');
154
- cards[target]?.classList.add('speaking');
155
- idx = target;
156
- }
157
- };
158
- audio.onended = () => {
159
- cards.forEach(c => c.classList.remove('speaking'));
160
- if (playBtn) playBtn.disabled = false;
161
- if (pauseBtn) pauseBtn.disabled = true;
162
- };
163
- } else {
164
- audio.style.display = 'none';
165
- audio.removeAttribute('src');
166
- const playBtn = $('co-play');
167
- const pauseBtn = $('co-pause');
168
- if (playBtn) playBtn.disabled = true;
169
- if (pauseBtn) pauseBtn.disabled = true;
170
  }
171
- }
172
- };
173
-
174
- function highlightAgent(idx) {
175
- const cards = document.querySelectorAll('#co-body .agent-card');
176
- cards.forEach(c => c.classList.remove('speaking'));
177
- if (cards[idx]) cards[idx].classList.add('speaking');
178
- }
179
- function clearHighlight() {
180
- document.querySelectorAll('#co-body .agent-card').forEach(c => c.classList.remove('speaking'));
181
- }
182
-
183
- /* =========================================================
184
- OVERLAY CONTROLS (defensive — only wire if button exists)
185
- ========================================================= */
186
- const wire = (id, fn) => { const el = $(id); if (el) el.onclick = fn; };
187
-
188
- wire('co-close', () => {
189
- const ov = $('council-overlay');
190
- if (ov) {
191
- ov.classList.remove('show');
192
- setTimeout(() => ov.classList.add('hidden'), 400);
193
- }
194
- const pill = $('council-pill'); if (pill) pill.classList.add('hidden');
195
- if (_audio) { try { _audio.pause(); _audio.currentTime = 0; } catch {} }
196
- });
197
-
198
- wire('co-min', () => {
199
- const ov = $('council-overlay'); if (ov) ov.classList.add('minimized');
200
- if (_currentAgents.length) { const p = $('council-pill'); if (p) p.classList.remove('hidden'); }
201
- });
202
-
203
- wire('council-pill', () => {
204
- const ov = $('council-overlay');
205
- if (ov) ov.classList.remove('minimized');
206
- const p = $('council-pill'); if (p) p.classList.add('hidden');
207
  });
208
 
209
- wire('co-play', () => {
210
- if (!_audio || !_audio.src) return;
211
- _audio.dataset.combined = '1';
212
- _audio.play().then(() => {
213
- const playBtn = $('co-play');
214
- const pauseBtn = $('co-pause');
215
- if (playBtn) playBtn.disabled = true;
216
- if (pauseBtn) pauseBtn.disabled = false;
 
 
 
 
217
  }).catch(() => {});
218
- });
219
 
220
- wire('co-pause', () => {
221
- if (!_audio) return;
222
- try { _audio.pause(); } catch {}
223
- const playBtn = $('co-play');
224
- const pauseBtn = $('co-pause');
225
- if (playBtn) playBtn.disabled = false;
226
- if (pauseBtn) pauseBtn.disabled = true;
227
- });
228
-
229
- // My-Agent button: toggle overlay (and warn if empty)
230
- wire('my-agent', () => {
231
- const ov = $('council-overlay');
232
- if (!ov) return;
233
- if (ov.classList.contains('hidden') || ov.classList.contains('minimized')) {
234
- if (!_currentAgents.length) {
235
- if (window.toast) window.toast('No agents yet — ask a complex question to summon the council', 'info');
236
- return;
237
  }
238
- ov.classList.remove('hidden', 'minimized');
239
- requestAnimationFrame(() => ov.classList.add('show'));
240
- const p = $('council-pill'); if (p) p.classList.add('hidden');
241
- } else {
242
- ov.classList.remove('show');
243
- setTimeout(() => ov.classList.add('hidden'), 400);
244
- }
245
- });
246
-
247
- /* =========================================================
248
- NODE DETAIL POPOVER (Task 3)
249
- Click any node → fetch enriched detail from /api/node/:id,
250
- fall back to local data if offline.
251
- ========================================================= */
252
- window.showNodeDetail = async function (n, sx, sy) {
253
- const el = $('node-detail');
254
- if (!el || !n) return;
255
-
256
- if (typeof window.elysiumSelect === 'function') {
257
- try { window.elysiumSelect(n.node_id); } catch {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  }
259
-
260
- // Position popover near click but keep on-screen
261
- const W = window.innerWidth, H = window.innerHeight;
262
- const left = Math.min(W - 340, Math.max(10, (sx || W/2) + 14));
263
- const top = Math.min(H - 360, Math.max(10, (sy || H/2) - 40));
264
- el.style.left = left + 'px';
265
- el.style.top = top + 'px';
266
-
267
- // Initial skeleton with locally-known data (instant render)
268
- const localPayload = n.payload && Object.keys(n.payload).length
269
- ? Object.entries(n.payload).slice(0, 6).map(([k, v]) =>
270
- `${escapeHtml(k)}: ${escapeHtml(typeof v === 'object' ? JSON.stringify(v) : String(v))}`
271
- ).join('\n')
272
- : (n.embedding_hint || 'Loading details…');
273
-
274
- el.innerHTML = `
275
- <div class="nd-head">
276
- <div class="nd-title">
277
- <span class="nd-dot" style="background:${n.color};color:${n.color}"></span>
278
- ${escapeHtml(n.label)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  </div>
280
- <button class="nd-close" aria-label="Close">×</button>
281
- </div>
282
- <div class="nd-type">${escapeHtml(n.type)}</div>
283
- <div class="nd-stats">
284
- <div class="nd-pill"><b></b>links</div>
285
- <div class="nd-pill"><b>…</b>in</div>
286
- <div class="nd-pill"><b>…</b>out</div>
287
- </div>
288
- <div class="nd-section">PAYLOAD</div>
289
- <div class="nd-payload">${escapeHtml(localPayload)}</div>
290
- <div class="nd-section">CONNECTIONS</div>
291
- <div class="nd-conns"><div class="nd-conn"><span class="ct">Loading…</span></div></div>
292
- `;
293
- el.classList.remove('hidden');
294
- requestAnimationFrame(() => el.classList.add('show'));
295
-
296
- // Wire close
297
- const closeBtn = el.querySelector('.nd-close');
298
- if (closeBtn) closeBtn.onclick = (ev) => { ev.stopPropagation(); window.hideNodeDetail(); };
299
-
300
- // Compute local connections immediately (works even offline)
301
- const localConns = [];
302
- if (window.ELYSIUM) {
303
- window.ELYSIUM.edges.forEach(e => {
304
- if (e.src === n.node_id) {
305
- const target = window.ELYSIUM.nodes.get(e.dst);
306
- localConns.push({
307
- ct: target ? target.label : e.dst,
308
- type: e.type,
309
- weight: e.weight,
310
- dir: 'out',
311
- });
312
- } else if (e.dst === n.node_id) {
313
- const source = window.ELYSIUM.nodes.get(e.src);
314
- localConns.push({
315
- ct: source ? source.label : e.src,
316
- type: e.type,
317
- weight: e.weight,
318
- dir: 'in',
319
- });
320
- }
321
- });
322
- }
323
-
324
- // Render with local data first
325
- renderDetail(el, {
326
- node_id: n.node_id,
327
- label: n.label,
328
- node_type: n.type,
329
- payload: n.payload || {},
330
- embedding_hint: n.embedding_hint || '',
331
- incoming: localConns.filter(c => c.dir === 'in').map(c => ({
332
- from_label: c.ct, edge_type: c.type, weight: c.weight,
333
- })),
334
- outgoing: localConns.filter(c => c.dir === 'out').map(c => ({
335
- to_label: c.ct, edge_type: c.type, weight: c.weight,
336
- })),
337
- degree: localConns.length,
338
- });
339
-
340
- // Then try to enrich from backend (silent fail)
341
- if (window.ElysiumAPI && window.ElysiumAPI.nodeDetail) {
342
- try {
343
- const d = await window.ElysiumAPI.nodeDetail(n.node_id);
344
- if (d) renderDetail(el, d);
345
- } catch (e) { /* keep local data */ }
346
  }
347
- };
348
-
349
- function renderDetail(el, d) {
350
- if (!el || !d) return;
351
- const n = window.ELYSIUM ? window.ELYSIUM.nodes.get(d.node_id) : null;
352
- const color = (n && n.color) || (window.colorFor ? window.colorFor(d.node_type) : '#a76bff');
353
-
354
- const conns = (Array.isArray(d.incoming) ? d.incoming : [])
355
- .concat(Array.isArray(d.outgoing) ? d.outgoing : []);
356
  const connsHtml = conns.length
357
  ? conns.slice(0, 8).map(c => `
358
  <div class="nd-conn">
359
  <span class="ct">${escapeHtml(c.from_label || c.to_label || c.from || c.to || '?')}</span>
360
- <span class="cw">${(typeof c.weight === 'number' ? c.weight : 0.5).toFixed(2)}</span>
361
  </div>`).join('')
362
  : '<div class="nd-conn"><span class="ct">No connections yet</span></div>';
363
 
@@ -370,14 +292,13 @@
370
  el.innerHTML = `
371
  <div class="nd-head">
372
  <div class="nd-title">
373
- <span class="nd-dot" style="background:${color};color:${color}"></span>
374
- ${escapeHtml(d.label || d.node_id)}
375
- </div>
376
- <button class="nd-close" aria-label="Close">×</button>
377
  </div>
378
- <div class="nd-type">${escapeHtml(d.node_type || '')}</div>
379
  <div class="nd-stats">
380
- <div class="nd-pill"><b>${d.degree ?? conns.length}</b>links</div>
381
  <div class="nd-pill"><b>${(d.incoming || []).length}</b>in</div>
382
  <div class="nd-pill"><b>${(d.outgoing || []).length}</b>out</div>
383
  </div>
@@ -386,30 +307,23 @@
386
  <div class="nd-section">CONNECTIONS</div>
387
  <div class="nd-conns">${connsHtml}</div>
388
  `;
389
- const closeBtn = el.querySelector('.nd-close');
390
- if (closeBtn) closeBtn.onclick = (ev) => { ev.stopPropagation(); window.hideNodeDetail(); };
391
  }
392
-
393
- window.hideNodeDetail = function () {
394
- const el = $('node-detail');
395
- if (!el) return;
396
- el.classList.remove('show');
397
- setTimeout(() => el.classList.add('hidden'), 250);
398
- if (window.ELYSIUM) window.ELYSIUM.nodes.forEach(n => n.selected = false);
399
- };
400
-
401
- // Click outside popover (and outside canvas) → close
402
- document.addEventListener('pointerdown', e => {
403
- const nd = $('node-detail');
404
- if (!nd || nd.classList.contains('hidden')) return;
405
- if (nd.contains(e.target)) return; // click inside popover
406
- if (e.target.id === 'elysium-canvas') return; // canvas handles its own
407
- if (e.target.closest && e.target.closest('#minimap-wrap')) return;
408
- window.hideNodeDetail();
409
- });
410
-
411
- // Escape closes popover
412
- document.addEventListener('keydown', e => {
413
- if (e.key === 'Escape') window.hideNodeDetail();
414
- });
415
- })();
 
1
+ /* Council overlay + node-detail popover.
2
+
3
+ FIXES vs previous build:
4
+ • Defensive null checks around every DOM lookup so a missing element
5
+ can never throw "Cannot read properties of null (reading 'classList')".
6
+ renderCouncil tolerates a partially-populated `resp` / `runtime`.
7
+ Node-detail popover (TASK 3): clicking ANY node opens a rich card
8
+ with type, payload, incoming/outgoing edges, and node degree.
9
+ */
10
+ const AGENT_COLORS = {
11
+ THE_BUILDER: '#ff4fa3',
12
+ THE_GUARDIAN: '#a76bff',
13
+ THE_ORACLE: '#19d6ff',
14
+ THE_WEAVER: '#ff80c4',
15
+ THE_WILDCARD: '#5cffae',
16
+ DYNAMIC: '#ffb840',
17
+ };
18
+ window.agentColor = (a) => AGENT_COLORS[a] || '#a76bff';
19
+
20
+ const $$ = (id) => document.getElementById(id);
21
+ const overlay = () => $$('council-overlay');
22
+ const pill = () => $$('council-pill');
23
+
24
+ let _audio = null;
25
+ let _agentAudios = [];
26
+ let _currentAgents = [];
27
+
28
+ function escapeHtml(s) {
29
+ return String(s || '').replace(/[&<>"']/g, c => ({
30
+ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
31
+ }[c]));
32
+ }
33
+
34
+ window.renderCouncil = function (resp, runtime) {
35
+ const ov = overlay();
36
+ const body = $$('co-body');
37
+ const synth = $$('co-synth');
38
+ const count = $$('co-count');
39
+ const audio = $$('debate-audio');
40
+ if (!ov || !body || !synth || !audio) return; // hard guard
41
+ _audio = audio;
42
+
43
+ const cd = (resp && resp.council_deliberation) || {};
44
+ const agents = cd.agent_outputs || [];
45
+ _currentAgents = agents;
46
+ _agentAudios = (runtime && runtime.per_agent_audio) || [];
47
+
48
+ if (!agents.length) {
49
+ ov.classList.remove('show');
50
+ setTimeout(() => ov.classList.add('hidden'), 400);
51
+ pill()?.classList.add('hidden');
52
+ body.innerHTML = '';
53
+ synth.textContent = '';
54
+ return;
55
  }
56
 
57
+ ov.classList.remove('hidden');
58
+ requestAnimationFrame(() => ov.classList.add('show'));
59
+
60
+ if (count) count.textContent = `+${agents.length}`;
61
+ const pillCount = $$('pill-count');
62
+ if (pillCount) pillCount.textContent = agents.length;
63
+
64
+ body.innerHTML = agents.map((a, i) => {
65
+ const c = window.agentColor(a.archetype);
66
+ const audioInfo = _agentAudios[i] || {};
67
+ const hasAudio = !!audioInfo.audio_url;
68
+ return `
69
+ <div class="agent-card" style="border-color:${c}; --card-glow:${c}33; animation-delay:${i * 80}ms"
70
+ data-aid="${escapeHtml(a.agent_id)}" data-idx="${i}">
71
+ <div class="row1">
72
+ <span class="ad-dot" style="background:${c};color:${c}"></span>
73
+ <span class="name" style="color:${c}">${escapeHtml(a.agent_name || 'Agent')}</span>
74
+ <span class="archetype">${escapeHtml((a.archetype || 'DYNAMIC').replace(/^THE_/, ''))}</span>
75
+ ${a.veto_triggered ? '<span class="veto">VETO</span>' : ''}
76
+ </div>
77
+ <div class="thinking">${escapeHtml(a.thinking || '')}</div>
78
+ <div class="stance">${escapeHtml(a.tts_speech_text || a.stance || '')}</div>
79
+ <div class="footer-row">
80
+ <div class="conf">confidence ${(a.confidence ?? 0.8).toFixed(2)}</div>
81
+ ${hasAudio ? `<button class="play-mini" data-aurl="${audioInfo.audio_url}" data-idx="${i}" title="Play this agent's voice">▶</button>` : ''}
82
+ </div>
83
+ </div>`;
84
+ }).join('');
85
+
86
+ synth.textContent = cd.final_synthesis || '';
87
+
88
+ // Wire per-agent mini-play
89
+ body.querySelectorAll('.play-mini').forEach(btn => {
90
+ btn.onclick = (ev) => {
91
+ ev.stopPropagation();
92
+ const url = btn.dataset.aurl;
93
+ const idx = +btn.dataset.idx;
94
+ if (audio.src.endsWith(url) && !audio.paused) {
95
+ audio.pause();
96
+ btn.classList.remove('playing'); btn.textContent = '▶';
97
+ return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  }
99
+ body.querySelectorAll('.play-mini.playing').forEach(b => {
100
+ b.classList.remove('playing'); b.textContent = '▶';
101
+ });
102
+ audio.src = url;
103
+ audio.play().catch(() => {});
104
+ btn.classList.add('playing'); btn.textContent = '⏸';
105
+ highlightAgent(idx);
106
+ audio.onended = () => {
107
+ btn.classList.remove('playing'); btn.textContent = '▶';
108
+ clearHighlight();
109
+ };
110
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  });
112
 
113
+ // Combined audio drama
114
+ const coPlay = $$('co-play');
115
+ const coPause = $$('co-pause');
116
+ if (runtime && runtime.audio_url) {
117
+ audio.style.display = 'block';
118
+ audio.src = runtime.audio_url;
119
+ audio.dataset.combined = '1';
120
+ if (coPlay) coPlay.disabled = false;
121
+ if (coPause) coPause.disabled = true;
122
+ audio.play().then(() => {
123
+ if (coPlay) coPlay.disabled = true;
124
+ if (coPause) coPause.disabled = false;
125
  }).catch(() => {});
 
126
 
127
+ const cards = body.querySelectorAll('.agent-card');
128
+ let idx = 0;
129
+ if (cards[0]) cards[0].classList.add('speaking');
130
+ audio.ontimeupdate = () => {
131
+ if (!audio.duration || audio.dataset.combined !== '1') return;
132
+ const target = Math.min(cards.length - 1,
133
+ Math.floor((audio.currentTime / audio.duration) * cards.length));
134
+ if (target !== idx) {
135
+ cards[idx]?.classList.remove('speaking');
136
+ cards[target]?.classList.add('speaking');
137
+ idx = target;
 
 
 
 
 
 
138
  }
139
+ };
140
+ audio.onended = () => {
141
+ cards.forEach(c => c.classList.remove('speaking'));
142
+ if (coPlay) coPlay.disabled = false;
143
+ if (coPause) coPause.disabled = true;
144
+ };
145
+ } else {
146
+ audio.style.display = 'none';
147
+ audio.removeAttribute('src');
148
+ if (coPlay) coPlay.disabled = true;
149
+ if (coPause) coPause.disabled = true;
150
+ }
151
+ };
152
+
153
+ function highlightAgent(idx) {
154
+ const cards = document.querySelectorAll('#co-body .agent-card');
155
+ cards.forEach(c => c.classList.remove('speaking'));
156
+ cards[idx]?.classList.add('speaking');
157
+ }
158
+ function clearHighlight() {
159
+ document.querySelectorAll('#co-body .agent-card').forEach(c => c.classList.remove('speaking'));
160
+ }
161
+
162
+ /* ── Controls ── */
163
+ $$('co-close')?.addEventListener('click', () => {
164
+ const ov = overlay();
165
+ ov?.classList.remove('show');
166
+ setTimeout(() => ov?.classList.add('hidden'), 400);
167
+ pill()?.classList.add('hidden');
168
+ if (_audio) { _audio.pause(); _audio.currentTime = 0; }
169
+ });
170
+
171
+ $$('co-min')?.addEventListener('click', () => {
172
+ overlay()?.classList.add('minimized');
173
+ if (_currentAgents.length) pill()?.classList.remove('hidden');
174
+ });
175
+
176
+ $$('council-pill')?.addEventListener('click', () => {
177
+ overlay()?.classList.remove('minimized');
178
+ pill()?.classList.add('hidden');
179
+ });
180
+
181
+ $$('co-play')?.addEventListener('click', () => {
182
+ if (!_audio || !_audio.src) return;
183
+ _audio.dataset.combined = '1';
184
+ _audio.play().then(() => {
185
+ const p1 = $$('co-play'), p2 = $$('co-pause');
186
+ if (p1) p1.disabled = true;
187
+ if (p2) p2.disabled = false;
188
+ }).catch(() => {});
189
+ });
190
+
191
+ $$('co-pause')?.addEventListener('click', () => {
192
+ if (!_audio) return;
193
+ _audio.pause();
194
+ const p1 = $$('co-play'), p2 = $$('co-pause');
195
+ if (p1) p1.disabled = false;
196
+ if (p2) p2.disabled = true;
197
+ });
198
+
199
+ $$('my-agent')?.addEventListener('click', () => {
200
+ const ov = overlay();
201
+ if (!ov) return;
202
+ if (ov.classList.contains('hidden') || ov.classList.contains('minimized')) {
203
+ if (!_currentAgents.length) {
204
+ window.toast?.('No agents yet — ask a complex question to summon the council', 'info');
205
+ return;
206
  }
207
+ ov.classList.remove('hidden', 'minimized');
208
+ requestAnimationFrame(() => ov.classList.add('show'));
209
+ pill()?.classList.add('hidden');
210
+ } else {
211
+ ov.classList.remove('show');
212
+ setTimeout(() => ov.classList.add('hidden'), 400);
213
+ }
214
+ });
215
+
216
+ /* ──────────────────────────────────────────────────────────
217
+ NODE DETAIL POPOVER TASK 3
218
+ Clicking ANY node in the canvas calls window.showNodeDetail()
219
+ ────────────────────────────────────────────────────────── */
220
+ window.showNodeDetail = async function (n, sx, sy) {
221
+ if (!n) return;
222
+ window.elysiumSelect?.(n.node_id);
223
+ const el = $$('node-detail');
224
+ if (!el) return;
225
+
226
+ // Position popover near the click, clamped to viewport
227
+ el.style.left = Math.min(innerWidth - 340, Math.max(10, (sx ?? innerWidth / 2) + 14)) + 'px';
228
+ el.style.top = Math.min(innerHeight - 360, Math.max(10, (sy ?? innerHeight / 2) - 40)) + 'px';
229
+
230
+ // Initial skeleton
231
+ el.innerHTML = `
232
+ <div class="nd-head">
233
+ <div class="nd-title">
234
+ <span class="nd-dot" style="background:${n.color};color:${n.color}"></span>
235
+ ${escapeHtml(n.label)}</div>
236
+ <button class="nd-close" onclick="window.hideNodeDetail()">×</button>
237
+ </div>
238
+ <div class="nd-type">${escapeHtml(n.type || 'NODE')}</div>
239
+ <div class="nd-stats">
240
+ <div class="nd-pill"><b>…</b>links</div>
241
+ <div class="nd-pill"><b>…</b>in</div>
242
+ <div class="nd-pill"><b>…</b>out</div>
243
+ </div>
244
+ <div class="nd-section">DESCRIPTION</div>
245
+ <div class="nd-payload">${escapeHtml(n.embedding_hint || (n.payload?.description) || 'Loading…')}</div>
246
+ `;
247
+ el.classList.add('show');
248
+
249
+ // Fetch enriched details from backend
250
+ try {
251
+ const d = await window.ElysiumAPI.nodeDetail(n.node_id);
252
+ if (!d) {
253
+ // Local-only data (CORE seed, or node not yet persisted)
254
+ const localPayload = n.payload && Object.keys(n.payload).length
255
+ ? Object.entries(n.payload).slice(0, 6).map(([k, v]) =>
256
+ `${escapeHtml(k)}: ${escapeHtml(typeof v === 'object' ? JSON.stringify(v) : String(v))}`
257
+ ).join('\n')
258
+ : (n.embedding_hint || 'A node of your civilization.');
259
+ el.innerHTML = `
260
+ <div class="nd-head">
261
+ <div class="nd-title">
262
+ <span class="nd-dot" style="background:${n.color};color:${n.color}"></span>
263
+ ${escapeHtml(n.label)}</div>
264
+ <button class="nd-close" onclick="window.hideNodeDetail()">×</button>
265
  </div>
266
+ <div class="nd-type">${escapeHtml(n.type || 'NODE')}</div>
267
+ <div class="nd-stats">
268
+ <div class="nd-pill"><b>0</b>links</div>
269
+ <div class="nd-pill"><b>0</b>in</div>
270
+ <div class="nd-pill"><b>0</b>out</div>
271
+ </div>
272
+ <div class="nd-section">DESCRIPTION</div>
273
+ <div class="nd-payload">${escapeHtml(localPayload)}</div>
274
+ `;
275
+ return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  }
277
+ const conns = (d.incoming || []).concat(d.outgoing || []);
 
 
 
 
 
 
 
 
278
  const connsHtml = conns.length
279
  ? conns.slice(0, 8).map(c => `
280
  <div class="nd-conn">
281
  <span class="ct">${escapeHtml(c.from_label || c.to_label || c.from || c.to || '?')}</span>
282
+ <span class="cw">${(c.weight ?? 0.5).toFixed(2)}</span>
283
  </div>`).join('')
284
  : '<div class="nd-conn"><span class="ct">No connections yet</span></div>';
285
 
 
292
  el.innerHTML = `
293
  <div class="nd-head">
294
  <div class="nd-title">
295
+ <span class="nd-dot" style="background:${n.color};color:${n.color}"></span>
296
+ ${escapeHtml(d.label)}</div>
297
+ <button class="nd-close" onclick="window.hideNodeDetail()">×</button>
 
298
  </div>
299
+ <div class="nd-type">${escapeHtml(d.node_type)}</div>
300
  <div class="nd-stats">
301
+ <div class="nd-pill"><b>${d.degree}</b>links</div>
302
  <div class="nd-pill"><b>${(d.incoming || []).length}</b>in</div>
303
  <div class="nd-pill"><b>${(d.outgoing || []).length}</b>out</div>
304
  </div>
 
307
  <div class="nd-section">CONNECTIONS</div>
308
  <div class="nd-conns">${connsHtml}</div>
309
  `;
310
+ } catch (e) {
311
+ console.warn('nodeDetail fetch failed', e);
312
  }
313
+ };
314
+
315
+ window.hideNodeDetail = function () {
316
+ $$('node-detail')?.classList.remove('show');
317
+ window.ELYSIUM?.nodes.forEach(n => n.selected = false);
318
+ };
319
+
320
+ // Close popover when clicking outside (but never via canvas — canvas owns its clicks)
321
+ document.addEventListener('pointerdown', e => {
322
+ const nd = $$('node-detail');
323
+ if (!nd || !nd.classList.contains('show')) return;
324
+ if (nd.contains(e.target)) return;
325
+ if (e.target.id === 'elysium-canvas') return; // canvas handles its own logic
326
+ // Click on canvas via target tree
327
+ if (e.target.closest && e.target.closest('#elysium-canvas')) return;
328
+ window.hideNodeDetail();
329
+ });