PageSourceSearch

https://odysseus-lxcq.onrender.com/static/js/compare/index.js?v=20260723compareicon2

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