// 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?v=20260908compareprompts1';
import { fetchModels, _persistSelections, _modelDisplayNames, getExcludedModels, setExcludedModels } from './models.js';
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js?v=20260903compareprobe4';
import { _checkUnprobed, _clearProbeWaves } from './probe.js';
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js?v=20260908panestatspopup2';
import {
stopAll, stopPane, rerollPane, shufflePanePositions, resetCompare,
_addPane, _removePane, toggleExpandPane, togglePanePreview, copyPaneResponse,
_showModelSwapDropdown, _createAndAppendPane, _autoPreviewHtml,
mountMobilePaneTabs, syncShuffleButtonPlacement,
paneSettingsButtonHtml, togglePaneSettings,
registerPaneActions,
} from './panes.js?v=20260908compareheader1';
import { handleVote, buildVoteBar, addFinishBadge, spawnConfetti, _saveVote, registerCompareActions } from './vote.js?v=20260828resendcaldrag1';
import { showScoreboard } from './scoreboard.js?v=20260909voteconfirmalign1';
// ── External dependency imports ──
import Storage from '../storage.js';
import uiModule from '../ui.js?v=20260908weekhoverfix1';
import sessionModule from '../sessions.js';
import spinnerModule from '../spinner.js';
import themeModule from '../theme.js?v=20260909effectspeed1';
import presetsModule from '../presets.js?v=20260908personaname1';
import markdownModule from '../markdown.js';
import { bindMenuDismiss } from '../escMenuStack.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;
}
function _compareModeLabel() {
return ({ search: ' search providers', agent: ' agents', research: ' research models' }[state._compareMode] || ' models');
}
function _paneModeBadgeHtml(paneIdx) {
const mode = String(state._compareMode || 'chat');
if (mode !== 'search') return '';
const label = 'Search';
const detail = ({ agent: 'tools', search: 'web', research: 'sources' }[mode] || 'plain');
return '' +
'' +
'' + escapeHtml(label) + '' +
(mode === 'agent' ? '' : '' + escapeHtml(detail) + '') +
'';
}
function _setToolbarMode(mode, syncModeTools = !state.isActive) {
const target = mode === 'agent' ? 'agent' : 'chat';
const toggleState = Storage.loadToggleState();
toggleState.mode = target;
Storage.saveToggleState(toggleState);
const agentBtn = document.getElementById('mode-agent-btn');
const chatBtn = document.getElementById('mode-chat-btn');
const modeToggle = agentBtn?.closest('.mode-toggle') || chatBtn?.closest('.mode-toggle') || document.querySelector('.mode-toggle');
if (agentBtn && chatBtn) {
agentBtn.classList.toggle('active', target === 'agent');
chatBtn.classList.toggle('active', target === 'chat');
agentBtn.setAttribute('aria-pressed', target === 'agent' ? 'true' : 'false');
chatBtn.setAttribute('aria-pressed', target === 'chat' ? 'true' : 'false');
}
if (modeToggle) {
modeToggle.classList.toggle('mode-chat', target === 'chat');
modeToggle.classList.toggle('mode-right', target === 'chat');
}
if (syncModeTools) {
document.querySelectorAll('[data-mode-tool]').forEach(b => { b.style.display = target === 'agent' ? '' : 'none'; });
}
}
function _syncCompareModeFromToolbar(mode) {
if (!state.isActive) return;
state._compareMode = mode === 'agent' ? 'agent' : 'chat';
_setToolbarMode(state._compareMode, false);
const headerLabel = document.querySelector('.compare-header-label');
if (headerLabel) {
headerLabel.textContent = 'Comparing' + _compareModeLabel() + (state._blindMode ? ' (blind)' : '') + ' · ' + state._timeout + 's timeout';
}
document.querySelectorAll('.compare-pane .pane-mode-badge').forEach((badge) => {
const template = document.createElement('template');
const paneIdx = Number(badge.closest('.compare-pane')?.dataset.pane || 0);
template.innerHTML = _paneModeBadgeHtml(paneIdx);
const replacement = template.content.firstElementChild;
if (replacement) badge.replaceWith(replacement);
else badge.remove();
});
const evalWrap = document.getElementById('cmp-eval-wrap');
if (evalWrap && typeof evalWrap._renderItems === 'function') evalWrap._renderItems();
}
function _showPaneStatsPopup(summary) {
document.querySelectorAll('.pane-stats-popup').forEach((el) => el._dismiss ? el._dismiss() : el.remove());
const values = String(summary.textContent || '').split(' · ').filter(Boolean);
if (!values.length) return;
const popup = document.createElement('div');
popup.className = 'pane-stats-popup';
popup.setAttribute('role', 'dialog');
popup.setAttribute('aria-label', 'Response statistics');
popup.innerHTML = '
' + values.map((value) => {
let label = 'Value';
let display = value;
if (value.startsWith('TTFT ')) { label = 'First token'; display = value.slice(5); }
else if (value.endsWith(' tok')) { label = 'Output'; display = value.replace(/ tok$/, ' tokens'); }
else if (value.endsWith('/s')) label = 'Speed';
else if (value.endsWith('% ctx')) { label = 'Context'; display = value.replace(/ ctx$/, ''); }
else if (value.startsWith('$')) label = 'Cost';
return '';
}).join('');
document.body.appendChild(popup);
const rect = summary.getBoundingClientRect();
const popupRect = popup.getBoundingClientRect();
popup.style.left = Math.max(8, Math.min(rect.left, window.innerWidth - popupRect.width - 8)) + 'px';
popup.style.top = (rect.bottom + popupRect.height + 6 <= window.innerHeight
? rect.bottom + 6
: Math.max(8, rect.top - popupRect.height - 6)) + 'px';
summary.setAttribute('aria-expanded', 'true');
bindMenuDismiss(popup, () => {
popup.remove();
summary.setAttribute('aria-expanded', 'false');
}, (event) => !popup.contains(event.target) && event.target !== summary);
}
// ────────────────────────────────────────────────────────────────────────────
// ── 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
_setToolbarMode(state._savedMode, true);
// 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();
// Blind mode: name the session by its neutral slot so the sidebar /
// GET /api/sessions can't de-anonymize the comparison (issue #1285).
fd.append('name', '[CMP] ' + (state._blindMode ? 'Model ' + _slotChar(i) : 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;
state._paneGenerationSettings = sessionIds.map(() => ({
thinking_mode: '', temperature_override: null, max_tokens_override: null,
}));
} else {
state._paneSessionIds = [];
state._paneGenerationSettings = [];
}
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 seed the toolbar for this compare type.
const _toggleState = Storage.loadToggleState();
state._savedMode = _toggleState.mode || 'chat';
const _targetMode = (state._compareMode === 'agent') ? 'agent' : 'chat';
_setToolbarMode(_targetMode, false);
const _ab = document.getElementById('mode-agent-btn'), _cb = document.getElementById('mode-chat-btn');
let _modeCleanup = null;
const _onCompareModeClick = (ev) => {
ev.stopPropagation();
ev.stopImmediatePropagation();
_syncCompareModeFromToolbar(ev.currentTarget === _ab ? 'agent' : 'chat');
};
if (_ab && _cb) {
_ab.addEventListener('click', _onCompareModeClick, true);
_cb.addEventListener('click', _onCompareModeClick, true);
_modeCleanup = document.createElement('span');
_modeCleanup.style.display = 'none';
_modeCleanup._cleanup = () => {
_ab.removeEventListener('click', _onCompareModeClick, true);
_cb.removeEventListener('click', _onCompareModeClick, true);
};
}
const _modeToggle = document.querySelector('.mode-toggle');
if (_modeToggle) { _modeToggle.style.pointerEvents = ''; _modeToggle.style.opacity = ''; }
// 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 = [];
if (_modeCleanup) state._compareElements.push(_modeCleanup);
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.className = 'compare-header-label';
headerLabel.style.cssText = 'font-size:10px;font-weight:400;color:var(--fg);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;';
headerLabel.textContent = 'Comparing' + _compareModeLabel() + (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());
// 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.className = 'compare-export-wrap';
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);
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();
syncShuffleButtonPlacement(false);
});
const moreWrap = document.createElement('div');
moreWrap.className = 'compare-more-wrap';
const moreBtn = document.createElement('button');
moreBtn.className = 'compare-more-btn';
moreBtn.type = 'button';
moreBtn.title = 'Compare actions';
moreBtn.setAttribute('aria-label', 'Compare actions');
moreBtn.innerHTML = '';
const moreMenu = document.createElement('div');
moreMenu.className = 'compare-more-menu';
moreMenu.append(exportWrap, shuffleBtn, checkBtn);
moreBtn.addEventListener('click', (e) => {
e.stopPropagation();
moreMenu.classList.toggle('is-open');
moreBtn.setAttribute('aria-expanded', moreMenu.classList.contains('is-open') ? 'true' : 'false');
});
document.addEventListener('click', (e) => {
if (!moreWrap.contains(e.target)) {
moreMenu.classList.remove('is-open');
moreBtn.setAttribute('aria-expanded', 'false');
}
}, true);
moreWrap.append(moreBtn, moreMenu);
headerActions.appendChild(moreWrap);
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));
addBtn.className = 'compare-add-flap';
addBtn.setAttribute('aria-label', 'Add model pane');
const addMenuBtn = document.createElement('button');
addMenuBtn.id = 'compare-add-menu-btn';
addMenuBtn.type = 'button';
addMenuBtn.innerHTML = 'Add model';
addMenuBtn.title = 'Add model pane';
addMenuBtn.addEventListener('click', () => _addPane(addMenuBtn));
moreMenu.append(addMenuBtn, shuffleBtn, checkBtn, exportWrap);
const closeBtn = document.createElement('button');
closeBtn.className = 'close-btn compare-close-btn';
closeBtn.innerHTML = '';
closeBtn.title = 'Close compare mode';
closeBtn.addEventListener('click', () => deactivate(true));
headerActions.appendChild(closeBtn);
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 summary = e.target.closest('.pane-summary');
if (summary && summary.textContent.trim()) {
_showPaneStatsPopup(summary);
return;
}
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('[data-action]');
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 === 'settings') togglePaneSettings(idx, actionBtn);
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);
}
});
grid.addEventListener('keydown', (e) => {
const summary = e.target.closest('.pane-summary');
if (summary && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
_showPaneStatsPopup(summary);
}
});
container.appendChild(grid);
grid.appendChild(addBtn);
const mobileTabs = mountMobilePaneTabs(container, grid, (anchor) => _addPane(anchor));
state._compareElements.push(mobileTabs);
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 = window.matchMedia('(max-width: 767px)').matches ? '' : 'Enter prompt for all models...';
if (window.innerWidth > 768) 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 = 'You
';
userMsg.querySelector('.body').textContent = message;
hist.appendChild(userMsg);
const aiMsg = document.createElement('div');
aiMsg.className = 'msg msg-ai';
aiMsg.innerHTML = 'Search
';
const aiBody = aiMsg.querySelector('.body');
if (spinnerModule) {
const spinner = spinnerModule.create('Searching...', 'right');
aiBody.appendChild(spinner.createElement());
spinner.start();
}
hist.appendChild(aiMsg);
hist.scrollTop = hist.scrollHeight;
}
// Fire searches — parallel or sequential based on _parallel setting
const t0 = performance.now();
state._abortControllers = state._selectedModels.map(() => new AbortController());
async function _searchOne(m, i) {
const fd = new FormData();
fd.append('query', message);
fd.append('provider', m.model);
fd.append('count', '10');
try {
const res = await fetch(`${state.API_BASE}/api/search/query`, { method: 'POST', body: fd, signal: state._abortControllers[i].signal });
const data = await res.json();
return { idx: i, data };
} catch (err) {
return { idx: i, data: { results: [], error: err.name === 'AbortError' ? 'Stopped' : err.message } };
}
}
let results;
const _seqSynthDone = new Set();
if (state._parallel) {
results = await Promise.all(state._selectedModels.map((m, i) => _searchOne(m, i)));
} else {
// Sequential — run one at a time, dim waiting panes
results = [];
const panes = document.querySelectorAll('.compare-pane');
panes.forEach((p, i) => { if (i > 0) p.style.opacity = '0.4'; });
for (let i = 0; i < state._selectedModels.length; i++) {
const pane = panes[i];
if (pane) pane.style.opacity = '1';
results.push(await _searchOne(state._selectedModels[i], i));
// Render this result immediately
const { idx, data } = results[results.length - 1];
const hist = document.getElementById('cmp-history-' + idx);
if (hist) {
const aiMsg = hist.querySelector('.msg-ai:last-child');
if (aiMsg) {
const aiBody = aiMsg.querySelector('.body');
aiBody.innerHTML = '';
if (data.error) {
aiBody.innerHTML = 'Error: ' + escapeHtml(data.error) + '
';
} else if (!data.results || data.results.length === 0) {
aiBody.innerHTML = 'No results found
';
} else {
aiBody.appendChild(_renderSearchResults(data));
}
const footer = document.createElement('div'); footer.className = 'msg-footer';
const span = document.createElement('span'); span.className = 'response-metrics';
const parts = [];
if (data.results) parts.push(data.results.length + ' results');
if (data.time) parts.push(data.time + 's');
span.textContent = parts.join(' | '); footer.appendChild(span); aiMsg.appendChild(footer);
hist.scrollTop = hist.scrollHeight;
const _pe = document.querySelector(`.compare-pane[data-pane="${idx}"]`);
if (_pe) _pe.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
}
}
// Sequential: run synthesis for this pane immediately before moving to next
_seqSynthDone.add(idx);
if (!data.error && data.results && data.results.length > 0) {
const modelToUse = state._searchSynthModels?.[idx] || null;
if (modelToUse) {
const seqHist = document.getElementById('cmp-history-' + idx);
if (seqHist) {
const synthMsg = document.createElement('div');
synthMsg.className = 'msg msg-ai';
synthMsg.innerHTML = 'Analysis
';
const synthBody = synthMsg.querySelector('.body');
let spinner = null;
if (spinnerModule) { spinner = spinnerModule.create('Analyzing...', 'right'); synthBody.appendChild(spinner.createElement()); spinner.start(); }
seqHist.appendChild(synthMsg);
seqHist.scrollTop = seqHist.scrollHeight;
const resultsText = data.results.map((r, ri) => `[${ri + 1}] ${r.title}\n${r.snippet || ''}\nURL: ${r.url}`).join('\n\n');
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}`;
await _runSynthForPane(modelToUse, synthPrompt, synthBody, spinner, seqHist);
}
}
}
}
// Reset opacity
panes.forEach(p => { p.style.opacity = ''; });
}
// Render results into each pane
for (const { idx, data } of results) {
const hist = document.getElementById('cmp-history-' + idx);
if (!hist) continue;
const aiMsg = hist.querySelector('.msg-ai:last-child');
if (!aiMsg) continue;
const aiBody = aiMsg.querySelector('.body');
aiBody.innerHTML = '';
if (data.error) {
aiBody.innerHTML = 'Error: ' + escapeHtml(data.error) + '
';
} else if (!data.results || data.results.length === 0) {
aiBody.innerHTML = 'No results found
';
} else {
aiBody.appendChild(_renderSearchResults(data));
}
// Footer metrics
const footer = document.createElement('div');
footer.className = 'msg-footer';
const span = document.createElement('span');
span.className = 'response-metrics';
const parts = [];
if (data.results) parts.push(data.results.length + ' results');
if (data.time) parts.push(data.time + 's');
span.textContent = parts.join(' | ');
footer.appendChild(span);
aiMsg.appendChild(footer);
hist.scrollTop = hist.scrollHeight;
// Show reroll/copy buttons for search results
const _paneEl = document.querySelector(`.compare-pane[data-pane="${idx}"]`);
if (_paneEl) _paneEl.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
}
// ── Synthesis: send results to LLM for analysis (respects _parallel setting) ──
if (state._searchSynthModels) {
// Build list of synthesis tasks
const synthTasks = [];
for (let i = 0; i < results.length; i++) {
const { idx, data } = results[i];
// Skip panes already synthesized in sequential mode
if (_seqSynthDone.has(idx)) continue;
if (data.error || !data.results || data.results.length === 0) continue;
const modelToUse = state._searchSynthModels?.[idx] || null;
if (!modelToUse) continue;
const hist = document.getElementById('cmp-history-' + idx);
if (!hist) continue;
// Add synthesis message with spinner
const synthMsg = document.createElement('div');
synthMsg.className = 'msg msg-ai';
synthMsg.innerHTML = 'Analysis
';
const synthBody = synthMsg.querySelector('.body');
let spinner = null;
if (spinnerModule) {
spinner = spinnerModule.create('Analyzing...', 'right');
synthBody.appendChild(spinner.createElement());
spinner.start();
}
hist.appendChild(synthMsg);
// Auto-scroll to show the Analysis message
hist.scrollTop = hist.scrollHeight;
// Build synthesis prompt
const resultsText = data.results.map((r, ri) =>
`[${ri + 1}] ${r.title}\n${r.snippet || ''}\nURL: ${r.url}`
).join('\n\n');
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}`;
synthTasks.push({ idx, modelToUse, synthBody, synthMsg, spinner, hist, synthPrompt });
}
// Run synthesis streams (parallel or sequential based on _parallel flag)
const runSynthesis = async (task) => _runSynthForPane(task.modelToUse, task.synthPrompt, task.synthBody, task.spinner, task.hist);
if (state._parallel) {
await Promise.all(synthTasks.map(runSynthesis));
} else {
for (const task of synthTasks) {
await runSynthesis(task);
}
}
}
buildVoteBar(n);
} catch (err) {
console.error('Search compare error:', err);
if (uiModule) uiModule.showError('Search compare failed: ' + err.message);
} finally {
state._streaming = false;
_setSendBtn('send');
}
return;
}
// ── Chat / Image mode ──
const isFollowUp = document.getElementById('cmp-history-0')?.querySelector('.msg-ai');
try {
const n = state._selectedModels.length;
if (isFollowUp) {
const voteBar = document.getElementById('compare-vote-bar');
if (voteBar) {
voteBar.innerHTML = '';
voteBar.classList.add('hidden');
}
}
// ── Add user + AI bubbles to each pane ──
const aiElements = [];
for (let i = 0; i < n; i++) {
const hist = document.getElementById('cmp-history-' + i);
if (!hist) { aiElements.push(null); continue; }
const userMsg = document.createElement('div');
userMsg.className = 'msg msg-user';
userMsg.innerHTML = 'You
';
userMsg.querySelector('.body').textContent = message;
hist.appendChild(userMsg);
const aiMsg = document.createElement('div');
aiMsg.className = 'msg msg-ai';
aiMsg.innerHTML = 'AI
';
const aiBody = aiMsg.querySelector('.body');
if (spinnerModule) {
// In sequential mode, only first pane says "Processing", rest say "Waiting"
const label = (!state._parallel && i > 0)
? 'Waiting for Model ' + _slotChar(i - 1) + '...'
: 'Processing...';
const spinner = spinnerModule.create(label, 'right');
aiBody.appendChild(spinner.createElement());
spinner.start();
aiMsg._spinner = spinner;
}
hist.appendChild(aiMsg);
hist.scrollTop = hist.scrollHeight;
aiElements.push(aiMsg);
}
// ── Auto-extend timeout ──
const researchChk = document.getElementById('research-toggle');
const webChkT = document.getElementById('web-toggle');
const noTimeLimit = state._compareMode === 'research' || (researchChk && researchChk.checked);
const needsLongTimeout = state._compareMode === 'agent' || (webChkT && webChkT.checked);
const runTimeout = noTimeLimit ? 999999 : needsLongTimeout ? Math.max(state._timeout, 300) : state._timeout;
// ── Pre-search if web toggle is on (share same results across all panes) ──
let sharedSearchContext = null;
let sharedSearchSources = null;
const webChk = document.getElementById('web-toggle');
const isAgentMode = state._compareMode === 'agent';
const webOn = webChk && webChk.checked;
// In agent mode, web_search is a tool (handled per-pane); in chat mode, pre-search and share
if (webOn && !isAgentMode) {
try {
const fd = new FormData();
fd.append('query', message);
const searchRes = await fetch(`${state.API_BASE}/api/search`, { method: 'POST', body: fd });
if (searchRes.ok) {
const searchData = await searchRes.json();
if (searchData.context) sharedSearchContext = searchData.context;
if (searchData.sources) sharedSearchSources = searchData.sources;
}
} catch (err) {
console.warn('Compare pre-search failed, panes will search individually:', err);
}
}
// ── Show vote bar immediately so user can vote anytime ──
buildVoteBar(n);
// ── Stream all panes (parallel or sequential based on _parallel flag) ──
state._finishOrder = 0;
state._paneElapsed = new Array(n).fill(null);
state._paneMetrics = new Array(n).fill(null);
state._abortControllers = new Array(n).fill(null);
if (state._parallel) {
// Run all panes at once
await Promise.all(state._paneSessionIds.map((sid, i) =>
streamToPane(i, sid, message, aiElements[i], { searchContext: sharedSearchContext, timeout: runTimeout })
));
} else {
// Run one pane at a time (sequential) — active pane full opacity, others dimmed
const allPanes = document.querySelectorAll('.compare-pane');
allPanes.forEach(p => { p.style.transition = 'opacity 0.4s ease'; });
// Dim all except first
allPanes.forEach((p, idx) => { p.style.opacity = idx === 0 ? '1' : '0.35'; });
for (let i = 0; i < state._paneSessionIds.length; i++) {
// Update spinner
if (aiElements[i] && aiElements[i]._spinner) {
aiElements[i]._spinner.updateLabel('Processing...');
}
await streamToPane(i, state._paneSessionIds[i], message, aiElements[i], { searchContext: sharedSearchContext, timeout: runTimeout });
// Swap opacity: dim current, brighten next
if (allPanes[i]) allPanes[i].style.opacity = '0.35';
if (i + 1 < allPanes.length && allPanes[i + 1]) {
allPanes[i + 1].style.opacity = '1';
}
}
// Restore all pane opacities when done
allPanes.forEach(p => { p.style.opacity = ''; p.style.transition = ''; });
}
// Re-focus main input for follow-up
if (state._continueChat) {
const ta = document.getElementById('message');
if (ta) ta.focus();
}
} catch (err) {
console.error('Compare error:', err);
if (uiModule) uiModule.showError('Compare failed: ' + err.message);
} finally {
// A pane may have started its own ask_user/approval continuation while the
// original all-pane Promise was settling. Keep Compare busy until every
// pane-owned controller is gone instead of exposing a second broadcast send.
const compareStillStreaming = state._abortControllers.some(Boolean);
state._streaming = compareStillStreaming;
_setSendBtn(compareStillStreaming ? 'stop' : 'send');
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach((button) => {
button.disabled = compareStillStreaming;
button.style.opacity = compareStillStreaming ? '0.25' : '0.7';
button.style.pointerEvents = compareStillStreaming ? 'none' : '';
});
}
}
// showModelSelector imported from ./selector.js
// ────────────────────────────────────────────────────────────────────────────
// ── cleanupResults / removeOverlays ──
// ────────────────────────────────────────────────────────────────────────────
/**
* Build a markdown comparison of the current panes (prompt + each model's
* response + metrics + grade) and copy it to the clipboard. Lets users save
* or share a side-by-side at a glance.
*/
// Build the comparison markdown string. Shared by all export paths.
function _buildComparisonMarkdown() {
const grid = document.querySelector('.compare-grid');
if (!grid) return null;
const panes = grid.querySelectorAll('.compare-pane');
if (!panes.length) return null;
const prompt = state._lastPrompt || '(no prompt yet — run a comparison first)';
const expected = state._expectedAnswer || '';
const date = new Date().toISOString().slice(0, 19).replace('T', ' ');
let md = '# Compare\n\n';
md += '**When:** ' + date + '\n';
md += '**Type:** ' + (state._compareMode || 'chat') + (state._blindMode ? ' (blind)' : '') + '\n';
md += '**Prompt:**\n\n```\n' + prompt + '\n```\n\n';
if (expected) md += '**Expected answer:** `' + expected + '`\n\n';
panes.forEach((pane, i) => {
const m = state._selectedModels[i];
const name = m ? (m.name || m.model) + (m.endpointName ? ' (' + m.endpointName + ')' : '') : 'Model ' + (i + 1);
const body = pane.querySelector('.compare-text-content, .msg-body, .body');
const text = body ? (body.innerText || body.textContent || '').trim() : '';
const metrics = state._paneMetrics[i];
const grade = pane.querySelector('.pane-grade-badge');
const gradeMark = grade ? (grade.classList.contains('pass') ? ' ✓' : ' ✗') : '';
md += '## ' + name + gradeMark + '\n\n';
if (metrics) {
const bits = [];
if (metrics.output_tokens != null) bits.push(metrics.output_tokens + ' tokens');
if (metrics.tokens_per_second != null) bits.push(metrics.tokens_per_second + ' tok/s');
if (metrics.response_time != null) bits.push(metrics.response_time + 's');
if (bits.length) md += '_' + bits.join(' · ') + '_\n\n';
}
md += text ? text + '\n\n' : '_(no response)_\n\n';
md += '---\n\n';
});
return md;
}
let _exportMenuEl = null;
let _closeExportMenu = () => {};
function _toggleExportMenu(btn) {
if (_exportMenuEl) { _closeExportMenu(); return; }
const r = btn.getBoundingClientRect();
const m = document.createElement('div');
m.className = 'compare-export-menu';
const inCompareMenu = !!btn.closest('.compare-more-menu');
const menuLeft = inCompareMenu ? r.right + 6 : r.left;
const menuTop = inCompareMenu ? r.top : r.bottom + 4;
m.style.cssText = 'position:fixed;z-index:10001;top:' + menuTop + 'px;left:' + menuLeft + 'px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3),0 0 0 1px color-mix(in srgb,var(--fg) 5%,transparent);padding:6px;font-size:12px;display:flex;flex-direction:column;gap:2px;min-width:170px;backdrop-filter:blur(12px);';
const opts = [
{ label: 'Copy as Markdown', icon: '', fn: () => _exportCopyMarkdown(btn) },
{ label: 'Download .md', icon: '', fn: () => _exportDownloadMarkdown() },
{ label: 'Print / Save PDF', icon: '', fn: () => _exportPrint() },
];
for (const o of opts) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'compare-export-item';
item.innerHTML = o.icon + '' + o.label + '';
item.style.cssText = 'background:none;border:1px solid transparent;color:var(--fg);text-align:left;padding:8px 10px;border-radius:6px;cursor:pointer;font:inherit;font-size:11px;line-height:1.3;';
item.addEventListener('click', () => { _closeExportMenu(); o.fn(); });
m.appendChild(item);
}
document.body.appendChild(m);
const mr = m.getBoundingClientRect();
if (mr.right > window.innerWidth - 8) m.style.left = Math.max(8, r.left - mr.width - 6) + 'px';
if (mr.bottom > window.innerHeight - 8) m.style.top = Math.max(8, window.innerHeight - mr.height - 8) + 'px';
_exportMenuEl = m;
_closeExportMenu = bindMenuDismiss(m, () => {
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
}, (ev) => !m.contains(ev.target));
}
async function _exportCopyMarkdown(_btn) {
const md = _buildComparisonMarkdown();
if (!md) return;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(md);
} else {
// Avoid the focus-stealing textarea fallback when the modern API
// is available — that path briefly flashes the page as the
// textarea is added/focused/removed.
const ta = document.createElement('textarea');
ta.value = md;
ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0;';
document.body.appendChild(ta);
ta.select(); document.execCommand('copy'); ta.remove();
}
try { window.uiModule?.showToast?.('Copied comparison to clipboard'); } catch {}
} catch (e) {
try { window.uiModule?.showToast?.('Copy failed'); } catch {}
}
}
function _exportDownloadMarkdown() {
const md = _buildComparisonMarkdown();
if (!md) return;
const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
const blob = new Blob([md], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'compare-' + ts + '.md';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function _exportPrint() {
const md = _buildComparisonMarkdown();
if (!md) return;
// Render the markdown as a quick HTML view in a new window and trigger
// the system print dialog — user can pick "Save as PDF" from there.
const w = window.open('', '_blank');
if (!w) return;
try { w.opener = null; } catch (_) {}
const escape = (s) => s.replace(/&/g, '&').replace(//g, '>');
const html = 'Compare export' +
'' + escape(md) + '
' +
'