// compare/index.js — orchestrator module (public API)
/**
* Model A/B Comparison module.
* Builds its own multi-pane grid layout (up to 8 models).
* Sends same prompt to all models in parallel, lets user vote.
*
* Uses show/hide on the original container children instead of
* innerHTML replacement, so event listeners on the input bar,
* compare button, mode toggle, etc. are preserved.
*/
// ── Submodule imports ──
import state from './state.js';
import { EVAL_PROMPTS, WAVE_FRAMES,
ICON_DICE, ICON_EXPAND, ICON_COLLAPSE, ICON_CLOSE,
ICON_REROLL, ICON_COPY, ICON_PLAY, ICON_CODE,
ICON_PARALLEL, ICON_SEQUENTIAL,
EYE_OPEN, EYE_CLOSED, SAVE_ICON, CHAT_ICON,
SEND_SVG, VOTES_STORAGE_KEY,
} from './icons.js';
import { fetchModels, _persistSelections, _modelDisplayNames, getExcludedModels, setExcludedModels } from './models.js';
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js';
import { _checkUnprobed, _clearProbeWaves } from './probe.js';
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js';
import {
stopAll, stopPane, rerollPane, shufflePanePositions, resetCompare,
_addPane, _removePane, toggleExpandPane, togglePanePreview, copyPaneResponse,
_showModelSwapDropdown, _createAndAppendPane, _autoPreviewHtml,
registerPaneActions,
} from './panes.js';
import { handleVote, buildVoteBar, addFinishBadge, spawnConfetti, _saveVote, registerCompareActions } from './vote.js';
import { showScoreboard } from './scoreboard.js';
// ── External dependency imports ──
import Storage from '../storage.js';
import uiModule from '../ui.js';
import sessionModule from '../sessions.js';
import spinnerModule from '../spinner.js';
import themeModule from '../theme.js';
import presetsModule from '../presets.js';
import markdownModule from '../markdown.js';
var escapeHtml = uiModule.esc;
/** Slot label: letters (A, B) in parallel, numbers (1, 2) in sequential */
function _slotChar(i) { return state._parallel ? String.fromCharCode(65 + i) : String(i + 1); }
// ────────────────────────────────────────────────────────────────────────────
// ── Toolbar indicator sync ──
// ────────────────────────────────────────────────────────────────────────────
// ── init ──
// ────────────────────────────────────────────────────────────────────────────
function init(apiBase) {
state.API_BASE = apiBase;
// Clean up unsaved compare sessions on page close/refresh
window.addEventListener('beforeunload', () => {
if (!state._saveOnClose && state._paneSessionIds.length > 0) {
// sendBeacon uses POST — use the bulk delete endpoint
navigator.sendBeacon(
`${state.API_BASE}/api/sessions/bulk-delete`,
new Blob([JSON.stringify({ ids: state._paneSessionIds })], { type: 'application/json' })
);
}
});
}
// ────────────────────────────────────────────────────────────────────────────
// ── isCompareActive ──
// ────────────────────────────────────────────────────────────────────────────
function isCompareActive() {
return state.isActive;
}
// ────────────────────────────────────────────────────────────────────────────
// ── closeCompare ──
// ────────────────────────────────────────────────────────────────────────────
/** Close compare mode (public API for toolbar indicator). */
function closeCompare() {
if (state.isActive) deactivate(true);
}
// ────────────────────────────────────────────────────────────────────────────
// ── toggleMode ──
// ────────────────────────────────────────────────────────────────────────────
/** Toggle compare mode — shows model selector, then builds UI. */
async function toggleMode() {
if (state.isActive) {
deactivate(true);
return false;
}
if (state._openingSelector) return false;
state._openingSelector = true;
try {
const confirmed = await showModelSelector();
if (!confirmed) return false;
state.isActive = true;
_syncToolbarIndicator(true);
await _buildCompareUI();
return true;
} catch (err) {
console.error('Compare toggleMode error:', err);
return false;
} finally {
state._openingSelector = false;
}
}
// ────────────────────────────────────────────────────────────────────────────
// ── deactivate ──
// ────────────────────────────────────────────────────────────────────────────
async function deactivate(teardown) {
// Abort any in-flight streams
state._abortControllers.forEach(ac => { if (ac) ac.abort(); });
state._abortControllers = [];
// Move sessions to compare folder if saving
if (state._saveOnClose && state._paneSessionIds.length > 0) {
const modelShorts = _modelDisplayNames(state._selectedModels);
const folderName = 'Compare: ' + modelShorts.join(' vs ');
await Promise.all(state._paneSessionIds.map(sid =>
fetch(`${state.API_BASE}/api/session/${sid}`, {
method: 'PATCH', body: new URLSearchParams({ folder: folderName })
}).catch(() => {})
));
}
// Capture session IDs to delete before resetting state
const sessionIdsToDelete = (!state._saveOnClose && teardown && state._paneSessionIds.length > 0)
? [...state._paneSessionIds] : [];
removeOverlays();
state.isActive = false;
state._streaming = false;
state._paneSessionIds = [];
state._paneMetrics = [];
state._finishOrder = 0;
state._paneElapsed = [];
state._saveOnClose = false;
state._continueChat = false;
state._probed.clear();
state._expectedAnswer = '';
_syncToolbarIndicator(false);
// Restore main textarea placeholder
const msgTA = document.getElementById('message');
if (msgTA) msgTA.placeholder = '';
// Restore toolbar indicator display states and pointer events
Object.entries(state._savedIndicatorDisplay).forEach(([id, display]) => {
const el = document.getElementById(id);
if (el) { el.style.display = display; el.style.pointerEvents = ''; }
});
state._savedIndicatorDisplay = {};
// Unlock mode toggle
const _modeToggleR = document.querySelector('.mode-toggle');
if (_modeToggleR) { _modeToggleR.style.pointerEvents = ''; _modeToggleR.style.opacity = ''; }
// Restore tool toggle pointer events
['overflow-plus-btn', 'web-toggle-btn', 'bash-toggle-btn'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.pointerEvents = '';
});
// Restore agent/chat mode to what it was before compare
const _ts = Storage.loadToggleState();
_ts.mode = state._savedMode;
Storage.saveToggleState(_ts);
const _ab2 = document.getElementById('mode-agent-btn'), _cb2 = document.getElementById('mode-chat-btn');
if (_ab2 && _cb2) { _ab2.classList.toggle('active', state._savedMode === 'agent'); _cb2.classList.toggle('active', state._savedMode === 'chat'); }
document.querySelectorAll('[data-mode-tool]').forEach(b => { b.style.display = state._savedMode === 'agent' ? '' : 'none'; });
// Delete unsaved sessions, then reload
if (teardown) {
if (sessionIdsToDelete.length > 0) {
// keepalive ensures requests complete even during page navigation
await Promise.all(sessionIdsToDelete.map(sid =>
fetch(`${state.API_BASE}/api/session/${sid}`, { method: 'DELETE', keepalive: true }).catch(() => {})
));
}
location.href = location.pathname;
}
}
// ────────────────────────────────────────────────────────────────────────────
// ── _buildCompareUI ──
// ────────────────────────────────────────────────────────────────────────────
/** Build the compare UI: sessions, header bar, grid of panes, vote bar, eval dropdown. */
async function _buildCompareUI() {
if (state._selectedModels.length < 1) {
if (uiModule) uiModule.showError('Select at least 1 model');
return;
}
const n = state._selectedModels.length;
const modelShorts = _modelDisplayNames(state._selectedModels);
_persistSelections();
// 1. Create sessions (skip for search mode — no LLM sessions needed)
if (state._compareMode !== 'search') {
const sessionIds = [];
for (let i = 0; i < n; i++) {
const m = state._selectedModels[i];
const fd = new FormData();
fd.append('name', '[CMP] ' + modelShorts[i]);
fd.append('endpoint_url', m.endpoint || '');
fd.append('model', m.model || '');
if (m.endpointId) {
fd.append('endpoint_id', m.endpointId);
fd.append('skip_validation', 'true');
}
const res = await fetch(`${state.API_BASE}/api/session`, { method: 'POST', body: fd });
if (!res.ok) throw new Error('Failed to create session for ' + modelShorts[i]);
const data = await res.json();
sessionIds.push(data.id);
}
state._paneSessionIds = sessionIds;
} else {
state._paneSessionIds = [];
}
state._paneMetrics = state._selectedModels.map(() => null);
state._abortControllers = state._selectedModels.map(() => null);
// 2. Auto-collapse sidebar if many panes
if (n > 3) {
const sidebar = document.getElementById('sidebar');
if (sidebar && !sidebar.classList.contains('hidden')) {
sidebar.classList.add('hidden');
state._sidebarWasHidden = true;
const iconRail = document.getElementById('icon-rail');
if (iconRail) iconRail.classList.remove('rail-hidden');
if (typeof window.syncRailSide === 'function') window.syncRailSide();
}
}
// 3. Hide mobile new-chat button during compare
const _mobileNewBtn = document.getElementById('mobile-new-chat-btn');
if (_mobileNewBtn) {
_mobileNewBtn.dataset.cmpWasDisplay = _mobileNewBtn.style.display;
_mobileNewBtn.style.display = 'none';
}
// 4. Save toolbar indicator display states before hiding
const indicatorIds = ['overflow-tts-btn', 'overflow-attach-btn', 'overflow-rag-btn', 'overflow-research-btn', 'overflow-doc-btn', 'rag-indicator-btn', 'research-toggle-btn'];
state._savedIndicatorDisplay = {};
indicatorIds.forEach(id => {
const el = document.getElementById(id);
if (el) state._savedIndicatorDisplay[id] = el.style.display;
});
// 5. Save current mode and lock to the right one for this compare type
const _toggleState = Storage.loadToggleState();
state._savedMode = _toggleState.mode || 'chat';
const _targetMode = (state._compareMode === 'agent') ? 'agent' : 'chat';
_toggleState.mode = _targetMode;
Storage.saveToggleState(_toggleState);
const _ab = document.getElementById('mode-agent-btn'), _cb = document.getElementById('mode-chat-btn');
if (_ab && _cb) {
_ab.classList.toggle('active', _targetMode === 'agent');
_cb.classList.toggle('active', _targetMode === 'chat');
}
const _modeToggle = document.querySelector('.mode-toggle');
if (_modeToggle) { _modeToggle.style.pointerEvents = 'none'; _modeToggle.style.opacity = '0.4'; }
// 6. Force tool toggles per compare mode
disableToolToggles();
if (state._compareMode === 'search') {
const webChk = document.getElementById('web-toggle');
if (webChk && !webChk.checked) { webChk.checked = true; webChk.dispatchEvent(new Event('change')); }
const webBtn = document.getElementById('web-toggle-btn');
if (webBtn) webBtn.classList.add('active');
} else if (state._compareMode === 'research') {
const resChk = document.getElementById('research-toggle');
if (resChk && !resChk.checked) { resChk.checked = true; resChk.dispatchEvent(new Event('change')); }
const resBtn = document.getElementById('research-toggle-btn');
if (resBtn) { resBtn.style.display = ''; resBtn.classList.add('active'); }
}
// 7. Hide existing chat container children (preserves event listeners)
const container = document.getElementById('chat-container');
state._compareElements = [];
Array.from(container.children).forEach(child => {
if (child.style.display === 'none') return;
child.dataset.cmpHidden = '1';
child.style.display = 'none';
});
container.classList.add('compare-active');
// 8. Header bar
const cols = Math.min(n, 4);
const headerBar = document.createElement('div');
headerBar.className = 'compare-header-bar';
headerBar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:6px 10px;flex-shrink:0;';
const headerLabel = document.createElement('span');
headerLabel.style.cssText = 'font-size:10px;font-weight:400;color:var(--fg);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;';
const _modeLabel = ({ search: ' search providers', agent: ' agents', research: ' research models' }[state._compareMode] || ' models');
headerLabel.textContent = 'Comparing' + _modeLabel + (state._blindMode ? ' (blind)' : '') + ' · ' + state._timeout + 's timeout';
// Left side: the Compare tool icon (two side-by-side panes, matching the
// rail/sidebar icon) + the label. Other tool headers carry their icon; this
// one was missing it.
const headerLeft = document.createElement('div');
headerLeft.style.cssText = 'display:flex;align-items:center;min-width:0;';
const headerIcon = document.createElement('span');
headerIcon.style.cssText = 'display:inline-flex;flex-shrink:0;margin-right:6px;opacity:0.85;';
headerIcon.innerHTML = '';
headerLeft.appendChild(headerIcon);
headerLeft.appendChild(headerLabel);
headerBar.appendChild(headerLeft);
const headerActions = document.createElement('div');
headerActions.style.cssText = 'display:flex;align-items:center;gap:2px;';
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;';
const checkBtn = document.createElement('button');
checkBtn.id = 'compare-check-btn';
checkBtn.innerHTML = 'Probe';
checkBtn.title = 'Probe unverified models with a small test request';
checkBtn.style.cssText = _btnCSS;
checkBtn.addEventListener('click', () => _checkUnprobed());
headerActions.appendChild(checkBtn);
// Check button is dynamic: only visible when at least one selected model
// hasn't been probed yet. Show right after add/change, hide after success.
window._updateCheckBtnState = function() {
const btn = document.getElementById('compare-check-btn');
if (!btn) return;
const hasUnprobed = state._selectedModels.some(m => !state._probed.has(m.model));
btn.style.display = hasUnprobed ? '' : 'none';
};
// (Scoreboard button moved into the vote bar, next to Tie — see vote.js.)
const exportWrap = document.createElement('div');
exportWrap.style.cssText = 'position:relative;display:inline-flex;';
const exportBtn = document.createElement('button');
exportBtn.id = 'compare-export-btn';
exportBtn.innerHTML = 'Export';
exportBtn.title = 'Export options';
exportBtn.style.cssText = _btnCSS;
exportBtn.addEventListener('click', (e) => {
e.stopPropagation();
_toggleExportMenu(exportBtn);
});
exportWrap.appendChild(exportBtn);
headerActions.appendChild(exportWrap);
const shuffleBtn = document.createElement('button');
shuffleBtn.id = 'compare-shuffle-btn';
shuffleBtn.innerHTML = ICON_DICE + 'Shuffle';
shuffleBtn.title = 'Shuffle pane positions';
shuffleBtn.style.cssText = _btnCSS;
shuffleBtn.addEventListener('click', () => shufflePanePositions());
headerActions.appendChild(shuffleBtn);
const addBtn = document.createElement('button');
addBtn.id = 'compare-add-btn';
addBtn.innerHTML = 'Add';
addBtn.title = 'Add model pane';
addBtn.style.cssText = _btnCSS;
addBtn.addEventListener('click', () => _addPane(addBtn));
headerActions.appendChild(addBtn);
const closeBtn = document.createElement('button');
closeBtn.className = 'compare-close-btn';
closeBtn.innerHTML = '';
closeBtn.title = 'Close compare mode';
// Match Export/Score/Shuffle/Model styling so the X sits flush with
// the rest of the toolbar instead of being a 24×24 bordered square.
closeBtn.style.cssText = _btnCSS;
closeBtn.addEventListener('click', () => deactivate(true));
headerActions.appendChild(closeBtn);
// Move Export to the far left of the action cluster (per user preference).
headerActions.insertBefore(exportWrap, headerActions.firstChild);
headerBar.appendChild(headerActions);
container.appendChild(headerBar);
state._compareElements.push(headerBar);
// Initial visibility — hidden if all current models are already probed
window._updateCheckBtnState();
// 9. Grid of panes
const grid = document.createElement('div');
grid.className = 'compare-grid';
grid.dataset.cols = String(cols);
for (let i = 0; i < n; i++) {
const label = state._blindMode ? 'Model ' + _slotChar(i) : modelShorts[i];
const pane = document.createElement('div');
pane.className = 'compare-pane';
pane.dataset.pane = String(i);
pane.innerHTML =
'
' +
'' +
'' +
'' +
'
' +
'' +
'' +
'' +
'' +
'' +
'' +
'
' +
'
' +
'' +
'' +
'';
grid.appendChild(pane);
}
grid.addEventListener('click', (e) => {
const voteBtn = e.target.closest('.pane-vote-btn');
if (voteBtn) {
e.stopPropagation();
if (voteBtn.disabled) return;
const idx = parseInt(voteBtn.dataset.pane);
handleVote(idx);
return;
}
const actionBtn = e.target.closest('.pane-action-btn');
if (actionBtn) {
e.stopPropagation();
const action = actionBtn.dataset.action;
const idx = parseInt(actionBtn.dataset.pane);
if (action === 'stop') stopPane(idx);
else if (action === 'copy') copyPaneResponse(idx);
else if (action === 'reroll') rerollPane(idx);
else if (action === 'expand') toggleExpandPane(idx, actionBtn);
else if (action === 'preview') togglePanePreview(idx);
else if (action === 'close') _removePane(idx);
return;
}
const titleBtn = e.target.closest('.pane-title-btn');
if (titleBtn) {
e.stopPropagation();
const idx = parseInt(titleBtn.dataset.pane);
_showModelSwapDropdown(idx, titleBtn);
}
});
container.appendChild(grid);
state._compareElements.push(grid);
// 10. Vote bar placeholder
const voteBar = document.createElement('div');
voteBar.id = 'compare-vote-bar';
voteBar.className = 'compare-vote-bar';
container.appendChild(voteBar);
state._compareElements.push(voteBar);
buildVoteBar(n);
if (state._blindMode && n > 1) shufflePanePositions();
// 11. Move chat input bar to the bottom of the container
const inputBar = document.querySelector('.chat-input-bar');
if (inputBar) {
inputBar.style.display = '';
if (inputBar.dataset.cmpHidden) delete inputBar.dataset.cmpHidden;
container.appendChild(inputBar);
}
const msgTA = document.getElementById('message');
if (msgTA) {
msgTA.placeholder = 'Enter prompt for all models...';
requestAnimationFrame(() => msgTA.focus());
}
// Eval-prompts picker — sits inside the message box at top-right (where
// model-picker normally lives). Model-picker is irrelevant during compare,
// so hide it and restore on deactivate via the wrap's _cleanup.
_setupEvalPicker();
// 12. Hide tool buttons that don't apply during compare
['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 => {
const el = document.getElementById(id);
if (el) { el.style.display = 'none'; el.style.pointerEvents = 'none'; }
});
if (state._compareMode !== 'research') {
const resBtn = document.getElementById('research-toggle-btn');
if (resBtn) { resBtn.style.display = 'none'; resBtn.style.pointerEvents = 'none'; }
}
document.querySelectorAll('[data-mode-tool]').forEach(b => { b.style.display = 'none'; });
_setSendBtn('send');
}
// ────────────────────────────────────────────────────────────────────────────
// ── _setSendBtn ──
// ────────────────────────────────────────────────────────────────────────────
function _setSendBtn(mode) {
const btn = document.querySelector('.send-btn');
if (!btn) return;
if (mode === 'stop') {
btn.innerHTML = '';
btn.title = 'Stop all models';
btn.dataset.mode = 'streaming';
btn.classList.remove('mic-mode', 'newchat-mode');
} else {
btn.dataset.mode = '';
btn.innerHTML = SEND_SVG;
btn.style.color = '';
btn.title = 'Send to all models';
btn.classList.remove('mic-mode', 'newchat-mode', 'newchat-expanded');
}
}
// ────────────────────────────────────────────────────────────────────────────
// ── handleCompareSubmit ──
// ────────────────────────────────────────────────────────────────────────────
/**
* Handle submit from the main chat input while compare is active.
* Called by app.js submit guard.
*/
function handleCompareSubmit(e) {
// If streaming, act as stop button
if (state._streaming) {
stopAll();
return;
}
const input = document.getElementById('message');
const message = input ? input.value.trim() : '';
if (!message) return;
input.value = '';
// Reset textarea height
input.style.height = '';
// Notify input listeners (eval-picker visibility, autosize, etc.) that the
// textarea is empty again — programmatic clears don't fire `input` natively.
input.dispatchEvent(new Event('input', { bubbles: true }));
// Mobile: dismiss the on-screen keyboard after the prompt is sent so the
// user sees the streaming output instead of the typing area. A plain blur()
// is often ignored on Firefox mobile, so toggle readonly around it (and blur
// the active element too) to reliably collapse the keyboard.
// Mobile keyboard dismiss — use the SAME proven logic as the main chat send
// (chat.js handleChatSubmit). Compare returns early in that flow, so it never
// reached this code; replicating it here is what actually works on Firefox
// mobile (readonly + blur, then drop readonly only once the blur is confirmed
// or the user taps to type again — avoids the keyboard bouncing back up).
if (window.innerWidth <= 768) {
try {
input.setAttribute('readonly', 'readonly');
input.blur();
// Setting readonly on an ALREADY-FOCUSED textarea doesn't dismiss the
// keyboard on Firefox, and blur() is often ignored — so the readonly-only
// approach works only when the input happened not to be focused at send
// time (inconsistent between 1st/2nd prompt). Deterministically pull focus
// off the textarea by focusing a throwaway readonly input, then drop it.
const tmp = document.createElement('input');
tmp.setAttribute('readonly', 'readonly');
tmp.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;border:0;padding:0;';
document.body.appendChild(tmp);
tmp.focus();
setTimeout(() => { try { tmp.blur(); tmp.remove(); } catch {} }, 50);
const _dropReadonly = () => { try { input.removeAttribute('readonly'); } catch {} };
setTimeout(() => {
if (document.activeElement === input) {
input.addEventListener('pointerdown', _dropReadonly, { once: true });
input.addEventListener('focus', _dropReadonly, { once: true });
} else {
_dropReadonly();
}
}, 120);
} catch {}
}
_executeCompare(message);
}
// ────────────────────────────────────────────────────────────────────────────
// ── _executeCompare ──
// ────────────────────────────────────────────────────────────────────────────
/**
* Send prompt to all panes, stream responses.
* Works for both first and follow-up messages.
*/
async function _executeCompare(message) {
if (state._streaming) return;
if (state._selectedModels.length < 1) return;
// New round — allow voting again and clear the previous round's win/lose/tie
// styling (pane highlight + the Winner!/= title decorations), otherwise the
// old result stays stuck on the panes through the next prompt.
state._voted = false;
for (let i = 0; i < state._selectedModels.length; i++) {
const pane = document.querySelector('.compare-pane[data-pane="' + i + '"]');
if (pane) {
pane.classList.remove('winner', 'loser');
// Clear the previous round's Failed/Timeout badge and the eval ✓/✗ grade.
pane.querySelector('.pane-grade-badge')?.remove();
}
const fb = document.getElementById('cmp-badge-' + i);
if (fb) { fb.textContent = ''; fb.style.color = ''; }
const titleEl = document.getElementById('cmp-title-' + i);
if (titleEl) {
const label = state._blindMode
? 'Model ' + _slotChar(i)
: ((state._selectedModels[i] && state._selectedModels[i].name) || 'Model ' + _slotChar(i));
titleEl.innerHTML = escapeHtml(label) + ' ▾';
}
}
state._streaming = true;
state._lastPrompt = message;
_setSendBtn('stop');
// Disable header buttons during streaming
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach(b => {
b.disabled = true; b.style.opacity = '0.25'; b.style.pointerEvents = 'none';
});
// ── Search mode: direct API calls, no SSE streaming ──
if (state._compareMode === 'search') {
try {
const n = state._selectedModels.length;
// Clear previous vote buttons on follow-up
const voteBar = document.getElementById('compare-vote-bar');
if (voteBar) voteBar.innerHTML = '';
// Add user query + spinner to each pane
for (let i = 0; i < n; i++) {
const hist = document.getElementById('cmp-history-' + i);
if (!hist) continue;
const userMsg = document.createElement('div');
userMsg.className = 'msg msg-user';
userMsg.innerHTML = '