PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/research/panel.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 17:00:00 UTC 58,750 bytes, 1,243 lines download raw bytes

1/**
2 * Deep Research side panel — open/close, form, job rendering, library.
3 */
4import * as jobs from './jobs.js';
5import themeModule from '../theme.js';
6import createResearchSynapse from '../researchSynapse.js';
7import spinnerModule from '../spinner.js';
8import { sortModelIds } from '../modelSort.js';
9
10// jobId -> { synapse, status } — survives across _renderJobs() rebuilds so
11// the SVG keeps its accumulated nodes/edges between progress events.
12const _jobSynapses = new Map();
13// Which foldable job sections ('active' / 'past') the user has collapsed — kept
14// across re-renders so the panel doesn't re-expand on every job-state change.
15const _collapsedSections = new Set();
16
17// Persisted preference to minimize (hide) the per-job synapse "tree" visual.
18// Stored globally so it survives the frequent _renderJobs() card rebuilds and
19// applies to every running job.
20const _SYNAPSE_MIN_KEY = 'research.synapseMinimized';
21let _synapseMinimized = (() => { try { return localStorage.getItem(_SYNAPSE_MIN_KEY) === '1'; } catch { return false; } })();
22const _vizCollapseIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>';
23const _vizExpandIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
24function _toggleSynapseMinimized() {
25  _synapseMinimized = !_synapseMinimized;
26  try { localStorage.setItem(_SYNAPSE_MIN_KEY, _synapseMinimized ? '1' : '0'); } catch {}
27  // Apply live to all rendered cards without forcing a full rebuild.
28  document.querySelectorAll('.research-job-synapse-host')
29    .forEach(h => h.classList.toggle('synapse-collapsed', _synapseMinimized));
30  document.querySelectorAll('.research-synapse-toggle').forEach(b => {
31    b.classList.toggle('active', _synapseMinimized);
32    b.title = _synapseMinimized ? 'Show visualization' : 'Minimize visualization';
33    b.innerHTML = _synapseMinimized ? _vizExpandIcon : _vizCollapseIcon;
34  });
35}
36
37let _open = false;
38let _onDocKeydown = null;
39let _apiBase = '';
40let _endpoints = [];
41let _expandedJobId = null;
42let _markdownModule = null;
43let _sessionModule = null;
44let _settingsCollapsed = false;
45const _SETTINGS_KEY = 'odysseus-research-settings';
46const _COLLAPSE_KEY = 'odysseus-research-settings-collapsed';
47
48try { _settingsCollapsed = localStorage.getItem(_COLLAPSE_KEY) === '1'; } catch {}
49
50function _saveSettingsToStorage() {
51  try {
52    const activeCat = document.querySelector('.research-cat.active');
53    localStorage.setItem(_SETTINGS_KEY, JSON.stringify({
54      max_rounds: document.getElementById('research-rounds')?.value || '0',
55      search_provider: document.getElementById('research-search-provider')?.value || '',
56      endpoint_id: document.getElementById('research-endpoint')?.value || '',
57      model: document.getElementById('research-model')?.value || '',
58      category: activeCat?.dataset.cat || '',
59    }));
60  } catch {}
61}
62
63function _loadSettingsFromStorage() {
64  try {
65    const raw = localStorage.getItem(_SETTINGS_KEY);
66    return raw ? JSON.parse(raw) : null;
67  } catch { return null; }
68}
69
70function _showBadge() {
71  const btn = document.getElementById('tool-research-btn');
72  if (!btn || btn.querySelector('.research-badge')) return;
73  const dot = document.createElement('span');
74  dot.className = 'research-badge';
75  btn.appendChild(dot);
76}
77
78function _clearBadge() {
79  const dot = document.querySelector('#tool-research-btn .research-badge');
80  if (dot) dot.remove();
81}
82
83// Live sidebar/rail feedback — mirrors the cookbook pattern. While
84// research jobs are running, the rail button pulses; errors flag red;
85// nothing running clears it. Panel-independent so it works with the
86// modal closed. Called from _renderJobs on every job-state change.
87function _syncResearchRail() {
88  let running = 0, errored = 0, runningJob = null;
89  try {
90    for (const j of jobs.getJobs()) {
91      if (j.status === 'running' || j.status === 'queued') {
92        running++;
93        if (j.status === 'running' && !runningJob) runningJob = j;
94      } else if (j.status === 'error') errored++;
95    }
96  } catch { return; }
97  const railBtn = document.getElementById('rail-research');
98  const toolBtn = document.getElementById('tool-research-btn');
99  const active = running > 0 || errored > 0;
100  // Shared flag so sessions.js:_updateRailNotifs (which lights the same
101  // rail button for INLINE research mode) ORs with us instead of
102  // clobbering — otherwise a session re-render would clear our dot.
103  window._researchJobsActive = active;
104  if (railBtn) {
105    railBtn.classList.remove('rail-notify', 'rail-notify-success', 'rail-notify-error', 'research-notif-active');
106    if (active) {
107      railBtn.classList.add('rail-notify', errored ? 'rail-notify-error' : 'rail-notify-success', 'research-notif-active');
108    }
109  }
110  if (toolBtn) {
111    toolBtn.classList.toggle('research-notif-active', active);
112    toolBtn.style.opacity = active ? '1' : '';
113    // Sidebar feedback while running — a small pulsing dot + round text,
114    // same style as Cookbook's running indicator (no glow).
115    let wrap = toolBtn.querySelector('.research-sb-running');
116    if (running > 0) {
117      if (!wrap) {
118        wrap = document.createElement('span');
119        wrap.className = 'research-sb-running';
120        wrap.innerHTML = '<span class="research-sb-status"></span><span class="research-sb-dot"></span>';
121        toolBtn.appendChild(wrap);
122      }
123      const round = runningJob && runningJob.progress && runningJob.progress.round;
124      // Just the round as "R1", "R2", … (empty until the first roun
124d lands).
125      // Only update when we actually have a round — don't blank it out on
126      // progress ticks that lack one, or it flickers on/off between rounds.
127      if (round) wrap.querySelector('.research-sb-status').textContent = `R${round}`;
128    } else if (wrap) {
129      wrap.remove();
130    }
131  }
132  // Orbiting edge animation: faster when a job is running, slower while idle
133  // (ambient). The rAF loop in _ensureOrbit drives --research-orbit-angle on
134  // the pane element — CSS-only @property animation silently no-op'd in some
135  // browsers, so JS drives it for universal compatibility.
136  _orbitSpeedDegPerSec = running > 0 ? 60 : 22;  // 6s/rev vs ~16s/rev
137  _ensureOrbit();
138  if (window._syncRailDynamic) window._syncRailDynamic();
139}
140
141// ── Orbit-angle rAF driver ─────────────────────────────────────
142// Universally-supported alternative to a CSS @property angle animation.
143// Walks --research-orbit-angle on the #research-pane element every frame
144// while the panel is open. Stops itself when the pane is gone.
145let _orbitRAF = null;
146let _orbitAngle = 0;
147let _orbitLastTs = 0;
148let _orbitSpeedDegPerSec = 22;  // idle ambient default
149function _ensureOrbit() {
150  if (_orbitRAF) return;
151  _orbitLastTs = 0;
152  const tick = (ts) => {
153    const pane = document.getElementById('research-pane');
154    if (!pane) { _orbitRAF = null; return; }  // panel closed → stop loop
155    if (_orbitLastTs) {
156      const dt = (ts - _orbitLastTs) / 1000;
157      _orbitAngle = (_orbitAngle + _orbitSpeedDegPerSec * dt) % 360;
158      pane.style.setProperty('--research-orbit-angle', _orbitAngle.toFixed(2) + 'deg');
159    }
160    _orbitLastTs = ts;
161    _orbitRAF = requestAnimationFrame(tick);
162  };
163  _orbitRAF = requestAnimationFrame(tick);
164}
165
166/** Fetch the count of saved research items and populate the header chip. */
167async function _updateResearchCount() {
168  const el = document.getElementById('research-stats');
169  if (!el) return;
170  try {
171    const res = await fetch('/api/research/library?limit=1', { credentials: 'same-origin' });
172    if (!res.ok) return;
173    const data = await res.json();
174    const n = data.total || 0;
175    el.textContent = n + (n === 1 ? ' research' : ' research');
176  } catch {}
177}
178
179const _searchIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>';
180const _closeIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
181const _playIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21"/></svg>';
182const _cancelIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
183const _trashIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>';
184const _externalIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
185const _copyIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>';
186const _retryIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>';
187const _chevronIcon = '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
188const _editIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
189const _chatIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
190
191export function init(apiBase, markdownMod, sessionMod) {
192  _apiBase = apiBase;
193  _markdownModule = markdownMod;
194  _sessionModule = sessionMod;
195  jobs.init(apiBase);
196  jobs.setRenderCallback(_renderJobs);
197  jobs.onComplete(() => { if (!_open) _showBadge(); });
198}
199
200export function isOpen() { return _open; }
201export function toggle() {
202  if (_open) {
203    // If minimized, restore instead of closing
204    const overlay = document.getElementById('research-overlay');
205    if (overlay && overlay.style.display === 'none') {
206      overlay.style.display = '';
207      const btn = document.getElementById('tool-research-btn');
208      if (btn) btn.classList.remove('minimized');
209      return;
210    }
211    closePanel();
212  } else {
213    openPanel();
214  }
215}
216
217export function openPanel(focusJobId) {
218  if (_open) {
219    const overlay = document.getElementById('research-overlay');
220    if (overlay && overlay.style.display === 'none') {
221      overlay.style.display = '';
222      const btn = document.getElementById('tool-research-btn');
223      if (btn) btn.classList.remove('minimized');
224    }
225    document.body.classList.add('research-panel-view');
226    if (focusJobId) _focusJob(focusJobId);
227    return;
228  }
229  _open = true;
230
231  const container = document.getElementById('chat-container');
232  if (!container) return;
233
234  document.body.classList.add('research-panel-view');
235  const btn = document.getElementById('tool-research-btn');
236  if (btn) btn.classList.add('active');
237
238  const overlay = document.createElement('div');
239  overlay.id = 'research-overlay';
240  overlay.className = 'modal research-overlay';
241
242  // Match doclib/gallery/calendar modal sizing exactly so research feels like
243  // the rest of the modal family (centered, ~640px, 85vh).
244  const pane = document.createElement('div');
245  pane.id = 'research-pane';
246  pane.className = 'modal-content doclib-modal-content research-pane';
247  // Mobile: full-screen so the content has room and the jobs list can scroll
248  // inside it. Desktop: centered ~640px / 85vh modal like the rest.
249  pane.style.cssText = (window.innerWidth <= 768)
250    ? 'width:100vw;max-width:100vw;height:90dvh;max-height:90dvh;border-radius:14px 14px 0 0;background:var(--bg);'
251    : 'width:min(640px, 92vw);max-height:85vh;background:var(--bg);';
252  pane.innerHTML = _buildPanelHTML();
253
254  overlay.appendChild(pane);
255  document.body.appendChild(overlay);
256
257  overlay.addEventListener('click', (e) => {
258    if (e.target === overlay) closePanel();
259  });
260
261  // Document-level ESC handler — overlay-only listener never fired because
262  // overlay isn't focused. Tracked in module scope so closePanel can detach.
263  _onDocKeydown = (e) => {
264    if (e.key === 'Escape' && _open) {
265      e.preventDefault();
266      closePanel();
267    }
268  };
269  document.addEventListener('keydown', _onDocKeydown);
270
271  // Make the pane draggable by its header — same pattern as Library/Calendar.
272  const paneHeader = pane.querySelector('.research-pane-header');
273  if (themeModule && themeModule.makeDraggable && paneHeader) {
274    themeModule.makeDraggable(pane, paneHeader);
275  }
276
277  _wireEvents(pane);
278  _loadEndpoints().then(_restoreSavedSettings);
279  _clearBadge();
280  _updateResearchCount();
281
282  if ('Notification' in window && Notification.permission === 'default') {
283    try { Notification.requestPermission(); } catch {}
284  }
285
286  if (focusJobId) _focusJob(focusJobId);
287}
288
289// Scroll to + highlight a research job card by session id. Used by the
290// chat anchor-link delegate ([Topic](#research-<session_id>)).
291function _focusJob(jobId) {
292  if (!jobId) return;
293  // jobs may still be loading from /api/research/active — retry a few times.
294  let tries = 0;
295  const tryFocus = () => {
296    const card = document.querySelector(`[data-job-id="${jobId}"]`);
297    if (card) {
298      card.scrollIntoView({ behavior: 'smooth', block: 'center' });
299      card.classList.add('research-card-flash');
300      setTimeout(() => card.classList.remove('research-card-flash'), 2000);
301      return;
302    }
303    if (tries++ < 8) setTimeout(tryFocus, 400);
304  };
305  setTimeout(tryFocus, 200);
306}
307
308export function closePanel() {
309  if (!_open) return;
310  _open = false;
311
312  if (_onDocKeydown) {
313    document.removeEventListener('keydown', _onDocKeydown);
314    _onDocKeydown = null;
315  }
316
317  document.body.classList.remove('research-panel-view');
318  const btn = document.getElementById('tool-research-btn');
319  if (btn) btn.classList.remove('active');
320
321  const overlay = document.getElementById('research-overlay');
322  if (overlay) overlay.remove();
323}
324
325function _buildPanelHTML() {
326  const searchProviders = ['', 'searxng', 'duckduckgo', 'tavily', 'brave', 'google', 'serper'];
327  const providerOpts = searchProviders.map(p =>
328    `<option value="${p}">${p || 'Default'}</option>`
329  ).join('');
330
331  let roundOpts = '<option value="0" selected>Auto</option>';
332  for (let i = 1; i <= 20; i++) {
333    roundOpts += `<option value="${i}">${i}</option>`;
334  }
335
336  const settingsHidden = _settingsCollapsed ? ' style="display:none"' : '';
337  const chevronCls = _settingsCollapsed ? ' collapsed' : '';
338
339  return `
340    <div class="modal-header research-pane-header">
341      <h4><span style="position:relative;top:-1px;left:6px;display:inline-flex;vertical-align:middle;">${_searchIcon}</span><span style="margin-left:6px;">Deep Research</span></h4>
342      <div class="research-pane-header-actions">
343        <button id="research-panel-minimize" class="modal-minimize-btn" type="button" title="Minimize"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="5" y1="18" x2="19" y2="18"/></svg></button>
344        <button id="research-panel-close" class="close-btn" title="Close">&#x2716;</button>
345      </div>
346    </div>
347    <div class="modal-body research-pane-body" data-no-swipe-dismiss>
348      <div class="research-new-job">
349        <div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">
350          <h2 style="margin:0;padding:0;line-height:1;">Research <span id="research-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>
351        </div>
352        <p class="memory-desc doclib-desc" style="margin-top:6px;display:flex;align-items:center;gap:6px;">
353          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.8;"><path d="M6 18h8"/><path d="M3 22h18"/><path d="M14 22a7 7 0 1 0 0-14h-1"/><path d="M9 14h2"/><path d="M9 12a2 2 0 0 1-2-2V6h4v4a2 2 0 0 1-2 2Z"/><path d="M12 6V3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3"/></svg>
354          <span>Multi-step web research with an LLM-in-the-loop agent</span>
355        </p>
356        <div id="research-no-past-hint" class="memory-desc doclib-desc" style="display:none;margin-top:-2px;font-size:11px;opacity:0.7;">All past research found in <button type="button" class="research-library-link">Library, Research</button></div>
357        <textarea id="research-query" class="research-query" placeholder="e.g. Trace Odysseus's ten-year journey home from Troy — every island, monster, and detour, and why each one cost him" rows="4"></textarea>
358        <div class="research-category-row" id="research-category-row">
359          <button class="research-cat active" data-cat="" title="LLM auto-detects the best format">Auto</button>
360          <button class="research-cat" data-cat="product">Product</button>
361          <button class="research-cat" data-cat="comparison">Compare</button>
362          <button class="research-cat" data-cat="howto">How-to</button>
363          <button class="research-cat" data-cat="factcheck">Fact-check</button>
364        </div>
365        <button id="research-settings-toggle" class="research-settings-toggle${chevronCls}">
366          Settings<span class="research-settings-chevron">${_chevronIcon}</span>
367        </button>
368        <div id="research-settings-body" class="research-settings-row"${settingsHidden}>
369          <label class="research-setting">
370            <span class="research-setting-label">Rounds</span>
371            <select id="research-rounds">${roundOpts}</select>
372          </label>
373          <label class="research-setting">
374            <span class="research-setting-label">Search engine</span>
375            <select id="research-search-provider">${providerOpts}</select>
376          </label>
377          <label class="research-setting">
378            <span class="research-setting-label">Endpoint</span>
379            <select id="research-endpoint"><option value="">Default</option></select>
380          </label>
381          <label class="research-setting">
382            <span class="research-setting-label">Model</span>
383            <select id="research-model"><option value="">Default</option></select>
384          </label>
385        </div>
386        <div class="research-controls-row">
387          <button id="research-add-btn" class="research-add-btn"><span class="research-add-plus">+</span> Queue</button>
388          <button id="research-start-btn" class="research-start-btn">${_playIcon} Start</button>
389        </div>
390      </div>
391      <div id="research-jobs-list" class="research-jobs-list" data-no-swipe-dismiss></div>
392    </div>
393  `;
394}
395
396/** Fade/slide a card out, then run the removal — matches cookbook's smooth exit. */
397function _animateOutThenRemove(el, removeFn) {
398  if (!el || !el.style) { removeFn(); return; }
399  el.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
400  el.style.opacity = '0';
401  el.style.transform = 'translateX(-10px)';
402  setTimeout(removeFn, 320);
403}
404
405/** Dismiss the mobile keyboard by stealing focus into a throwaway readonly
406 *  input (blur() alone is often ignored on Firefox mobile). */
407function _dismissKeyboard(input) {
408  try {
409    if (input) input.blur();
410    const tmp = document.createElement('input');
411    tmp.setAttribute('readonly', 'readonly');
412    tmp.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;border:0;padding:0;';
413    document.body.appendChild(tmp);
414    tmp.focus();
415    setTimeout(() => { try { tmp.blur(); tmp.remove(); } catch {} }, 60);
416  } catch {}
417}
418
419/** Reset the category selector back to "Auto" (called after each start). */
420function _resetCategoryToAuto() {
421  document.querySelectorAll('.research-cat').forEach(b =>
422    b.classList.toggle('active', (b.dataset.cat || '') === ''));
423}
424
425function _wireEvents(pane) {
426  pane.querySelector('#research-panel-close').addEventListener('click', closePanel);
427  pane.querySelector('#research-panel-minimize')?.addEventListener('click', () => {
428    const overlay = document.getElementById('research-overlay');
429    if (overlay) overlay.style.display = 'none';
430    const btn = document.getElementById('tool-research-btn');
431    if (btn) btn.classList.add('minimized');
432  });
433  pane.querySelector('#research-start-btn').addEventListener('click', _handleStart);
434  pane.querySelector('#research-add-btn').addEventListener('click', _handleAdd);
435
436  pane.querySelectorAll('.research-cat').forEach(btn => {
437    btn.addEventListener('click', () => {
438      pane.querySelectorAll('.research-cat').forEach(b => b.classList.remove('active'));
439      btn.classList.add('active');
440    });
441  });
442
443  pane.querySelector('#research-settings-toggle').addEventListener('click', () => {
444    const body = document.getElementById('research-settings-body');
445    const btn = document.getElementById('research-settings-toggle');
446    if (!body || !btn) return;
447    _settingsCollapsed = !_settingsCollapsed;
448    body.style.display = _settingsCollapsed ? 'none' : '';
449    btn.classList.toggle('collapsed', _settingsCollapsed);
450    try { localStorage.setItem('odysseus-research-settings-collapsed', _settingsCollapsed ? '1' : '0'); } catch {}
451  });
452
453  const queryInput = pane.querySelector('#research-query');
454  queryInput.addEventListener('keydown', (e) => {
455    if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
456      e.preventDefault();
457      _handleStart();
458    }
459  });
460
461  const endpointSelect = pane.querySelector('#research-endpoint');
462  endpointSelect.addEventListener('change', () => _populateModels(endpointSelect.value));
463
464  _renderJobs();
465}
466
467function _readSettings() {
468  const activeCat = document.querySelector('.research-cat.active');
469  const category = activeCat?.dataset.cat || undefined;
470  const settings = {
471    max_rounds: parseInt(document.getElementById('research-rounds')?.value || '0', 10),
472    search_provider: document.getElementById('research-search-provider')?.value || undefined,
473    endpoint_id: document.getElementById('research-endpoint')?.value || undefined,
474    model: document.getElementById('research-model')?.value || undefined,
475    category: category || undefined,
476  };
477  const epSel = document.getElementById('research-endpoint');
478  if (epSel && epSel.value) {
479    const opt = epSel.options[epSel.selectedIndex];
480    settings._endpointName = opt?.textContent || '';
481  }
482  const modelSel = document.getElementById('research-model');
483  if (modelSel && modelSel.value) settings._modelName = modelSel.value;
484  Object.keys(settings).forEach(k => { if (!settings[k]) delete settings[k]; });
485  return settings;
486}
487
488function _handleAdd() {
489  const queryEl = document.getElementById('research-query');
490  const query = (queryEl?.value || '').trim();
491  if (!query) { queryEl?.focus(); return; }
492  _saveSettingsToStorage();
493  jobs.addToQueue(query, _readSettings());
494  queryEl.value = '';
495  queryEl.focus();
496}
497
498// Move a job's data back into the compose form so user can edit and re-queue
499function _editJob(job) {
500  const queryEl = document.getElementById('research-query');
501  if (queryEl) {
502    queryEl.value = job.query || '';
503    queryEl.focus();
504    queryEl.setSelectionRange(queryEl.value.length, queryEl.value.length);
505  }
506  // Restore category
507  const cat = job.category || '';
508  document.querySelectorAll('.research-cat').forEach(b => {
509    b.classList.toggle('active', b.dataset.cat === cat);
510  });
511  // Restore settings
512  const s = job.settings || {};
513  const roundsEl = document.getElementById('research-rounds');
514  if (roundsEl && s.max_rounds) roundsEl.value = s.max_rounds;
515  const spEl = document.getElementById('research-search-provider');
516  if (spEl && s.search_provider) spEl.value = s.search_provider;
517  const epEl = document.getElementById('research-endpoint');
518  if (epEl && s.endpoint_id) epEl.value = s.endpoint_id;
519  const mEl = document.getElementById('research-model');
520  if (mEl && s.model) mEl.value = s.model;
521  // Remove the old job so clicking Start/Queue makes a fresh one
522  jobs.removeJob(job.id);
523  // Scroll the form into view
524  queryEl?.scrollIntoView({ behavior: 'smooth', block: 'center' });
525}
526
527async function _handleStart() {
528  const queryEl = document.getElementById('research-query');
529  const startBtn = document.getElementById('research-start-btn');
530  const query = (queryEl?.value || '').trim();
531
532  // "Start All" mode: more than one job queued → let the user pick parallel
533  // vs sequential before launching. Queue any freshly-typed query first so
534  // it joins the batch, then open the picker anchored to this button.
535  const queuedCount = jobs.getJobs().filter(j => j.status === 'queued').length;
536  if (queuedCount > 1) {
537    if (query) { _saveSettingsToStorage(); jobs.addToQueue(query, _readSettings()); queryEl.value = ''; }
538    _resetCategoryToAuto();
539    if (window.innerWidth <= 768) _dismissKeyboard(queryEl);
540    const total = jobs.getJobs().filter(j => j.status === 'queued').length;
541    _promptParallelOrSequential(total, startBtn);
542    return;
543  }
544
545  // Visual + spinner feedback while the launch request is in flight
546  const _setBusy = (busy) => {
547    if (!startBtn) return;
548    if (busy) {
549      startBtn.disabled = true;
550      startBtn.dataset._origHTML = startBtn.dataset._origHTML || startBtn.innerHTML;
551      startBtn.innerHTML = '';
552      try {
553        const _wp = spinnerModule.createWhirlpool(14);
554        _wp.element.style.cssText += ';vertical-align:middle;margin-right:5px;position:relative;top:-1px;';
555        startBtn.appendChild(_wp.element);
556      } catch {}
557      startBtn.appendChild(document.createTextNode('Starting'));
558      startBtn.classList.add('research-start-busy');
559    } else {
560      startBtn.disabled = false;
561      startBtn.classList.remove('research-start-busy');
562      if (startBtn.dataset._origHTML) {
563        startBtn.innerHTML = startBtn.dataset._origHTML;
564      }
565    }
566  };
567
568  // Show busy briefly for click feedback. Don't await the full launch —
569  // the per-job card immediately shows "Starting..." progress, and the
570  // backend POST can take a while.
571  _setBusy(true);
572  setTimeout(() => _setBusy(false), 1500);
573
574  const _mobile = window.innerWidth <= 768;
575  if (!query) {
576    jobs.startAllQueued();
577    _resetCategoryToAuto();
578    if (_mobile) _dismissKeyboard(queryEl);
579    return;
580  }
581  _saveSettingsToStorage();
582  const settings = _readSettings();
583  queryEl.value = '';
584  // Mobile: drop the keyboard after sending; desktop: keep focus for fast follow-ups.
585  if (_mobile) _dismissKeyboard(queryEl); else queryEl.focus();
586  _resetCategoryToAuto();
587  jobs.startJob(query, settings).catch((e) => {
588    if (typeof uiModule !== 'undefined' && uiModule?.showError) uiModule.showError('Failed to start research');
589    queryEl.value = query; // restore so user can retry
590  });
591}
592
593function _restoreSavedSettings() {
594  const saved = _loadSettingsFromStorage();
595  if (!saved) return;
596  if (saved.category !== undefined) {
597    document.querySelectorAll('.research-cat').forEach(b => {
598      b.classList.toggle('active', b.dataset.cat === saved.category);
599    });
600  }
601  // Rounds intentionally defaults to "Auto" on every open — don't restore.
602  // Users can pick a specific cap each time if needed.
603  const search = document.getElementById('research-search-provider');
604  if (search && saved.search_provider !== undefined) search.value = saved.search_provider;
605  const ep = document.getElementById('research-endpoint');
606  if (ep && saved.endpoint_id) {
607    ep.value = saved.endpoint_id;
608    _populateModels(saved.endpoint_id);
609    if (saved.model) {
610      setTimeout(() => {
611        const model = document.getElementById('research-model');
612        if (model) model.value = saved.model;
613      }, 50);
614    }
615  }
616}
617
618async function _loadEndpoints() {
619  try {
620    const res = await fetch(`${_apiBase}/api/model-endpoints`, { credentials: 'same-origin' });
621    if (!res.ok) return;
622    _endpoints = await res.json();
623    const sel = document.getElementById('research-endpoint');
624    if (!sel) return;
625    _endpoints.filter(e => e.is_enabled && e.model_type === 'llm').forEach(ep => {
626      const opt = document.createElement('option');
627      opt.value = ep.id;
628      opt.textContent = ep.name || ep.base_url;
629      sel.appendChild(opt);
630    });
631  } catch {}
632}
633
634function _populateModels(endpointId) {
635  const sel = document.getElementById('research-model');
636  if (!sel) return;
637  sel.innerHTML = '<option value="">Default</option>';
638  if (!endpointId) return;
639  const ep = _endpoints.find(e => e.id === endpointId);
640  if (!ep || !ep.models) return;
641  sortModelIds(ep.models).forEach(m => {
642    const opt = document.createElement('option');
643    opt.value = m;
644    opt.textContent = m;
645    sel.appendChild(opt);
646  });
647}
648
649// ── Job rendering ──
650
651function _renderJobs() {
652  // Keep the rail/sidebar indicator in sync on every job-state change,
653  // even when the panel is closed (no container yet).
654  _syncResearchRail();
655  const container = document.getElementById('research-jobs-list');
656  if (!container) return;
657
658  const allJobs = jobs.getJobs();
659  if (!allJobs.length) {
660    // No empty-state text in the body — the query box above is the call to
661    // action. But still surface the "All past research found in Library,
662    // Research" hint under the main title, since the Past section won't
663    // render to host it (this is exactly the case the dynamic hint targets).
664    container.innerHTML = '';
665    const noPastHint = document.getElementById('research-no-past-hint');
666    if (noPastHint) {
667      noPastHint.style.display = '';
668      if (!noPastHint.dataset._wired) {
669        noPastHint.dataset._wired = '1';
670        noPastHint.querySelector('.research-library-link')?.addEventListener('click', (e) => {
671          e.stopPropagation();
672          closePanel();
673          if (window.documentModule && window.documentModule.openLibrary) {
674            window.documentModule.openLibrary({ tab: 'research' });
675          }
676        });
677      }
678    }
679    return;
680  }
681
682  container.innerHTML = '';
683
684  const active = allJobs.filter(j => j.status === 'queued' || j.status === 'running' || j.status === 'error' || j.status === 'cancelled');
685  const past = allJobs.filter(j => j.status === 'done' && j._fromLibrary);
686  const recentDone = allJobs.filter(j => j.status === 'done' && !j._fromLibrary).reverse();
687
688  // Keep the header "(N research)" chip in sync with the Past-section count.
689  // _updateResearchCount fetches the library total only, which under-counts
690  // when there's a session-completed job not yet persisted to the library.
691  const statsEl = document.getElementById('research-stats');
692  if (statsEl) {
693    const n = recentDone.length + past.length;
694    statsEl.textContent = n + ' research';
695  }
696
697  // The main Start button doubles as "Start All (N)" when more than one job
698  // is queued — clicking it then opens the parallel/sequential picker. No
699  // separate queue-bar button (that was the redundant second button).
700  const queued = active.filter(j => j.status === 'queued');
701  const startBtn = document.getElementById('research-start-btn');
702  if (startBtn && !startBtn.classList.contains('research-start-busy')) {
703    startBtn.innerHTML = queued.length > 1
704      ? `${_playIcon} Start All (${queued.length})`
705      : `${_playIcon} Start`;
706    startBtn.dataset._origHTML = startBtn.innerHTML;
707  }
708
709  // Dynamic Past hint: when the Past section won't render (no past items),
710  // surface the "All past research found in Library, Research" line under
711  // the main Research title instead, so the link is always discoverable.
712  const noPastHint = document.getElementById('research-no-past-hint');
713  if (noPastHint) {
714    const hasPast = past.length + recentDone.length > 0;
715    noPastHint.style.display = hasPast ? 'none' : '';
716    if (!hasPast && !noPastHint.dataset._wired) {
717      noPastHint.dataset._wired = '1';
718      noPastHint.querySelector('.research-library-link')?.addEventListener('click', (e) => {
719        e.stopPropagation();
720        closePanel();
721        if (window.documentModule && window.documentModule.openLibrary) {
722          window.documentModule.openLibrary({ tab: 'research' });
723        }
724      });
725    }
726  }
727
728  // Clean up synapses for jobs that finished or disappeared. complete()
729  // marks the SVG green for ~800ms before destroy removes it.
730  const liveIds = new Set(allJobs.filter(j => j.status === 'running').map(j => j.id));
731  for (const [jobId, entry] of _jobSynapses) {
732    if (liveIds.has(jobId)) continue;
733    try { entry.synapse.complete(); } catch {}
734    setTimeout(() => { try { entry.synapse.destroy(); } catch {} }, 800);
735    _jobSynapses.delete(jobId);
736  }
737
738  // Group into foldable sections: "Active" (in-progress) and "Past research"
739  // (everything done — this session + library). Each has a clickable title
740  // that collapses its body. Collapsed state persists across re-renders via
741  // the module-level _collapsedSections set.
742  const _addSection = (key, title, arr) => {
743    if (!arr.length) return;
744    const collapsed = _collapsedSections.has(key);
745    const sec = document.createElement('div');
746    sec.className = 'research-section' + (collapsed ? ' collapsed' : '');
747    const header = document.createElement('div');
748    header.className = 'research-section-header';
749    // Status dot on the right (visible even when folded):
750    //  • Active = pulsing accent glow (work in progress)
751    //  • any failed/cancelled job in Active = solid red
752    //  • Past (done) = solid green (success)
753    let dotColor, dotPulse = false;
754    if (key === 'active') {
755      const failed = arr.some(j => j.status === 'error' || j.status === 'cancelled');
756      if (failed) { dotColor = '#f44336'; }
757      else { dotColor = 'var(--accent, var(--red))'; dotPulse = true; }
758    } else {
759      dotColor = 'var(--color-success)';
760    }
761    // Both sections carry a "Clear all" button in the header (cookbook-running
762    // section style); it clears all research and must not toggle the fold.
763    const clearAllHtml = '<button class="research-section-clear" title="Clear all research">' + _cancelIcon + ' Clear all</button>';
764    header.innerHTML =
765      '<span class="research-section-title">' + title + '</span>'
766      + '<span class="research-section-count memory-count">' + arr.length + ' research</span>'
767      + '<span class="research-section-right">'
768      +   clearAllHtml
769      +   '<span class="research-section-dot' + (dotPulse ? ' pulsing' : '') + '" style="background:' + dotColor + ';"></span>'
770      +   '<svg class="research-section-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg>'
771      + '</span>';
772    header.addEventListener('click', () => {
773      const nowCollapsed = sec.classList.toggle('collapsed');
774      if (nowCollapsed) _collapsedSections.add(key); else _collapsedSections.delete(key);
775    });
776    header.querySelector('.research-section-clear')?.addEventListener('click', (e) => {
777      e.stopPropagation();
778      // Gracefully fade + collapse the whole section block(s) out, then clear.
779      container.querySelectorAll('.research-section').forEach(s => {
780        s.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
781        s.style.opacity = '0';
782        s.style.transform = 'translateX(-10px)';
783      });
784      setTimeout(() => jobs.clearAll(), 320);
785    });
786    const body = document.createElement('div');
787    body.className = 'research-section-body';
788    // Hint inside the "Past research" header (second line, styled like the main
789    // Research description) — past research is kept in the Library's Research tab.
790    if (key === 'past') {
791      const hint = document.createElement('div');
792      hint.className = 'memory-desc doclib-desc research-library-hint';
793      hint.innerHTML = 'All past research found in <button type="button" class="research-library-link">Library, Research</button>';
794      hint.querySelector('.research-library-link').addEventListener('click', (e) => {
795        e.stopPropagation();
796        // Close the research panel first so the Library opens ABOVE it on mobile
797        // (otherwise it stacks under the full-screen panel).
798        closePanel();
799        if (window.documentModule && window.documentModule.openLibrary) {
800          window.documentModule.openLibrary({ tab: 'research' });
801        }
802      });
803      header.appendChild(hint);
804    }
805    arr.forEach(j => body.appendChild(_buildJobCard(j)));
806    sec.appendChild(header);
807    sec.appendChild(body);
808    container.appendChild(sec);
809  };
810
811  // ("Clear all" lives inside the Past research section header — see _addSection.)
812
813  _addSection('active', 'Active', active);
814  _addSection('past', 'Past research', recentDone.concat(past));
815}
816
817/** Pick parallel vs sequential as a small popover anchored to the
818 *  Start-All button. Drops down by default; flips to drop-up if there
819 *  isn't enough room below the button. Outside-click / Esc dismiss. */
820function _promptParallelOrSequential(count, anchorBtn) {
821  // Strip any prior instance so a second click closes-then-reopens cleanly.
822  const existing = document.getElementById('research-run-mode-popover');
823  if (existing) { existing.remove(); return; }
824  if (!anchorBtn) return;
825
826  const rect = anchorBtn.getBoundingClientRect();
827  const pop = document.createElement('div');
828  pop.id = 'research-run-mode-popover';
829  pop.className = 'research-run-mode-popover';
830  // Same parallel / sequential glyphs the model-comparison picker uses.
831  const ICON_PARALLEL = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>';
832  const ICON_SEQUENTIAL = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="8" y1="6" x2="20" y2="6"/><line x1="8" y1="12" x2="20" y2="12"/><line x1="8" y1="18" x2="20" y2="18"/><circle cx="4" cy="6" r="1.5" fill="currentColor"/><circle cx="4" cy="12" r="1.5" fill="currentColor"/><circle cx="4" cy="18" r="1.5" fill="currentColor"/></svg>';
833  pop.innerHTML =
834    '<button class="research-run-mode-row" data-mode="parallel">' + ICON_PARALLEL + '<span class="rrm-title">Parallel</span></button>'
835    + '<button class="research-run-mode-row" data-mode="sequential">' + ICON_SEQUENTIAL + '<span class="rrm-title">Sequential</span></button>';
836  document.body.appendChild(pop);
837
838  // Position: prefer dropping down from the button's bottom-right corner.
839  // If there isn't enough room below the viewport, flip to drop-up above.
840  const popHeight = pop.offsetHeight;
841  const margin = 6;
842  const spaceBelow = window.innerHeight - rect.bottom;
843  const goUp = spaceBelow < popHeight + margin && rect.top > popHeight + margin;
844  const top = goUp ? (rect.top - popHeight - margin) : (rect.bottom + margin);
845  // Right-align to the button so the menu doesn't extend off-screen on the right
846  const right = Math.max(8, window.innerWidth - rect.right);
847  pop.style.top = `${Math.round(top)}px`;
848  pop.style.right = `${Math.round(right)}px`;
849  pop.classList.add(goUp ? 'rrm-up' : 'rrm-down');
850
851  const close = () => {
852    pop.remove();
853    document.removeEventListener('click', onDocClick, true);
854    document.removeEventListener('keydown', onKey, true);
855  };
856  const onDocClick = (e) => {
857    if (pop.contains(e.target) || e.target === anchorBtn) return;
858    close();
859  };
860  const onKey = (e) => {
861    if (e.key === 'Escape') { e.preventDefault(); close(); }
862  };
863  setTimeout(() => {
864    document.addEventListener('click', onDocClick, true);
865    document.addEventListener('keydown', onKey, true);
866  }, 0);
867
868  pop.querySelectorAll('.research-run-mode-row').forEach(b => {
869    b.addEventListener('click', () => {
870      const mode = b.dataset.mode;
871      close();
872      if (mode === 'parallel') jobs.startAllQueued();
873      else jobs.startAllQueuedSequential();
874    });
875  });
876}
877
878function _buildJobCard(job) {
879  const card = document.createElement('div');
880  card.className = `research-job-card ${job.status}${job._fromLibrary ? ' from-library' : ''}`;
881  card.dataset.jobId = job.id;
882  if (job.category) card.dataset.category = job.category;
883
884  const elapsed = jobs.formatElapsed(job.elapsed || 0);
885  const isExpanded = _expandedJobId === job.id;
886  const modelTag = (job.modelName || job.settings?._modelName)
887    ? `<span class="research-job-model">${_esc(job.modelName || job.settings._modelName)}</span>` : '';
888
889  if (job.status === 'queued') {
890    const rounds = job.settings?.max_rounds;
891    const roundsLabel = !rounds ? 'Auto rounds' : `${rounds} rounds`;
892    const epName = job.settings?._endpointName || '';
893    const mName = job.settings?._modelName || '';
894    const meta = [mName, epName, roundsLabel].filter(Boolean).join(' -- ');
895    card.innerHTML = `
896      <div class="research-job-header">
897        <span class="research-job-query">${_esc(job.query)}</span>${job.category ? `<span class="research-cat-badge">${_esc(job.category)}</span>` : ""}
898      </div>
899      <div class="research-job-queued-meta">${_esc(meta)}</div>
900      <div class="research-job-actions">
901        <button class="research-job-action" data-action="start" title="Start">${_playIcon} Start</button>
902        <button class="research-job-action" data-action="edit" title="Edit query">${_editIcon} Edit</button>
903        <button class="research-job-action research-job-action-dim" data-action="remove" title="Remove">${_cancelIcon}</button>
904      </div>
905    `;
906    card.querySelector('[data-action="start"]').addEventListener('click', (e) => {
907      e.stopPropagation(); jobs.startQueued(job.id);
908    });
909    card.querySelector('[data-action="edit"]').addEventListener('click', (e) => {
910      e.stopPropagation(); _editJob(job);
911    });
912    card.querySelector('[data-action="remove"]').addEventListener('click', (e) => {
913      e.stopPropagation(); jobs.removeJob(job.id);
914    });
915
916  } else if (job.status === 'running') {
917    // Auto mode (max_rounds=0/undefined) — show round number without total,
918    // and base the progress bar on a heuristic cap of 8 rounds.
919    const userMaxR = job.settings?.max_rounds || 0;
920    const phaseMaxR = userMaxR || 0;  // 0 = formatPhase shows "Round X" without total
921    const phase = jobs.formatPhase(job.progress, phaseMaxR);
922    const round = job.progress?.round || 0;
923    const barCap = userMaxR || 8;
924    const pct = Math.min(100, Math.round((round / barCap) * 100));
925    card.innerHTML = `
926      <div class="research-job-header">
927        <span class="research-job-query">${_esc(job.query)}</span>
927${job.category ? `<span class="research-cat-badge">${_esc(job.category)}</span>` : ""}
928        ${modelTag}
929        <span class="research-job-time">${elapsed}</span>
930        <button class="research-synapse-toggle${_synapseMinimized ? ' active' : ''}" title="${_synapseMinimized ? 'Show visualization' : 'Minimize visualization'}">${_synapseMinimized ? _vizExpandIcon : _vizCollapseIcon}</button>
931        <button class="research-job-cancel" title="Cancel research">${_cancelIcon}</button>
932      </div>
933      <div class="research-job-phase">${phase}</div>
934      <div class="research-job-synapse-host${_synapseMinimized ? ' synapse-collapsed' : ''}" data-synapse-host="${job.id}"></div>
935      <div class="research-progress-bar"><div class="research-progress-fill" style="width:${pct}%"></div></div>
936    `;
937    card.querySelector('.research-job-cancel').addEventListener('click', (e) => {
938      e.stopPropagation(); jobs.cancelJob(job.id);
939    });
940    card.querySelector('.research-synapse-toggle')?.addEventListener('click', (e) => {
941      e.stopPropagation(); _toggleSynapseMinimized();
942    });
943    // Click anywhere on the header (title/model/time) toggles the visualization
944    // too — the cancel/synapse buttons stopPropagation so they keep their own.
945    const _runHdr = card.querySelector('.research-job-header');
946    if (_runHdr) {
947      _runHdr.style.cursor = 'pointer';
948      _runHdr.addEventListener('click', () => _toggleSynapseMinimized());
949    }
950    // Attach (or re-attach) the live synapse visualization. Created once per
951    // job so animations/state persist across the _renderJobs() rebuilds that
952    // fire on every progress event.
953    const host = card.querySelector('.research-job-synapse-host');
954    let entry = _jobSynapses.get(job.id);
955    if (!entry) {
956      const synapse = createResearchSynapse(host, {
957        query: job.query || '',
958        startedAt: job.startedAt || (Date.now() - (job.elapsed || 0) * 1000),
959        compact: true,
960      });
961      entry = { synapse, status: 'running' };
962      _jobSynapses.set(job.id, entry);
963    } else {
964      // Move the existing element into the freshly-rendered host
965      host.appendChild(entry.synapse.element);
966    }
967    // Push the current progress state
968    if (job.progress) {
969      entry.synapse.setPhase(job.progress.phase, job.progress);
970      if (typeof job.progress.round === 'number') entry.synapse.setRound(job.progress.round);
971      if (typeof job.progress.total_sources === 'number') entry.synapse.setSourceCount(job.progress.total_sources);
972    }
973
974  } else if (job.status === 'done') {
975    // Library-loaded jobs have sources=null but pre-set sourceCount; fresh jobs
976    // populate sources directly. Prefer the pre-set count if present.
977    const srcCount = job.sources?.length ?? job.sourceCount ?? 0;
978    // 0 sources = the research couldn't gather/extract anything — flag it.
979    const failed = srcCount === 0;
980    if (failed) card.classList.add('research-job-failed');
981    const doneBadge = failed
982      ? `<span class="research-cat-badge research-cat-failed">${_cancelIcon} no results</span>`
983      : (job.category ? `<span class="research-cat-badge">${_esc(job.category)}</span>` : `<span class="research-cat-badge research-cat-standard">standard</span>`);
984    const failNote = failed
985      ? `<div class="research-job-failnote">Couldn't extract anything — try rephrasing the question, or switch the search engine in Settings.</div>`
986      : '';
987    card.innerHTML = `
988      <div class="research-job-header">
989        <span class="research-job-query">${_esc(job.query)}</span>${doneBadge}
990        ${modelTag}
991        <span class="research-job-meta">${elapsed} -- ${srcCount} sources</span>
992      </div>
993      ${failNote}
994      <div class="research-job-actions">
995        <button class="research-job-action" data-action="copy" title="Copy report to clipboard">${_copyIcon}</button>
996        <button class="research-job-action" data-action="chat" title="Open follow-up chat with this research as context">${_chatIcon} Discuss</button>
997        <button class="research-job-action research-job-action-report" data-action="report" title="Visual report">${_externalIcon} Visual Report</button>
998        <button class="research-job-action research-job-action-dim" data-action="dismiss" title="Clear from list">${_cancelIcon}</button>
999        <button class="research-job-action research-job-action-dim" data-action="delete" title="Delete from disk">${_trashIcon} Delete</button>
1000      </div>
1001      ${isExpanded ? `<div class="research-job-result">${_renderResult(job)}</div>` : ''}
1002    `;
1003    // Clicking anywhere on the card (except the action buttons, which
1004    // stopPropagation) opens the visual report — same as the Visual Report btn.
1005    card.style.cursor = 'pointer';
1006    card.addEventListener('click', () => {
1007      window.open(`${_apiBase}/api/research/report/${job.id}`, '_blank');
1008    });
1009    card.querySelector('[data-action="copy"]').addEventListener('click', async (e) => {
1010      e.stopPropagation();
1011      const btn = e.currentTarget; // capture before await — currentTarget becomes null after
1012      if (!job.result) await _ensureResult(job);
1013      _copyResult(job, btn);
1014    });
1015    card.querySelector('[data-action="report"]').addEventListener('click', (e) => {
1016      e.stopPropagation();
1017      window.open(`${_apiBase}/api/research/report/${job.id}`, '_blank');
1018    });
1019    card.querySelector('[data-action="chat"]').addEventListener('click', (e) => {
1020      e.stopPropagation();
1021      _chatAboutResearch(job.id, e.currentTarget);
1022    });
1023    card.querySelector('[data-action="delete"]').addEventListener('click', async (e) => {
1024      e.stopPropagation();
1025      if (window.styledConfirm) {
1026        const ok = await window.styledConfirm('Delete this research? This permanently removes it from disk.', { c
1026onfirmText: 'Delete', danger: true });
1027        if (!ok) return;
1028      }
1029      try { await fetch(`${_apiBase}/api/research/${job.id}`, { method: 'DELETE', credentials: 'same-origin' }); } catch {}
1030      _animateOutThenRemove(card, () => jobs.removeJob(job.id));
1031    });
1032    card.querySelector('[data-action="dismiss"]').addEventListener('click', (e) => {
1033      e.stopPropagation();
1034      _animateOutThenRemove(card, () => jobs.removeJob(job.id));
1035    });
1036
1037  } else {
1038    const errMsg = job.errorMsg ? `<div class="research-job-error">${_esc(job.errorMsg)}</div>` : '';
1039    card.innerHTML = `
1040      <div class="research-job-header">
1041        <span class="research-job-query">${_esc(job.query)}</span>${job.category ? `<span class="research-cat-badge">${_esc(job.category)}</span>` : ""}
1042        <span class="research-job-status">${job.status}</span>
1043      </div>
1044      ${errMsg}
1045      <div class="research-job-actions">
1046        <button class="research-job-action" data-action="retry" title="Retry">${_retryIcon} Retry</button>
1047        <button class="research-job-action" data-action="edit" title="Edit and retry">${_editIcon} Edit</button>
1048        <button class="research-job-action research-job-action-dim" data-action="dismiss" title="Dismiss">${_cancelIcon}</button>
1049      </div>
1050    `;
1051    card.querySelector('[data-action="retry"]').addEventListener('click', (e) => {
1052      e.stopPropagation(); jobs.retryJob(job.id);
1053    });
1054    card.querySelector('[data-action="edit"]').addEventListener('click', (e) => {
1055      e.stopPropagation(); _editJob(job);
1056    });
1057    card.querySelector('[data-action="dismiss"]').addEventListener('click', (e) => {
1058      e.stopPropagation(); jobs.removeJob(job.id);
1059    });
1060  }
1061
1062  return card;
1063}
1064
1065const _CAT_ICONS = {
1066  product:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="8" width="18" height="13" rx="2"/><path d="M7 8V5a5 5 0 0 1 10 0v3"/></svg>',
1067  comparison: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3v18"/><path d="M16 3v18"/><path d="M3 8h5"/><path d="M16 16h5"/></svg>',
1068  howto:      '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>',
1069  landscape:  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>',
1070  factcheck:  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>',
1071};
1072
1073const _CAT_LABELS = {
1074  product: 'Product',
1075  comparison: 'Comparison',
1076  howto: 'How-to Guide',
1077  landscape: 'Landscape',
1078  factcheck: 'Fact-check',
1079};
1080
1081function _renderResult(job) {
1082  if (!job.result) return '<div class="research-job-loading">Loading result...</div>';
1083  const cat = job.category || '';
1084  const catIcon = _CAT_ICONS[cat] || '';
1085  const catLabel = _CAT_LABELS[cat] || '';
1086
1087  let html = '';
1088
1089  // Category hero banner — only for completed, known-category results
1090  if (cat && catIcon) {
1091    html += `
1092      <div class="research-hero research-hero-${cat}">
1093        <span class="research-hero-icon">${catIcon}</span>
1094        <div class="research-hero-text">
1095          <div class="research-hero-label">${catLabel}</div>
1096          <div class="research-hero-query">${_esc(job.query)}</div>
1097        </div>
1098      </div>
1099    `;
1100  }
1101
1102  if (job.sources?.length) {
1103    html += '<div class="research-job-sources">';
1104    for (const s of job.sources.slice(0, 10)) {
1105      const title = _esc(s.title || s.url || '');
1106      const url = _safeSourceHref(s.url);
1107      html += url
1108        ? `<a href="${url}" target="_blank" rel="noopener" class="research-source-link">${title}</a>`
1109        : `<span class="research-source-link">${title}</span>`;
1110    }
1111    if (job.sources.length > 10) html += `<span class="research-source-more">+${job.sources.length - 10} more</span>`;
1112    html += '</div>';
1113  }
1114
1115  const bodyCls = `research-job-report-body${cat ? ' research-body-' + cat : ''}`;
1116  if (_markdownModule) {
1117    html += `<div class="${bodyCls}">${_markdownModule.renderContent(job.result)}</div>`;
1118  } else {
1119    html += `<div class="${bodyCls}"><pre>${_esc(job.result)}</pre></div>`;
1120  }
1121  return html;
1122}
1123
1124async function _ensureResult(job) {
1125  if (job.result) return;
1126  try {
1127    const res = await fetch(`${_apiBase}/api/research/result-peek/${job.id}`, {
1128      method: 'POST', credentials: 'same-origin',
1129    });
1130    if (!res.ok) return;
1131    const d = await res.json();
1132    job.result = d.result;
1133    job.sources = d.sources;
1134    job.findings = d.raw_findings;
1135  } catch {}
1136}
1137
1138async function _copyResult(job, btn) {
1139  if (!job.result) return;
1140  let text = `# ${job.query}\n\n${job.result}`;
1141  if (job.findings?.length) {
1142    text += '\n\n---\n## Raw Findings\n';
1143    for (const f of job.findings) {
1144      text += `\n### ${f.title || 'Untitled'}\nSource: ${f.url || ''}\n${f.summary || ''}\n`;
1145    }
1146  }
1147  if (job.sources?.length) {
1148    const srcList = job.sources.map(s => `- [${s.title || s.url}](${s.url})`).join('\n');
1149    text += `\n\n---\n## Sources\n${srcList}`;
1150  }
1151  let ok = false;
1152  try {
1153    if (navigator.clipboard && window.isSecureContext) {
1154      await navigator.clipboard.writeText(text);
1155      ok = true;
1156    }
1157  } catch {}
1158  if (!ok) {
1159    // Fallback for non-secure contexts (HTTP self-host) where navigator.clipboard
1160    // is unavailable. The textarea must be in-viewport and focusable for Firefox
1161    // Android / iOS Safari to allow execCommand('copy').
1162    const ta = document.createElement('textarea');
1163    ta.value = text;
1164    ta.readOnly = false;
1165    ta.contentEditable = 'true';
1166    ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;font-size:16px;';
1167    document.body.appendChild(ta);
1168    ta.focus();
1169    ta.select();
1170    try { ta.setSelectionRange(0, text.length); } catch {}
1171    try {
1172      const sel = window.getSelection();
1173      if (sel && (!sel.rangeCount || sel.isCollapsed)) {
1174        const range = document.createRange();
1175        range.selectNodeContents(ta);
1176        sel.removeAllRanges();
1177        sel.addRange(range);
1178        ta.setSelectionRange(0, text.length);
1179      }
1180    } catch {}
1181    try { ok = document.execCommand('copy'); } catch {}
1182    ta.remove();
1183  }
1184  if (btn) {
1185    const orig = btn.innerHTML;
1186    if (ok) {
1187      btn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
1188      btn.classList.add('research-job-action-copied');
1189      setTimeout(() => { btn.innerHTML = orig; btn.classList.remove('research-job-action-copied'); }, 2000);
1190    } else {
1191      btn.innerHTML = `${_cancelIcon} Failed`;
1192      setTimeout(() => { btn.innerHTML = orig; }, 2000);
1193    }
1194  }
1195}
1196
1197// ── Chat about this research (server-side spinoff) ──
1198
1199async function _chatAboutResearch(researchId, btn) {
1200  if (!researchId) return;
1201  const origLabel = btn ? btn.innerHTML : '';
1202  if (btn) { btn.disabled = true; btn.innerHTML = `${_chatIcon} Creating…`; }
1203  try {
1204    const res = await fetch(`${_apiBase}/api/research/spinoff/${researchId}`, {
1205      method: 'POST', credentials: 'same-origin',
1206    });
1207    if (!res.ok) {
1208      let detail = '';
1209      try { detail = (await res.json()).detail || ''; } catch {}
1210      throw new Error(detail || `HTTP ${res.status}`);
1211    }
1212    const payload = await res.json();
1213    if (_sessionModule && _sessionModule.selectSession && payload.session_id) {
1214      if (_sessionModule.loadSessions) await _sessionModule.loadSessions().catch(() => {});
1215      await _sessionModule.selectSession(payload.session_id);
1216      closePanel();
1217    } else if (payload.session_id) {
1218      window.location.hash = '#' + payload.session_id;
1219      window.location.reload();
1220    } else {
1221      // 200 OK but no session_id — server contract violation. Don't leave
1222      // the button stuck on 'Creating…'; surface the failure instead.
1223      throw new Error('Server returned no session id');
1224    }
1225  } catch (e) {
1226    if (btn) { btn.disabled = false; btn.innerHTML = origLabel; }
1227    alert('Could not start follow-up chat: ' + e.message);
1228  }
1229}
1230
1231function _esc(s) {
1232  const d = document.createElement('div');
1233  d.textContent = s || '';
1234  return d.innerHTML;
1235}
1236
1237function _safeSourceHref(raw) {
1238  try {
1239    const parsed = new URL(String(raw || '').trim(), window.location.origin);
1240    if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return _esc(parsed.href);
1241  } catch {}
1242  return '';
1243}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.