PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/compare/index.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 16:59:25 UTC 70,107 bytes, 1,483 lines download raw bytes

1// compare/index.js — orchestrator module (public API)
2/**
3 * Model A/B Comparison module.
4 * Builds its own multi-pane grid layout (up to 8 models).
5 * Sends same prompt to all models in parallel, lets user vote.
6 *
7 * Uses show/hide on the original container children instead of
8 * innerHTML replacement, so event listeners on the input bar,
9 * compare button, mode toggle, etc. are preserved.
10 */
11
12// ── Submodule imports ──
13import state from './state.js';
14import { EVAL_PROMPTS, WAVE_FRAMES,
15  ICON_DICE, ICON_EXPAND, ICON_COLLAPSE, ICON_CLOSE,
16  ICON_REROLL, ICON_COPY, ICON_PLAY, ICON_CODE,
17  ICON_PARALLEL, ICON_SEQUENTIAL,
18  EYE_OPEN, EYE_CLOSED, SAVE_ICON, CHAT_ICON,
19  SEND_SVG, VOTES_STORAGE_KEY,
20} from './icons.js';
21import { fetchModels, _persistSelections, _modelDisplayNames, getExcludedModels, setExcludedModels } from './models.js';
22import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js';
23import { _checkUnprobed, _clearProbeWaves } from './probe.js';
24import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js';
25import {
26  stopAll, stopPane, rerollPane, shufflePanePositions, resetCompare,
27  _addPane, _removePane, toggleExpandPane, togglePanePreview, copyPaneResponse,
28  _showModelSwapDropdown, _createAndAppendPane, _autoPreviewHtml,
29  registerPaneActions,
30} from './panes.js';
31import { handleVote, buildVoteBar, addFinishBadge, spawnConfetti, _saveVote, registerCompareActions } from './vote.js';
32import { showScoreboard } from './scoreboard.js';
33
34// ── External dependency imports ──
35import Storage from '../storage.js';
36import uiModule from '../ui.js';
37import sessionModule from '../sessions.js';
38import spinnerModule from '../spinner.js';
39import themeModule from '../theme.js';
40import presetsModule from '../presets.js';
41import markdownModule from '../markdown.js';
42
43var escapeHtml = uiModule.esc;
44
45/** Slot label: letters (A, B) in parallel, numbers (1, 2) in sequential */
46function _slotChar(i) { return state._parallel ? String.fromCharCode(65 + i) : String(i + 1); }
47
48// ────────────────────────────────────────────────────────────────────────────
49// ── Toolbar indicator sync ──
50// ────────────────────────────────────────────────────────────────────────────
51// ── init ──
52// ────────────────────────────────────────────────────────────────────────────
53
54function init(apiBase) {
55  state.API_BASE = apiBase;
56  // Clean up unsaved compare sessions on page close/refresh
57  window.addEventListener('beforeunload', () => {
58    if (!state._saveOnClose && state._paneSessionIds.length > 0) {
59      // sendBeacon uses POST — use the bulk delete endpoint
60      navigator.sendBeacon(
61        `${state.API_BASE}/api/sessions/bulk-delete`,
62        new Blob([JSON.stringify({ ids: state._paneSessionIds })], { type: 'application/json' })
63      );
64    }
65  });
66}
67
68// ────────────────────────────────────────────────────────────────────────────
69// ── isCompareActive ──
70// ────────────────────────────────────────────────────────────────────────────
71
72function isCompareActive() {
73  return state.isActive;
74}
75
76// ────────────────────────────────────────────────────────────────────────────
77// ── closeCompare ──
78// ────────────────────────────────────────────────────────────────────────────
79
80/** Close compare mode (public API for toolbar indicator). */
81function closeCompare() {
82  if (state.isActive) deactivate(true);
83}
84
85// ────────────────────────────────────────────────────────────────────────────
86// ── toggleMode ──
87// ────────────────────────────────────────────────────────────────────────────
88
89/** Toggle compare mode — shows model selector, then builds UI. */
90async function toggleMode() {
91  if (state.isActive) {
92    deactivate(true);
93    return false;
94  }
95  if (state._openingSelector) return false;
96
97  state._openingSelector = true;
98  try {
99    const confirmed = await showModelSelector();
100    if (!confirmed) return false;
101
102    state.isActive = true;
103    _syncToolbarIndicator(true);
104    await _buildCompareUI();
105    return true;
106  } catch (err) {
107    console.error('Compare toggleMode error:', err);
108    return false;
109  } finally {
110    state._openingSelector = false;
111  }
112}
113
114// ────────────────────────────────────────────────────────────────────────────
115// ── deactivate ──
116// ────────────────────────────────────────────────────────────────────────────
117
118async function deactivate(teardown) {
119  // Abort any in-flight streams
120  state._abortControllers.forEach(ac => { if (ac) ac.abort(); });
121  state._abortControllers = [];
122
123  // Move sessions to compare folder if saving
124  if (state._saveOnClose && state._paneSessionIds.length > 0) {
125    const modelShorts = _modelDisplayNames(state._selectedModels);
126    const folderName = 'Compare: ' + modelShorts.join(' vs ');
127    await Promise.all(state._paneSessionIds.map(sid =>
128      fetch(`${state.API_BASE}/api/session/${sid}`, {
129        method: 'PATCH', body: new URLSearchParams({ folder: folderName })
130      }).catch(() => {})
131    ));
132  }
133
134  // Capture session IDs to delete before resetting state
135  const sessionIdsToDelete = (!state._saveOnClose && teardown && state._paneSessionIds.length > 0)
136    ? [...state._paneSessionIds] : [];
137
138  removeOverlays();
139  state.isActive = false;
140  state._streaming = false;
141  state._paneSessionIds = [];
142  state._paneMetrics = [];
143  state._finishOrder = 0;
144  state._paneElapsed = [];
145  state._saveOnClose = false;
146  state._continueChat = false;
147  state._probed.clear();
148  state._expectedAnswer = '';
149  _syncToolbarIndicator(false);
150
151  // Restore main textarea placeholder
152  const msgTA = document.getElementById('message');
153  if (msgTA) msgTA.placeholder = '';
154
155  // Restore toolbar indicator display states and pointer events
156  Object.entries(state._savedIndicatorDisplay).forEach(([id, display]) => {
157    const el = document.getElementById(id);
158    if (el) { el.style.display = display; el.style.pointerEvents = ''; }
159  });
160  state._savedIndicatorDisplay = {};
161
162  // Unlock mode toggle
163  const _modeToggleR = document.querySelector('.mode-toggle');
164  if (_modeToggleR) { _modeToggleR.style.pointerEvents = ''; _modeToggleR.style.opacity = ''; }
165
166  // Restore tool toggle pointer events
167  ['overflow-plus-btn', 'web-toggle-btn', 'bash-toggle-btn'].forEach(id => {
168    const el = document.getElementById(id);
169    if (el) el.style.pointerEvents = '';
170  });
171
172  // Restore agent/chat mode to what it was before compare
173  const _ts = Storage.loadToggleState();
174  _ts.mode = state._savedMode;
175  Storage.saveToggleState(_ts);
176  const _ab2 = document.getElementById('mode-agent-btn'), _cb2 = document.getElementById('mode-chat-btn');
177  if (_ab2 && _cb2) { _ab2.classList.toggle('active', state._savedMode === 'agent'); _cb2.classList.toggle('active', state._savedMode === 'chat'); }
178  document.querySelectorAll('[data-mode-tool]').forEach(b => { b.style.display = state._savedMode === 'agent' ? '' : 'none'; });
179
180  // Delete unsaved sessions, then reload
181  if (teardown) {
182    if (sessionIdsToDelete.length > 0) {
183      // keepalive ensures requests complete even during page navigation
184      await Promise.all(sessionIdsToDelete.map(sid =>
185        fetch(`${state.API_BASE}/api/session/${sid}`, { method: 'DELETE', keepalive: true }).catch(() => {})
186      ));
187    }
188    location.href = location.pathname;
189  }
190}
191
192// ────────────────────────────────────────────────────────────────────────────
193// ── _buildCompareUI ──
194// ────────────────────────────────────────────────────────────────────────────
195
196/** Build the compare UI: sessions, header bar, grid of panes, vote bar, eval dropdown. */
197async function _buildCompareUI() {
198  if (state._selectedModels.length < 1) {
199    if (uiModule) uiModule.showError('Select at least 1 model');
200    return;
201  }
202
203  const n = state._selectedModels.length;
204  const modelShorts = _modelDisplayNames(state._selectedModels);
205  _persistSelections();
206
207  // 1. Create sessions (skip for search mode — no LLM sessions needed)
208  if (state._compareMode !== 'search') {
209    const sessionIds = [];
210    for (let i = 0; i < n; i++) {
211      const m = state._selectedModels[i];
212      const fd = new FormData();
213      // Blind mode: name the session by its neutral slot so the sidebar /
214      // GET /api/sessions can't de-anonymize the comparison (issue #1285).
215      fd.append('name', '[CMP] ' + (state._blindMode ? 'Model ' + _slotChar(i) : modelShorts[i]));
216      fd.append('endpoint_url', m.endpoint || '');
217      fd.append('model', m.model || '');
218      if (m.endpointId) {
219        fd.append('endpoint_id', m.endpointId);
220        fd.append('skip_validation', 'true');
221      }
222      const res = await fetch(`${state.API_BASE}/api/session`, { method: 'POST', body: fd });
223      if (!res.ok) throw new Error('Failed to create session for ' + modelShorts[i]);
224      const data = await res.json();
225      sessionIds.push(data.id);
226    }
227    state._paneSessionIds = sessionIds;
228  } else {
229    state._paneSessionIds = [];
230  }
231  state._paneMetrics = state._selectedModels.map(() => null);
232  state._abortControllers = state._selectedModels.map(() => null);
233
234  // 2. Auto-collapse sidebar if many panes
235  if (n > 3) {
236    const sidebar = document.getElementById('sidebar');
237    if (sidebar && !sidebar.classList.contains('hidden')) {
238      sidebar.classList.add('hidden');
239      state._sidebarWasHidden = true;
240      const iconRail = document.getElementById('icon-rail');
241      if (iconRail) iconRail.classList.remove('rail-hidden');
242      if (typeof window.syncRailSide === 'function') window.syncRailSide();
243    }
244  }
245
246  // 3. Hide mobile new-chat button during compare
247  const _mobileNewBtn = document.getElementById('mobile-new-chat-btn');
248  if (_mobileNewBtn) {
249    _mobileNewBtn.dataset.cmpWasDisplay = _mobileNewBtn.style.display;
250    _mobileNewBtn.style.display = 'none';
251  }
252
253  // 4. Save toolbar indicator display states before hiding
254  const indicatorIds = ['overflow-tts-btn', 'overflow-attach-btn', 'overflow-rag-btn', 'overflow-research-btn', 'overflow-doc-btn', 'rag-indicator-btn', 'research-toggle-btn'];
255  state._savedIndicatorDisplay = {};
256  indicatorIds.forEach(id => {
257    const el = document.getElementById(id);
258    if (el) state._savedIndicatorDisplay[id] = el.style.display;
259  });
260
261  // 5. Save current mode and lock to the right one for this compare type
262  const _toggleState = Storage.loadToggleState();
263  state._savedMode = _toggleState.mode || 'chat';
264  const _targetMode = (state._compareMode === 'agent') ? 'agent' : 'chat';
265  _toggleState.mode = _targetMode;
266  Storage.saveToggleState(_toggleState);
267  const _ab = document.getElementById('mode-agent-btn'), _cb = document.getElementById('mode-chat-btn');
268  if (_ab && _cb) {
269    _ab.classList.toggle('active', _targetMode === 'agent');
270    _cb.classList.toggle('active', _targetMode === 'chat');
271  }
272  const _modeToggle = document.querySelector('.mode-toggle');
273  if (_modeToggle) { _modeToggle.style.pointerEvents = 'none'; _modeToggle.style.opacity = '0.4'; }
274
275  // 6. Force tool toggles per compare mode
276  disableToolToggles();
277  if (state._compareMode === 'search') {
278    const webChk = document.getElementById('web-toggle');
279    if (webChk && !webChk.checked) { webChk.checked = true; webChk.dispatchEvent(new Event('change')); }
280    const webBtn = document.getElementById('web-toggle-btn');
281    if (webBtn) webBtn.classList.add('active');
282  } else if (state._compareMode === 'research') {
283    const resChk = document.getElementById('research-toggle');
284    if (resChk && !resChk.checked) { resChk.checked = true; resChk.dispatchEvent(new Event('change')); }
285    const resBtn = document.getElementById('research-toggle-btn');
286    if (resBtn) { resBtn.style.display = ''; resBtn.classList.add('active'); }
287  }
288
289  // 7. Hide existing chat container children (preserves event listeners)
290  const container = document.getElementById('chat-container');
291  state._compareElements = [];
292  Array.from(container.children).forEach(child => {
293    if (child.style.display === 'none') return;
294    child.dataset.cmpHidden = '1';
295    child.style.display = 'none';
296  });
297  container.classList.add('compare-active');
298
299  // 8. Header bar
300  const cols = Math.min(n, 4);
301  const headerBar = document.createElement('div');
302  headerBar.className = 'compare-header-bar';
303  headerBar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:6px 10px;flex-shrink:0;';
304  const headerLabel = document.createElement('span');
305  headerLabel.style.cssText = 'font-size:10px;font-weight:400;color:var(--fg);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;';
306  const _modeLabel = ({ search: ' search providers', agent: ' agents', research: ' research models' }[state._compareMode] || ' models');
307  headerLabel.textContent = 'Comparing' + _modeLabel + (state._blindMode ? ' (blind)' : '') + ' · ' + state._timeout + 's timeout';
308  // Left side: the Compare tool icon (two side-by-side panes, matching the
309  // rail/sidebar icon) + the label. Other tool headers carry their icon; this
310  // one was missing it.
311  const headerLeft = document.createElement('div');
312  headerLeft.style.cssText = 'display:flex;align-items:center;min-width:0;';
313  const headerIcon = document.createElement('span');
314  headerIcon.style.cssText = 'display:inline-flex;flex-shrink:0;margin-right:6px;opacity:0.85;';
315  headerIcon.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>';
316  headerLeft.appendChild(headerIcon);
317  headerLeft.appendChild(headerLabel);
318  headerBar.appendChild(headerLeft);
319
320  const headerActions = document.createElement('div');
321  headerActions.style.cssText = 'display:flex;align-items:center;gap:2px;';
322
323  const _btnCSS = 'background:none;border:1px solid var(--border);color:var(--fg);cursor:pointer;padding:3px 10px;font-size:11px;font-weight:600;opacity:0.7;transition:all 0.15s;line-height:1;border-radius:4px;display:inline-flex;align-items:center;font-family:inherit;';
324
325  const checkBtn = document.createElement('button');
326  checkBtn.id = 'compare-check-btn';
327  checkBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M20 6L9 17l-5-5"/></svg><span style="font-size:11px;margin-left:3px;">Probe</span>';
328  checkBtn.title = 'Probe unverified models with a small test request';
329  checkBtn.style.cssText = _btnCSS;
330  checkBtn.addEventListener('click', () => _checkUnprobed());
331  headerActions.appendChild(checkBtn);
332
333  // Check button is dynamic: only visible when at least one selected model
334  // hasn't been probed yet. Show right after add/change, hide after success.
335  window._updateCheckBtnState = function() {
336    const btn = document.getElementById('compare-check-btn');
337    if (!btn) return;
338    const hasUnprobed = state._selectedModels.some(m => !state._probed.has(m.model));
339    btn.style.display = hasUnprobed ? '' : 'none';
340  };
341
342  // (Scoreboard button moved into the vote bar, next to Tie — see vote.js.)
343
344  const exportWrap = document.createElement('div');
345  exportWrap.style.cssText = 'position:relative;display:inline-flex;';
346  const exportBtn = document.createElement('button');
347  exportBtn.id = 'compare-export-btn';
348  exportBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg><span style="font-size:11px;margin-left:3px;">Export</span>';
349  exportBtn.title = 'Export options';
350  exportBtn.style.cssText = _btnCSS;
351  exportBtn.addEventListener('click', (e) => {
352    e.stopPropagation();
353    _toggleExportMenu(exportBtn);
354  });
355  exportWrap.appendChild(exportBtn);
356  headerActions.appendChild(exportWrap);
357
358  const shuffleBtn = document.createElement('button');
359  shuffleBtn.id = 'compare-shuffle-btn';
360  shuffleBtn.innerHTML = ICON_DICE + '<span style="font-size:11px;margin-left:3px;">Shuffle</span>';
361  shuffleBtn.title = 'Shuffle pane positions';
362  shuffleBtn.style.cssText = _btnCSS;
363  shuffleBtn.addEventListener('click', () => shufflePanePositions());
364  headerActions.appendChild(shuffleBtn);
365
366  const addBtn = document.createElement('button');
367  addBtn.id = 'compare-add-btn';
368  addBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg><span style="font-size:11px;margin-left:3px;">Add</span>';
369  addBtn.title = 'Add model pane';
370  addBtn.style.cssText = _btnCSS;
371  addBtn.addEventListener('click', () => _addPane(addBtn));
372  headerActions.appendChild(addBtn);
373
374  const closeBtn = document.createElement('button');
375  closeBtn.className = 'compare-close-btn';
376  closeBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" 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>';
377  closeBtn.title = 'Close compare mode';
378  // Match Export/Score/Shuffle/Model styling so the X sits flush with
379  // the rest of the toolbar instead of being a 24×24 bordered square.
380  closeBtn.style.cssText = _btnCSS;
381  closeBtn.addEventListener('click', () => deactivate(true));
382  headerActions.appendChild(closeBtn);
383
384  // Move Export to the far left of the action cluster (per user preference).
385  headerActions.insertBefore(exportWrap, headerActions.firstChild);
386
387  headerBar.appendChild(headerActions);
388  container.appendChild(headerBar);
389  state._compareElements.push(headerBar);
390
391  // Initial visibility — hidden if all current models are already probed
392  window._updateCheckBtnState();
393
394  // 9. Grid of panes
395  const grid = document.createElement('div');
396  grid.className = 'compare-grid';
397  grid.dataset.cols = String(cols);
398  for (let i = 0; i < n; i++) {
399    const label = state._blindMode ? 'Model ' + _slotChar(i) : modelShorts[i];
400    const pane = document.createElement('div');
401    pane.className = 'compare-pane';
402    pane.dataset.pane = String(i);
403    pane.innerHTML =
404      '<div class="pane-header">' +
405        '<button class="pane-title pane-title-btn" id="cmp-title-' + i + '" data-pane="' + i + '" type="button">' + escapeHtml(label) + ' <span class="pane-title-caret">&#x25BE;</span></button>' +
406        '<span class="pane-timer" id="cmp-timer-' + i + '"></span>' +
407        '<span class="pane-finish-badge" id="cmp-badge-' + i + '"></span>' +
408        '<div class="pane-actions">' +
409          '<button class="pane-action-btn pane-stop-btn" data-action="stop" data-pane="' + i + '" title="Stop" style="display:none;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg></button>' +
410          '<button class="pane-action-btn pane-preview-btn" data-action="preview" data-pane="' + i + '" id="cmp-preview-' + i + '" title="Run preview" style="display:none;">' + ICON_PLAY + '</button>' +
411          '<button class="pane-action-btn" data-action="reroll" data-pane="' + i + '" title="Re-roll">' + ICON_REROLL + '</button>' +
412          '<button class="pane-action-btn" data-action="copy" data-pane="' + i + '" title="Copy">' + ICON_COPY + '</button>' +
413          '<button class="pane-action-btn" data-action="expand" data-pane="' + i + '" title="Expand">' + ICON_EXPAND + '</button>' +
414          '<button class="pane-action-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane">
414' + ICON_CLOSE + '</button>' +
415        '</div>' +
416      '</div>' +
417      '<div class="chat-history" id="cmp-history-' + i + '"></div>' +
418      '<iframe class="compare-pane-iframe" id="cmp-iframe-' + i + '" sandbox="allow-scripts" style="display:none;"></iframe>' +
419      '<div class="pane-vote-footer">' +
420        '<button class="pane-vote-btn" data-pane="' + i + '" type="button" disabled style="opacity:0.4;">' +
421          '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;vertical-align:-2px;"><polyline points="20 6 9 17 4 12"/></svg>' +
422          '<span class="pane-vote-label">Vote ' + escapeHtml(label) + '</span>' +
423        '</button>' +
424      '</div>';
425    grid.appendChild(pane);
426  }
427  grid.addEventListener('click', (e) => {
428    const voteBtn = e.target.closest('.pane-vote-btn');
429    if (voteBtn) {
430      e.stopPropagation();
431      if (voteBtn.disabled) return;
432      const idx = parseInt(voteBtn.dataset.pane);
433      handleVote(idx);
434      return;
435    }
436    const actionBtn = e.target.closest('.pane-action-btn');
437    if (actionBtn) {
438      e.stopPropagation();
439      const action = actionBtn.dataset.action;
440      const idx = parseInt(actionBtn.dataset.pane);
441      if (action === 'stop') stopPane(idx);
442      else if (action === 'copy') copyPaneResponse(idx);
443      else if (action === 'reroll') rerollPane(idx);
444      else if (action === 'expand') toggleExpandPane(idx, actionBtn);
445      else if (action === 'preview') togglePanePreview(idx);
446      else if (action === 'close') _removePane(idx);
447      return;
448    }
449    const titleBtn = e.target.closest('.pane-title-btn');
450    if (titleBtn) {
451      e.stopPropagation();
452      const idx = parseInt(titleBtn.dataset.pane);
453      _showModelSwapDropdown(idx, titleBtn);
454    }
455  });
456  container.appendChild(grid);
457  state._compareElements.push(grid);
458
459  // 10. Vote bar placeholder
460  const voteBar = document.createElement('div');
461  voteBar.id = 'compare-vote-bar';
462  voteBar.className = 'compare-vote-bar';
463  container.appendChild(voteBar);
464  state._compareElements.push(voteBar);
465  buildVoteBar(n);
466
467  if (state._blindMode && n > 1) shufflePanePositions();
468
469  // 11. Move chat input bar to the bottom of the container
470  const inputBar = document.querySelector('.chat-input-bar');
471  if (inputBar) {
472    inputBar.style.display = '';
473    if (inputBar.dataset.cmpHidden) delete inputBar.dataset.cmpHidden;
474    container.appendChild(inputBar);
475  }
476  const msgTA = document.getElementById('message');
477  if (msgTA) {
478    msgTA.placeholder = 'Enter prompt for all models...';
479    requestAnimationFrame(() => msgTA.focus());
480  }
481
482  // Eval-prompts picker — sits inside the message box at top-right (where
483  // model-picker normally lives). Model-picker is irrelevant during compare,
484  // so hide it and restore on deactivate via the wrap's _cleanup.
485  _setupEvalPicker();
486
487  // 12. Hide tool buttons that don't apply during compare
488  ['overflow-tts-btn', 'overflow-attach-btn', 'overflow-rag-btn', 'overflow-research-btn', 'overflow-doc-btn', 'rag-indicator-btn', 'web-toggle-btn', 'bash-toggle-btn', 'overflow-plus-btn'].forEach(id => {
489    const el = document.getElementById(id);
490    if (el) { el.style.display = 'none'; el.style.pointerEvents = 'none'; }
491  });
492  if (state._compareMode !== 'research') {
493    const resBtn = document.getElementById('research-toggle-btn');
494    if (resBtn) { resBtn.style.display = 'none'; resBtn.style.pointerEvents = 'none'; }
495  }
496  document.querySelectorAll('[data-mode-tool]').forEach(b => { b.style.display = 'none'; });
497
498  _setSendBtn('send');
499}
500
501// ────────────────────────────────────────────────────────────────────────────
502// ── _setSendBtn ──
503// ────────────────────────────────────────────────────────────────────────────
504
505function _setSendBtn(mode) {
506  const btn = document.querySelector('.send-btn');
507  if (!btn) return;
508  if (mode === 'stop') {
509    btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
510    btn.title = 'Stop all models';
511    btn.dataset.mode = 'streaming';
512    btn.classList.remove('mic-mode', 'newchat-mode');
513  } else {
514    btn.dataset.mode = '';
515    btn.innerHTML = SEND_SVG;
516    btn.style.color = '';
517    btn.title = 'Send to all models';
518    btn.classList.remove('mic-mode', 'newchat-mode', 'newchat-expanded');
519  }
520}
521
522// ────────────────────────────────────────────────────────────────────────────
523// ── handleCompareSubmit ──
524// ────────────────────────────────────────────────────────────────────────────
525
526/**
527 * Handle submit from the main chat input while compare is active.
528 * Called by app.js submit guard.
529 */
530function handleCompareSubmit(e) {
531  // If streaming, act as stop button
532  if (state._streaming) {
533    stopAll();
534    return;
535  }
536  const input = document.getElementById('message');
537  const message = input ? input.value.trim() : '';
538  if (!message) return;
539  input.value = '';
540  // Reset textarea height
541  input.style.height = '';
542  // Notify input listeners (eval-picker visibility, autosize, etc.) that the
543  // textarea is empty again — programmatic clears don't fire `input` natively.
544  input.dispatchEvent(new Event('input', { bubbles: true }));
545  // Mobile: dismiss the on-screen keyboard after the prompt is sent so the
546  // user sees the streaming output instead of the typing area. A plain blur()
547  // is often ignored on Firefox mobile, so toggle readonly around it (and blur
548  // the active element too) to reliably collapse the keyboard.
549  // Mobile keyboard dismiss — use the SAME proven logic as the main chat send
550  // (chat.js handleChatSubmit). Compare returns early in that flow, so it never
551  // reached this code; replicating it here is what actually works on Firefox
552  // mobile (readonly + blur, then drop readonly only once the blur is confirmed
553  // or the user taps to type again — avoids the keyboard bouncing back up).
554  if (window.innerWidth <= 768) {
555    try {
556      input.setAttribute('readonly', 'readonly');
557      input.blur();
558      // Setting readonly on an ALREADY-FOCUSED textarea doesn't dismiss the
559      // keyboard on Firefox, and blur() is often ignored — so the readonly-only
560      // approach works only when the input happened not to be focused at send
561      // time (inconsistent between 1st/2nd prompt). Deterministically pull focus
562      // off the textarea by focusing a throwaway readonly input, then drop it.
563      const tmp = document.createElement('input');
564      tmp.setAttribute('readonly', 'readonly');
565      tmp.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;border:0;padding:0;';
566      document.body.appendChild(tmp);
567      tmp.focus();
568      setTimeout(() => { try { tmp.blur(); tmp.remove(); } catch {} }, 50);
569      const _dropReadonly = () => { try { input.removeAttribute('readonly'); } catch {} };
570      setTimeout(() => {
571        if (document.activeElement === input) {
572          input.addEventListener('pointerdown', _dropReadonly, { once: true });
573          input.addEventListener('focus', _dropReadonly, { once: true });
574        } else {
575          _dropReadonly();
576        }
577      }, 120);
578    } catch {}
579  }
580  _executeCompare(message);
581}
582
583// ────────────────────────────────────────────────────────────────────────────
584// ── _executeCompare ──
585// ────────────────────────────────────────────────────────────────────────────
586
587/**
588 * Send prompt to all panes, stream responses.
589 * Works for both first and follow-up messages.
590 */
591async function _executeCompare(message) {
592  if (state._streaming) return;
593  if (state._selectedModels.length < 1) return;
594
595  // New round — allow voting again and clear the previous round's win/lose/tie
596  // styling (pane highlight + the Winner!/= title decorations), otherwise the
597  // old result stays stuck on the panes through the next prompt.
598  state._voted = false;
599  for (let i = 0; i < state._selectedModels.length; i++) {
600    const pane = document.querySelector('.compare-pane[data-pane="' + i + '"]');
601    if (pane) {
602      pane.classList.remove('winner', 'loser');
603      // Clear the previous round's Failed/Timeout badge and the eval ✓/✗ grade.
604      pane.querySelector('.pane-grade-badge')?.remove();
605    }
606    const fb = document.getElementById('cmp-badge-' + i);
607    if (fb) { fb.textContent = ''; fb.style.color = ''; }
608    const titleEl = document.getElementById('cmp-title-' + i);
609    if (titleEl) {
610      const label = state._blindMode
611        ? 'Model ' + _slotChar(i)
612        : ((state._selectedModels[i] && state._selectedModels[i].name) || 'Model ' + _slotChar(i));
613      titleEl.innerHTML = escapeHtml(label) + ' <span class="pane-title-caret">&#x25BE;</span>';
614    }
615  }
616
617  state._streaming = true;
618  state._lastPrompt = message;
619  _setSendBtn('stop');
620  // Disable header buttons during streaming
621  document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach(b => {
622    b.disabled = true; b.style.opacity = '0.25'; b.style.pointerEvents = 'none';
623  });
624
625  // ── Search mode: direct API calls, no SSE streaming ──
626  if (state._compareMode === 'search') {
627    try {
628      const n = state._selectedModels.length;
629
630      // Clear previous vote buttons on follow-up
631      const voteBar = document.getElementById('compare-vote-bar');
632      if (voteBar) voteBar.innerHTML = '';
633
634      // Add user query + spinner to each pane
635      for (let i = 0; i < n; i++) {
636        const hist = document.getElementById('cmp-history-' + i);
637        if (!hist) continue;
638        const userMsg = document.createElement('div');
639        userMsg.className = 'msg msg-user';
640        userMsg.innerHTML = '<div class="role">You</div><div class="body"></div>';
641        userMsg.querySelector('.body').textContent = message;
642        hist.appendChild(userMsg);
643
644        const aiMsg = document.createElement('div');
645        aiMsg.className = 'msg msg-ai';
646        aiMsg.innerHTML = '<div class="role">Search</div><div class="body"></div>';
647        const aiBody = aiMsg.querySelector('.body');
648        if (spinnerModule) {
649          const spinner = spinnerModule.create('Searching...', 'right');
650          aiBody.appendChild(spinner.createElement());
651          spinner.start();
652        }
653        hist.appendChild(aiMsg);
654        hist.scrollTop = hist.scrollHeight;
655      }
656
657      // Fire searches — parallel or sequential based on _parallel setting
658      const t0 = performance.now();
659      state._abortControllers = state._selectedModels.map(() => new AbortController());
660
661      async function _searchOne(m, i) {
662        const fd = new FormData();
663        fd.append('query', message);
664        fd.append('provider', m.model);
665        fd.append('count', '10');
666        try {
667          const res = await fetch(`${state.API_BASE}/api/search/query`, { method: 'POST', body: fd, signal: state._abortControllers[i].signal });
668          const data = await res.json();
669          return { idx: i, data };
670        } catch (err) {
671          return { idx: i, data: { results: [], error: err.name === 'AbortError' ? 'Stopped' : err.message } };
672        }
673      }
674
675      let results;
676      const _seqSynthDone = new Set();
677      if (state._parallel) {
678        results = await Promise.all(state._selectedModels.map((m, i) => _searchOne(m, i)));
679      } else {
680        // Sequential — run one at a time, dim waiting panes
681        results = [];
682        const panes = document.querySelectorAll('.compare-pane');
683        panes.forEach((p, i) => { if (i > 0) p.style.opacity = '0.4'; });
684        for (let i = 0; i < state._selectedModels.length; i++) {
685          const pane = panes[i];
686          if (pane) pane.style.opacity = '1';
687          results.push(await _searchOne(state._selectedModels[i], i));
688          // Render this result immediately
689          const { idx, data } = results[results.length - 1];
690          const hist = document.getElementById('cmp-history-' + idx);
691          if (hist) {
692            const aiMsg = hist.querySelector('.msg-ai:last-child');
693            if (aiMsg) {
694              const aiBody = aiMsg.querySelector('.body');
695              aiBody.innerHTML = '';
696              if (data.error) {
697                aiBody.innerHTML = '<div style="color:var(--color-error);font-size:0.85em;">Error: ' + escapeHtml(data.error) + '</div>';
698              } else if (!data.results || data.results.length === 0) {
699                aiBody.innerHTML = '<div style="color:color-mix(in srgb, var(--fg) 50%, transparent);font-size:0.85em;font-style:italic;">No results found</div>';
700              } else {
701                aiBody.appendChild(_renderSearchResults(data));
702              }
703              const footer = document.createElement('div'); footer.className = 'msg-footer';
704              const span = document.createElement('span'); span.className = 'response-metrics';
705              const parts = [];
706              if (data.results) parts.push(data.results.length + ' results');
707              if (data.time) parts.push(data.time + 's');
708              span.textContent = parts.join(' | '); footer.appendChild(span); aiMsg.appendChild(footer);
709              hist.scrollTop = hist.scrollHeight;
710              const _pe = document.querySelector(`.compare-pane[data-pane="${idx}"]`);
711              if (_pe) _pe.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
712            }
713          }
714          // Sequential: run synthesis for this pane immediately before moving to next
715          _seqSynthDone.add(idx);
716          if (!data.error && data.results && data.results.length > 0) {
717            const modelToUse = state._searchSynthModels?.[idx] || null;
718            if (modelToUse) {
719              const seqHist = document.getElementById('cmp-history-' + idx);
720              if (seqHist) {
721                const synthMsg = document.createElement('div');
722                synthMsg.className = 'msg msg-ai';
723                synthMsg.innerHTML = '<div class="role">Analysis</div><div class="body"></div>';
724                const synthBody = synthMsg.querySelector('.body');
725                let spinner = null;
726                if (spinnerModule) { spinner = spinnerModule.create('Analyzing...', 'right'); synthBody.appendChild(spinner.createElement()); spinner.start(); }
727                seqHist.appendChild(synthMsg);
728                seqHist.scrollTop = seqHist.scrollHeight;
729                const resultsText = data.results.map((r, ri) => `[${ri + 1}] ${r.title}\n${r.snippet || ''}\nURL: ${r.url}`).join('\n\n');
730                const synthPrompt = `Analyze these search results for the query "${message}". Summarize the key findings, note any consensus or conflicting information, and provide a brief synthesis.\n\nSearch Results:\n${resultsText}`;
731                await _runSynthForPane(modelToUse, synthPrompt, synthBody, spinner, seqHist);
732              }
733            }
734          }
735        }
736        // Reset opacity
737        panes.forEach(p => { p.style.opacity = ''; });
738      }
739      // Render results into each pane
740      for (const { idx, data } of results) {
741        const hist = document.getElementById('cmp-history-' + idx);
742        if (!hist) continue;
743        const aiMsg = hist.querySelector('.msg-ai:last-child');
744        if (!aiMsg) continue;
745        const aiBody = aiMsg.querySelector('.body');
746        aiBody.innerHTML = '';
747
748        if (data.error) {
749          aiBody.innerHTML = '<div style="color:var(--color-error);font-size:0.85em;">Error: ' + escapeHtml(data.error) + '</div>';
750        } else if (!data.results || data.results.length === 0) {
751          aiBody.innerHTML = '<div style="color:color-mix(in srgb, var(--fg) 50%, transparent);font-size:0.85em;font-style:italic;">No results found</div>';
752        } else {
753          aiBody.appendChild(_renderSearchResults(data));
754        }
755
756        // Footer metrics
757        const footer = document.createElement('div');
758        footer.className = 'msg-footer';
759        const span = document.createElement('span');
760        span.className = 'response-metrics';
761        const parts = [];
762        if (data.results) parts.push(data.results.length + ' results');
763        if (data.time) parts.push(data.time + 's');
764        span.textContent = parts.join(' | ');
765        footer.appendChild(span);
766        aiMsg.appendChild(footer);
767
768        hist.scrollTop = hist.scrollHeight;
769        // Show reroll/copy buttons for search results
770        const _paneEl = document.querySelector(`.compare-pane[data-pane="${idx}"]`);
771        if (_paneEl) _paneEl.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
772      }
773
774      // ── Synthesis: send results to LLM for analysis (respects _parallel setting) ──
775      if (state._searchSynthModels) {
776        // Build list of synthesis tasks
777        const synthTasks = [];
778        for (let i = 0; i < results.length; i++) {
779          const { idx, data } = results[i];
780          // Skip panes already synthesized in sequential mode
781          if (_seqSynthDone.has(idx)) continue;
782          if (data.error || !data.results || data.results.length === 0) continue;
783
784          const modelToUse = state._searchSynthModels?.[idx] || null;
785          if (!modelToUse) continue;
786
787          const hist = document.getElementById('cmp-history-' + idx);
788          if (!hist) continue;
789
790          // Add synthesis message with spinner
791          const synthMsg = document.createElement('div');
792          synthMsg.className = 'msg msg-ai';
793          synthMsg.innerHTML = '<div class="role">Analysis</div><div class="body"></div>';
794          const synthBody = synthMsg.querySelector('.body');
795          let spinner = null;
796          if (spinnerModule) {
797            spinner = spinnerModule.create('Analyzing...', 'right');
798            synthBody.appendChild(spinner.createElement());
799            spinner.start();
800          }
801          hist.appendChild(synthMsg);
802          // Auto-scroll to show the Analysis message
803          hist.scrollTop = hist.scrollHeight;
804
805          // Build synthesis prompt
806          const resultsText = data.results.map((r, ri) =>
807            `[${ri + 1}] ${r.title}\n${r.snippet || ''}\nURL: ${r.url}`
808          ).join('\n\n');
809
810          const synthPrompt = `Analyze these search results for the query "${message}". Summarize the key findings, note any consensus or conflicting information, and provide a brief synthesis.\n\nSearch Results:\n${resultsText}`;
811
812          synthTasks.push({ idx, modelToUse, synthBody, synthMsg, spinner, hist, synthPrompt });
813        }
814
815        // Run synthesis streams (parallel or sequential based on _parallel flag)
816        const runSynthesis = async (task) => _runSynthForPane(task.modelToUse, task.synthPrompt, task.synthBody, task.spinner, task.hist);
817
818        if (state._parallel) {
819          await Promise.all(synthTasks.map(runSynthesis));
820        } else {
821          for (const task of synthTasks) {
822            await runSynthesis(task);
823          }
824        }
825      }
826
827      buildVoteBar(n);
828    } catch (err) {
829      console.error('Search compare error:', err);
830      if (uiModule) uiModule.showError('Search compare failed: ' + err.message);
831    } finally {
832      state._streaming = false;
833      _setSendBtn('send');
834    }
835    return;
836  }
837
838  // ── Chat / Image mode ──
839  const isFollowUp = document.getElementById('cmp-history-0')?.querySelector('.msg-ai');
840
841  try {
842    const n = state._selectedModels.length;
843
844    if (isFollowUp) {
845      const voteBar = document.getElementById('compare-vote-bar');
846      if (voteBar) {
847        voteBar.innerHTML = '';
848        voteBar.classList.add('hidden');
849      }
850    }
851
852    // ── Add user + AI bubbles to each pane ──
853    const aiElements = [];
854    for (let i = 0; i < n; i++) {
855      const hist = document.getElementById('cmp-history-' + i);
856      if (!hist) { aiElements.push(null); continue; }
857
858      const userMsg = document.createElement('div');
859      userMsg.className = 'msg msg-user';
860      userMsg.innerHTML = '<div class="role">You</div><div class="body"></div>';
861      userMsg.querySelector('.body').textContent = message;
862      hist.appendChild(userMsg);
863
864      const aiMsg = document.createElement('div');
865      aiMsg.className = 'msg msg-ai';
866      aiMsg.innerHTML = '<div class="role">AI</div><div class="body"></div>';
867      const aiBody = aiMsg.querySelector('.body');
868      if (spinnerModule) {
869        // In sequential mode, only first pane says "Processing", rest say "Waiting"
870        const label = (!state._parallel && i > 0)
871          ? 'Waiting for Model ' + _slotChar(i - 1) + '...'
872          : 'Processing...';
873        const spinner = spinnerModule.create(label, 'right');
874        aiBody.appendChild(spinner.createElement());
875        spinner.start();
876        aiMsg._spinner = spinner;
877      }
878      hist.appendChild(aiMsg);
879      hist.scrollTop = hist.scrollHeight;
880      aiElements.push(aiMsg);
881    }
882
883    // ── Auto-extend timeout ──
884    const researchChk = document.getElementById('research-toggle');
885    const webChkT = document.getElementById('web-toggle');
886    const noTimeLimit = state._compareMode === 'research' || (researchChk && researchChk.checked);
887    const needsLongTimeout = state._compareMode === 'agent' || (webChkT && webChkT.checked);
888    const runTimeout = noTimeLimit ? 999999 : needsLongTimeout ? Math.max(state._timeout, 300) : state._timeout;
889
890    // ── Pre-search if web toggle is on (share same results across all panes) ──
891    let sharedSearchContext = null;
892    let sharedSearchSources = null;
893    const webChk = document.getElementById('web-toggle');
894    const toggleState = Storage.loadToggleState();
895    const isAgentMode = (toggleState.mode || 'chat') === 'agent';
896    const webOn = webChk && webChk.checked;
897    // In agent mode, web_search is a tool (handled per-pane); in chat mode, pre-search and share
898    if (webOn && !isAgentMode) {
899      try {
900        const fd = new FormData();
901        fd.append('query', message);
902        const searchRes = await fetch(`${state.API_BASE}/api/search`, { method: 'POST', body: fd });
903        if (searchRes.ok) {
904          const searchData = await searchRes.json();
905          if (searchData.context) sharedSearchContext = searchData.context;
906          if (searchData.sources) sharedSearchSources = searchData.sources;
907        }
908      } catch (err) {
909        console.warn('Compare pre-search failed, panes will search individually:', err);
910      }
911    }
912
913    // ── Show vote bar immediately so user can vote anytime ──
914    buildVoteBar(n);
915
916    // ── Stream all panes (parallel or sequential based on _parallel flag) ──
917    state._finishOrder = 0;
918    state._paneElapsed = new Array(n).fill(null);
919    state._paneMetrics = new Array(n).fill(null);
920    state._abortControllers = new Array(n).fill(null);
921
922    if (state._parallel) {
923      // Run all panes at once
924      await Promise.all(state._paneSessionIds.map((sid, i) =>
925        streamToPane(i, sid, message, aiElements[i], { searchContext: sharedSearchContext, timeout: runTimeout })
926      ));
927    } else {
928      // Run one pane at a time (sequential) — active pane full opacity, others dimmed
929      const allPanes = document.querySelectorAll('.compare-pane');
930      allPanes.forEach(p => { p.style.transition = 'opacity 0.4s ease'; });
931      // Dim all except first
932      allPanes.forEach((p, idx) => { p.style.opacity = idx === 0 ? '1' : '0.35'; });
933
934      for (let i = 0; i < state._paneSessionIds.length; i++) {
935        // Update spinner
936        if (aiElements[i] && aiElements[i]._spinner) {
937          aiElements[i]._spinner.updateLabel('Processing...');
938        }
939
940        await streamToPane(i, state._paneSessionIds[i], message, aiElements[i], { searchContext: sharedSearchContext, timeout: runTimeout });
941
942        // Swap opacity: dim current, brighten next
943        if (allPanes[i]) allPanes[i].style.opacity = '0.35';
944        if (i + 1 < allPanes.length && allPanes[i + 1]) {
945          allPanes[i + 1].style.opacity = '1';
946        }
947      }
948
949      // Restore all pane opacities when done
950      allPanes.forEach(p => { p.style.opacity = ''; p.style.transition = ''; });
951    }
952
953    // Re-focus main input for follow-up
954    if (state._continueChat) {
955      const ta = document.getElementById('message');
956      if (ta) ta.focus();
957    }
958
959  } catch (err) {
960    console.error('Compare error:', err);
961    if (uiModule) uiModule.showError('Compare failed: ' + err.message);
962  } finally {
963    state._streaming = false;
964    _setSendBtn('send');
965    // Re-enable header buttons
966    document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach(b => {
967      b.disabled = false; b.style.opacity = '0.7'; b.style.pointerEvents = '';
968    });
969  }
970}
971
972// showModelSelector imported from ./selector.js
973
974// ────────────────────────────────────────────────────────────────────────────
975// ── cleanupResults / removeOverlays ──
976// ────────────────────────────────────────────────────────────────────────────
977
978/**
979 * Build a markdown comparison of the current panes (prompt + each model's
980 * response + metrics + grade) and copy it to the clipboard. Lets users save
981 * or share a side-by-side at a glance.
982 */
983// Build the comparison markdown string. Shared by all export paths.
984function _buildComparisonMarkdown() {
985  const grid = document.querySelector('.compare-grid');
986  if (!grid) return null;
987  const panes = grid.querySelectorAll('.compare-pane');
988  if (!panes.length) return null;
989  const prompt = state._lastPrompt || '(no prompt yet — run a comparison first)';
990  const expected = state._expectedAnswer || '';
991  const date = new Date().toISOString().slice(0, 19).replace('T', ' ');
992  let md = '# Compare\n\n';
993  md += '**When:** ' + date + '\n';
994  md += '**Type:** ' + (state._compareMode || 'chat') + (state._blindMode ? ' (blind)' : '') + '\n';
995  md += '**Prompt:**\n\n```\n' + prompt + '\n```\n\n';
996  if (expected) md += '**Expected answer:** `' + expected + '`\n\n';
997  panes.forEach((pane, i) => {
998    const m = state._selectedModels[i];
999    const name = m ? (m.name || m.model) + (m.endpointName ? ' (' + m.endpointName + ')' : '') : 'Model ' + (i + 1);
1000    const body = pane.querySelector('.compare-text-content, .msg-body, .body');
1001    const text = body ? (body.innerText || body.textContent || '').trim() : '';
1002    const metrics = state._paneMetrics[i];
1003    const grade = pane.querySelector('.pane-grade-badge');
1004    const gradeMark = grade ? (grade.classList.contains('pass') ? ' ✓' : ' ✗') : '';
1005    md += '## ' + name + gradeMark + '\n\n';
1006    if (metrics) {
1007      const bits = [];
1008      if (metrics.output_tokens != null) bits.push(metrics.output_tokens + ' tokens');
1009      if (metrics.tokens_per_second != null) bits.push(metrics.tokens_per_second + ' tok/s');
1010      if (metrics.response_time != null) bits.push(metrics.response_time + 's');
1011      if (bits.length) md += '_' + bits.join(' · ') + '_\n\n';
1012    }
1013    md += text ? text + '\n\n' : '_(no response)_\n\n';
1014    md += '---\n\n';
1015  });
1016  return md;
1017}
1018
1019let _exportMenuEl = null;
1020function _toggleExportMenu(btn) {
1021  if (_exportMenuEl) { _closeExportMenu(); return; }
1022  const r = btn.getBoundingClientRect();
1023  const m = document.createElement('div');
1024  m.className = 'compare-export-menu';
1025  m.style.cssText = 'position:fixed;z-index:10001;top:' + (r.bottom + 4) + 'px;left:' + r.left + 'px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;display:flex;flex-direction:column;min-width:170px;';
1026  const opts = [
1027    { label: 'Copy as Markdown', fn: () => _exportCopyMarkdown(btn) },
1028    { label: 'Download .md',     fn: () => _exportDownloadMarkdown() },
1029    { label: 'Print / Save PDF', fn: () => _exportPrint() },
1030  ];
1031  for (const o of opts) {
1032    const item = document.createElement('button');
1033    item.type = 'button';
1034    item.textContent = o.label;
1035    item.style.cssText = 'background:none;border:none;color:var(--fg);text-align:left;padding:8px 12px;border-radius:6px;cursor:pointer;font:inherit;font-size:12px;';
1036    item.addEventListener('mouseenter', () => { item.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
1037    item.addEventListener('mouseleave', () => { item.style.background = 'none'; });
1038    item.addEventListener('click', () => { _closeExportMenu(); o.fn(); });
1039    m.appendChild(item);
1040  }
1041  document.body.appendChild(m);
1042  _exportMenuEl = m;
1043  setTimeout(() => document.addEventListener('click', _closeExportMenu, { once: true }), 0);
1044}
1045function _closeExportMenu() {
1046  if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
1047}
1048
1049async function _exportCopyMarkdown(_btn) {
1050  const md = _buildComparisonMarkdown();
1051  if (!md) return;
1052  try {
1053    if (navigator.clipboard && navigator.clipboard.writeText) {
1054      await navigator.clipboard.writeText(md);
1055    } else {
1056      // Avoid the focus-stealing textarea fallback when the modern API
1057      // is available — that path briefly flashes the page as the
1058      // textarea is added/focused/removed.
1059      const ta = document.createElement('textarea');
1060      ta.value = md;
1061      ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0;';
1062      document.body.appendChild(ta);
1063      ta.select(); document.execCommand('copy'); ta.remove();
1064    }
1065    try { window.uiModule?.showToast?.('Copied comparison to clipboard'); } catch {}
1066  } catch (e) {
1067    try { window.uiModule?.showToast?.('Copy failed'); } catch {}
1068  }
1069}
1070
1071function _exportDownloadMarkdown() {
1072  const md = _buildComparisonMarkdown();
1073  if (!md) return;
1074  const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
1075  const blob = new Blob([md], { type: 'text/markdown' });
1076  const url = URL.createObjectURL(blob);
1077  const a = document.createElement('a');
1078  a.href = url;
1079  a.download = 'compare-' + ts + '.md';
1080  document.body.appendChild(a);
1081  a.click();
1082  a.remove();
1083  setTimeout(() => URL.revokeObjectURL(url), 1000);
1084}
1085
1086function _exportPrint() {
1087  const md = _buildComparisonMarkdown();
1088  if (!md) return;
1089  // Render the markdown as a quick HTML view in a new window and trigger
1090  // the system print dialog — user can pick "Save as PDF" from there.
1091  const w = window.open('', '_blank');
1092  if (!w) return;
1093  try { w.opener = null; } catch (_) {}
1094  const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1095  const html = '<!doctype html><meta charset="utf-8"><title>Compare export</title>' +
1096    '<style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;max-width:780px;margin:32px auto;padding:0 24px;line-height:1.55;color:#222}' +
1097    'pre{background:#f5f5f5;border-radius:6px;padding:10px;white-space:pre-wrap}' +
1098    'h1{margin-top:0}h2{border-bottom:1px solid #ddd;padding-bottom:4px;margin-top:32px}' +
1099    'hr{border:none;border-top:1px solid #ccc;margin:24px 0}' +
1100    '</style><body><pre style="background:none;padding:0">' + escape(md) + '</pre>' +
1101    '<script>window.onload=()=>setTimeout(()=>window.print(),100)<\/script>';
1102  w.document.write(html);
1103  w.document.close();
1104}
1105
1106async function _exportComparison(btn) {
1107  const grid = document.querySelector('.compare-grid');
1108  if (!grid) return;
1109  const panes = grid.querySelectorAll('.compare-pane');
1110  if (!panes.length) return;
1111
1112  const prompt = state._lastPrompt || '(no prompt yet — run a comparison first)';
1113  const expected = state._expectedAnswer || '';
1114  const date = new Date().toISOString().slice(0, 19).replace('T', ' ');
1115
1116  let md = '# Compare\n\n';
1117  md += '**When:** ' + date + '\n';
1118  md += '**Type:** ' + (state._compareMode || 'chat') + (state._blindMode ? ' (blind)' : '') + '\n';
1119  md += '**Prompt:**\n\n```\n' + prompt + '\n```\n\n';
1120  if (expected) md += '**Expected answer:** `' + expected + '`\n\n';
1121
1122  panes.forEach((pane, i) => {
1123    const m = state._selectedModels[i];
1124    const name = m ? (m.name || m.model) + (m.endpointName ? ' (' + m.endpointName + ')' : '') : 'Model ' + (i + 1);
1125    const body = pane.querySelector('.compare-text-content, .msg-body, .body');
1126    const text = body ? (body.innerText || body.textContent || '').trim() : '';
1127    const metrics = state._paneMetrics[i];
1128    const grade = pane.querySelector('.pane-grade-badge');
1129    const gradeMark = grade ? (grade.classList.contains('pass') ? ' ✓' : ' ✗') : '';
1130
1131    md += '## ' + name + gradeMark + '\n\n';
1132    if (metrics) {
1133      const bits = [];
1134      if (metrics.output_tokens != null) bits.push(metrics.output_tokens + ' tokens');
1135      if (metrics.tokens_per_second != null) bits.push(metrics.tokens_per_second + ' tok/s');
1136      if (metrics.response_time != null) bits.push(metrics.response_time + 's');
1137      if (bits.length) md += '_' + bits.join(' · ') + '_\n\n';
1138    }
1139    md += text ? text + '\n\n' : '_(no response)_\n\n';
1140    md += '---\n\n';
1141  });
1142
1143  // Copy to clipboard
1144  const origLabel = btn ? btn.innerHTML : '';
1145  try {
1146    if (navigator.clipboard && navigator.clipboard.writeText) {
1147      await navigator.clipboard.writeText(md);
1148    } else {
1149      const ta = document.createElement('textarea');
1150      ta.value = md; document.body.appendChild(ta);
1151      ta.select(); document.execCommand('copy'); ta.remove();
1152    }
1153    if (btn) {
1154      btn.innerHTML = '<span style="font-size:11px;">Copied!</span>';
1155      setTimeout(() => { btn.innerHTML = origLabel; }, 1500);
1156    }
1157  } catch (e) {
1158    if (btn) {
1159      btn.innerHTML = '<span style="font-size:11px;color:var(--color-error);">Failed</span>';
1160      setTimeout(() => { btn.innerHTML = origLabel; }, 2000);
1161    }
1162  }
1163}
1164
1165/**
1166 * Build the eval-prompts picker — shown only during compare. Mirrors the
1167 * absolute-positioned model-picker location (top-right of .chat-input-top)
1168 * and is auto-cleaned up by the standard _compareElements teardown.
1169 */
1170function _setupEvalPicker() {
1171  const inputTop = document.querySelector('.chat-input-top');
1172  if (!inputTop) return;
1173
1174  const escapeHtml = uiModule.esc;
1175
1176  // Hide the model-picker so eval-prompts can occupy the same slot
1177  const modelWrap = document.getElementById('model-picker-wrap');
1178  const prevModelDisplay = modelWrap ? modelWrap.style.display : '';
1179  if (modelWrap) modelWrap.style.display = 'none';
1180
1181  const wrap = document.createElement('div');
1182  wrap.className = 'cmp-eval-wrap';
1183  wrap.id = 'cmp-eval-wrap';
1184
1185  const btn = document.createElement('button');
1186  btn.type = 'button';
1187  btn.id = 'cmp-eval-btn';
1188  btn.className = 'cmp-eval-btn';
1189  btn.title = 'Insert an evaluation prompt';
1190  btn.innerHTML =
1191    '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>'
1192    + '<span class="cmp-eval-label">Eval prompts</span>'
1193    + '<svg class="cmp-eval-caret" 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>';
1194
1195  const menu = document.createElement('div');
1196  menu.className = 'cmp-eval-menu hidden';
1197  menu.id = 'cmp-eval-menu';
1198
1199  function _renderItems() {
1200    const mode = state._compareMode || 'chat';
1201    // research/html aren't first-class compare types — fall back gracefully
1202    const key = EVAL_PROMPTS[mode] ? mode
1203      : (mode === 'research' ? 'search' : 'chat');
1204    const list = EVAL_PROMPTS[key] || [];
1205
1206    if (!list.length) {
1207      menu.innerHTML = '<div class="cmp-eval-empty">No prompts for this type</div>';
1208      return;
1209    }
1210    // Group by sub-category in original order
1211    const order = [];
1212    const groups = {};
1213    for (const p of list) {
1214      const sub = p.sub || 'Other';
1215      if (!groups[sub]) { groups[sub] = []; order.push(sub); }
1216      groups[sub].push(p);
1217    }
1218    let html = '';
1219    for (const sub of order) {
1220      html += '<div class="cmp-eval-group-label">' + escapeHtml(sub) + '</div>';
1221      for (const p of groups[sub]) {
1222        const data = encodeURIComponent(p.prompt);
1223        const ans = p.answer ? ' data-answer="' + encodeURIComponent(p.answer) + '"' : '';
1224        const checkMark = p.answer ? '<span class="cmp-eval-item-tick" title="Has expected answer">✓</span>' : '';
1225        html += '<button type="button" class="cmp-eval-item" data-prompt="' + data + '"' + ans + '>'
1226          + escapeHtml(p.label) + checkMark + '</button>';
1227      }
1228    }
1229    menu.innerHTML = html;
1230    menu.querySelectorAll('.cmp-eval-item').forEach(item => {
1231      item.addEventListener('click', (e) => {
1232        e.stopPropagation();
1233        const ta = document.getElementById('message');
1234        if (ta) {
1235          ta.value = decodeURIComponent(item.dataset.prompt);
1236          ta.dispatchEvent(new Event('input', { bubbles: true }));
1237          ta.focus();
1238        }
1239        const ans = item.dataset.answer ? decodeURIComponent(item.dataset.answer) : '';
1240        _showExpectedAnswer(ans);
1241        menu.classList.add('hidden');
1242      });
1243    });
1244  }
1245
1246  btn.addEventListener('click', (e) => {
1247    e.stopPropagation();
1248    if (menu.classList.contains('hidden')) {
1249      _renderItems();
1250      menu.classList.remove('hidden');
1251    } else {
1252      menu.classList.add('hidden');
1253    }
1254  });
1255
1256  const _onDocClick = (e) => {
1257    if (!wrap.contains(e.target)) menu.classList.add('hidden');
1258  };
1259  document.addEventListener('click', _onDocClick);
1260
1261  wrap.appendChild(btn);
1262  wrap.appendChild(menu);
1263  inputTop.appendChild(wrap);
1264
1265  // Expected-answer chip — placed above the chat-input-bar (outside it), so
1266  // it floats over the compare grid right before the message box. Shows when
1267  // a graded prompt is picked so the eval-runner can verify model output.
1268  const hintChip = document.createElement('div');
1269  hintChip.className = 'cmp-eval-expected hidden';
1270  hintChip.id = 'cmp-eval-expected';
1271  hintChip.innerHTML =
1272    '<span class="cmp-eval-expected-label">Expected:</span>'
1273    + ' <strong class="cmp-eval-expected-value"></strong>'
1274    + ' <button type="button" class="cmp-eval-expected-close" title="Dismiss">×</button>';
1275  // Anchor the floating panel against the input bar (needs position:relative
1276  // — added via CSS rule on .chat-input-bar:has(.cmp-eval-expected) below).
1277  const inputBar = document.querySelector('.chat-input-bar');
1278  if (inputBar) {
1279    inputBar.appendChild(hintChip);
1280  } else {
1281    inputTop.appendChild(hintChip);
1282  }
1283  hintChip.querySelector('.cmp-eval-expected-close').addEventListener('click', (e) => {
1284    e.stopPropagation();
1285    hintChip.classList.add('hidden');
1286    state._expectedAnswer = '';
1287  });
1288
1289  function _showExpectedAnswer(answer) {
1290    state._expectedAnswer = answer || '';
1291    if (!answer) {
1292      hintChip.classList.add('hidden');
1293      return;
1294    }
1295    hintChip.querySelector('.cmp-eval-expected-value').textContent = answer;
1296    hintChip.classList.remove('hidden');
1297  }
1298
1299  // Hide the picker when the textarea has any user text (it's only useful
1300  // when starting fresh). Reappears when cleared. The expected-answer
1301  // chip stays put across sends — clearing it on every empty-textarea
1302  // tick wiped state._expectedAnswer before grading could read it, so
1303  // pane ✓/✗ badges never appeared. The chip is only cleared via its
1304  // own dismiss button (or when the user picks a new eval).
1305  const ta = document.getElementById('message');
1306  const _syncEvalVisibility = () => {
1307    const hasText = ta && ta.value.trim().length > 0;
1308    wrap.style.display = hasText ? 'none' : '';
1309    if (hasText) menu.classList.add('hidden');
1310  };
1311  if (ta) ta.addEventListener('input', _syncEvalVisibility);
1312  _syncEvalVisibility();
1313
1314  // Stash cleanup so cleanupResults() can detach the doc listener and
1315  // restore the model-picker when compare deactivates.
1316  wrap._cleanup = () => {
1317    document.removeEventListener('click', _onDocClick);
1318    if (ta) ta.removeEventListener('input', _syncEvalVisibility);
1319    if (modelWrap) modelWrap.style.display = prevModelDisplay || '';
1320    if (hintChip.parentNode) hintChip.remove();
1321  };
1322  state._compareElements.push(wrap);
1323}
1324
1325/** Remove compare UI elements and restore original view. */
1326function cleanupResults() {
1327  // Remove all compare elements
1328  state._compareElements.forEach(el => {
1329    if (el._cleanup) el._cleanup();
1330    if (el._cleanupInput) el._cleanupInput();
1331    if (el.parentNode) el.remove();
1332  });
1333  state._compareElements = [];
1334
1335  // Remove any stray compare/probe overlays
1336  document.querySelectorAll('.compare-probe-overlay').forEach(el => el.remove());
1337
1338  // Restore sidebar
1339  if (state._sidebarWasHidden) {
1340    const sidebar = document.getElementById('sidebar');
1341    if (sidebar) sidebar.classList.remove('hidden');
1342    state._sidebarWasHidden = false;
1343  }
1344  const _mobileNewRestore = document.getElementById('mobile-new-chat-btn');
1345  if (_mobileNewRestore && _mobileNewRestore.dataset.cmpWasDisplay !== undefined) {
1346    _mobileNewRestore.style.display = _mobileNewRestore.dataset.cmpWasDisplay;
1347    delete _mobileNewRestore.dataset.cmpWasDisplay;
1348  }
1349  state._hasVisibleResults = false;
1350
1351  // Hard reload the page to cleanly restore all UI state
1352  window.location.reload();
1353}
1354
1355function removeOverlays() {
1356  const bar = document.getElementById('compare-vote-bar');
1357  if (bar) bar.remove();
1358  const modal = document.getElementById('compare-model-overlay');
1359  if (modal) modal.remove();
1360  const probe = document.querySelector('.compare-probe-overlay');
1361  if (probe) probe.remove();
1362}
1363
1364// ────────────────────────────────────────────────────────────────────────────
1365// ── showShufflePoolEditor ──
1366// ────────────────────────────────────────────────────────────────────────────
1367
1368/** Shuffle pool editor — lets users exclude broken models from the dice. */
1369async function showShufflePoolEditor() {
1370  let models;
1371  try { models = await fetchModels(); } catch (e) {
1372    if (uiModule) uiModule.showError('Failed to load models');
1373    return;
1374  }
1375
1376  const overlay = document.createElement('div');
1377  overlay.className = 'modal';
1378  overlay.id = 'shuffle-pool-overlay';
1379
1380  const content = document.createElement('div');
1381  content.className = 'modal-content';
1382  content.style.width = '420px';
1383
1384  const header = document.createElement('div');
1385  header.className = 'modal-header';
1386  header.innerHTML = '<h4>Shuffle Pool</h4>';
1387  const closeBtn = document.createElement('button');
1388  closeBtn.className = 'close-btn';
1389  closeBtn.innerHTML = '&#x2716;';
1390  closeBtn.addEventListener('click', () => overlay.remove());
1391  header.appendChild(closeBtn);
1392  content.appendChild(header);
1393
1394  const body = document.createElement('div');
1395  body.className = 'modal-body';
1396  body.style.padding = '12px 16px';
1397
1398  const desc = document.createElement('p');
1399  desc.style.cssText = 'color:color-mix(in srgb, var(--fg) 55%, transparent);font-size:0.85em;margin:0 0 12px;';
1400  desc.textContent = 'Uncheck models to exclude them from random shuffle. They can still be picked manually.';
1401  body.appendChild(desc);
1402
1403  const list = document.createElement('div');
1404  list.style.cssText = 'max-height:400px;overflow-y:auto;';
1405
1406  const excluded = getExcludedModels();
1407
1408  // Group by type
1409  const groups = { chat: [], image: [] };
1410  models.forEach(m => { if (groups[m.type]) groups[m.type].push(m); });
1411
1412  Object.entries(groups).forEach(([type, items]) => {
1413    if (items.length === 0) return;
1414    const heading = document.createElement('div');
1415    heading.style.cssText = 'font-size:0.78em;font-weight:600;color:color-mix(in srgb, var(--fg) 50%, transparent);text-transform:uppercase;letter-spacing:0.5px;padding:8px 4px 4px;';
1416    heading.textContent = type === 'chat' ? 'Chat Models' : 'Image Models';
1417    list.appendChild(heading);
1418
1419    items.forEach(m => {
1420      const row = document.createElement('label');
1421      row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:5px 4px;cursor:pointer;font-size:0.85em;color:var(--fg);border-radius:4px;';
1422      row.addEventListener('mouseenter', () => { row.style.background = 'color-mix(in srgb, var(--fg) 4%, transparent)'; });
1423      row.addEventListener('mouseleave', () => { row.style.background = ''; });
1424      const chk = document.createElement('input');
1425      chk.type = 'checkbox';
1426      chk.checked = !excluded.includes(m.id);
1427      chk.addEventListener('change', () => {
1428        const exc = getExcludedModels();
1429        if (chk.checked) {
1430          const idx = exc.indexOf(m.id);
1431          if (idx >= 0) exc.splice(idx, 1);
1432        } else {
1433          if (!exc.includes(m.id)) exc.push(m.id);
1434        }
1435        setExcludedModels(exc);
1436      });
1437      const label = document.createElement('span');
1438      label.textContent = m.endpointName ? m.name + ' (' + m.endpointName + ')' : m.name;
1439      row.appendChild(chk);
1440      row.appendChild(label);
1441      list.appendChild(row);
1442    });
1443  });
1444
1445  body.appendChild(list);
1446  content.appendChild(body);
1447  overlay.appendChild(content);
1448  document.body.appendChild(overlay);
1449
1450  if (themeModule && themeModule.makeDraggable) {
1451    themeModule.makeDraggable(content, header);
1452  }
1453}
1454
1455// ────────────────────────────────────────────────────────────────────────────
1456// ── Register cross-module callbacks ──
1457// ────────────────────────────────────────────────────────────────────────────
1458
1459registerCompareActions({ stopAll, resetCompare });
1460registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml });
1461registerPaneActions({ setSendBtn: _setSendBtn, deactivate, streamToPane, renderSearchResults: _renderSearchResults, fetchModels });
1462
1463// ────────────────────────────────────────────────────────────────────────────
1464// ── Public API ──
1465// ────────────────────────────────────────────────────────────────────────────
1466
1467export { EVAL_PROMPTS, showScoreboard, handleCompareSubmit };
1468
1469const compareModule = {
1470  init,
1471  toggleMode,
1472  handleCompareSubmit,
1473  isActive: isCompareActive,
1474  hasVisibleResults: () => state._hasVisibleResults,
1475  deactivate,
1476  closeCompare,
1477  cleanupResults,
1478  showShufflePoolEditor,
1479  showScoreboard,
1480};
1481
1482export default compareModule;
1483window.compareModule = compareModule;

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.