Squash Odysseus development history

This commit is contained in:
pewdiepie-archdaemon
2026-09-11 06:04:19 +00:00
parent e5c99a5eee
commit 6ee6502010
2050 changed files with 538359 additions and 57745 deletions
+450 -78
View File
@@ -1,15 +1,16 @@
// compare/panes.js — pane lifecycle, actions, layout
import state from './state.js';
import { _persistSelections } from './models.js';
import { buildVoteBar } from './vote.js';
import { buildVoteBar } from './vote.js?v=20260828resendcaldrag1';
import {
ICON_REROLL, ICON_COPY, ICON_EXPAND, ICON_COLLAPSE, ICON_CLOSE,
ICON_PLAY, ICON_CODE, SEND_SVG,
} from './icons.js';
ICON_PLAY, ICON_CODE, SEND_SVG, ICON_DICE,
} from './icons.js?v=20260908compareprompts1';
import { _clearProbeWaves } from './probe.js';
import Storage from '../storage.js';
import uiModule from '../ui.js';
import uiModule from '../ui.js?v=20260908weekhoverfix1';
import spinnerModule from '../spinner.js';
import { bindMenuDismiss } from '../escMenuStack.js';
var escapeHtml = uiModule.esc;
@@ -32,6 +33,342 @@ function registerPaneActions({ setSendBtn, deactivate, streamToPane, renderSearc
/** Slot label: A/B/C in parallel mode, 1/2/3 in sequential. */
function _slotChar(i) { return state._parallel ? String.fromCharCode(65 + i) : String(i + 1); }
/** Keep Shuffle compact until a larger comparison makes it useful as a
* direct action. It returns to the kebab after being used. */
function syncShuffleButtonPlacement(showDirect = state._selectedModels.length > 2) {
const shuffleBtn = document.getElementById('compare-shuffle-btn');
const moreMenu = document.querySelector('.compare-more-menu');
const moreWrap = document.querySelector('.compare-more-wrap');
const headerActions = moreWrap?.parentElement;
if (!shuffleBtn || !moreMenu || !moreWrap || !headerActions) return;
if (showDirect) {
if (shuffleBtn.parentElement !== headerActions) headerActions.insertBefore(shuffleBtn, moreWrap);
} else if (shuffleBtn.parentElement !== moreMenu) {
moreMenu.appendChild(shuffleBtn);
}
}
function _paneModeBadgeHtml(paneIdx) {
const mode = String(state._compareMode || 'chat');
if (mode !== 'search') return '';
const label = ({ agent: 'Agent', search: 'Search', research: 'Research' }[mode] || 'Chat');
const detail = ({ agent: 'tools', search: 'web', research: 'sources' }[mode] || 'plain');
return '<span class="pane-mode-badge pane-mode-' + escapeHtml(mode) + '" title="' + escapeHtml(label + ' mode') + '">' +
'<span class="pane-mode-dot" aria-hidden="true"></span>' +
'<span class="pane-mode-label">' + escapeHtml(label) + '</span>' +
(mode === 'agent' ? '' : '<span class="pane-mode-detail">' + escapeHtml(detail) + '</span>') +
'</span>';
}
const PANE_SETTINGS_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06A1.65 1.65 0 0 0 8.92 4a1.65 1.65 0 0 0 1-1.51V2a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg>';
function paneSettingsButtonHtml(paneIdx) {
return '<button type="button" class="pane-action-btn pane-settings-btn" data-action="settings" data-pane="' + Number(paneIdx) + '" title="Inference settings" aria-label="Inference settings" aria-haspopup="dialog" aria-expanded="false"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></button>';
}
function _paneModelRequiresThinking(paneIdx) {
const modelId = String(state._selectedModels[paneIdx]?.model || '').toLowerCase().split(':', 1)[0];
return modelId === 'x-ai/grok-4.5' || modelId === 'grok-4.5';
}
async function _savePaneGenerationSettings(paneIdx, change) {
const sid = state._paneSessionIds[paneIdx];
if (!sid) return false;
const current = state._paneGenerationSettings[paneIdx] || {};
const next = {
thinking_mode: current.thinking_mode || '',
temperature_override: current.temperature_override ?? null,
max_tokens_override: current.max_tokens_override ?? null,
...change,
};
try {
const res = await fetch(`${state.API_BASE}/api/session/${encodeURIComponent(sid)}/generation-settings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(next),
});
if (!res.ok) throw new Error(await res.text());
state._paneGenerationSettings[paneIdx] = { ...next, ...await res.json() };
return true;
} catch (err) {
uiModule.showError(`Could not save pane settings: ${err.message || err}`);
return false;
}
}
function togglePaneSettings(paneIdx, anchorBtn) {
const existing = document.querySelector('.compare-pane-settings-popup');
if (existing) {
const same = existing.dataset.pane === String(paneIdx);
if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove();
if (same) return;
}
const settings = state._paneGenerationSettings[paneIdx] || {
thinking_mode: '', temperature_override: null, max_tokens_override: null,
};
const popup = document.createElement('div');
popup.className = 'chat-context-popup compare-pane-settings-popup';
popup.dataset.pane = String(paneIdx);
popup.setAttribute('role', 'dialog');
popup.setAttribute('aria-label', 'Inference settings');
popup.innerHTML = '<div class="chat-context-popup-title">Inference settings</div>';
const thinkingRequired = _paneModelRequiresThinking(paneIdx);
const thinkingOn = thinkingRequired || settings.thinking_mode === 'on';
const thinkingRow = document.createElement('div');
thinkingRow.className = 'chat-context-toggle-row';
thinkingRow.innerHTML = '<div class="chat-context-toggle-copy"><span>Thinking</span><span class="chat-context-toggle-state">' + (thinkingRequired ? 'Required' : (thinkingOn ? 'On' : 'Off')) + '</span></div>';
const thinkingToggle = document.createElement('button');
thinkingToggle.type = 'button';
thinkingToggle.className = `chat-context-toggle${thinkingOn ? ' active' : ''}`;
thinkingToggle.setAttribute('role', 'switch');
thinkingToggle.setAttribute('aria-checked', thinkingOn ? 'true' : 'false');
thinkingToggle.disabled = thinkingRequired;
if (thinkingRequired) thinkingToggle.title = 'This model requires reasoning';
thinkingToggle.addEventListener('click', async () => {
if (thinkingRequired) return;
const next = !thinkingToggle.classList.contains('active');
thinkingToggle.disabled = true;
if (await _savePaneGenerationSettings(paneIdx, { thinking_mode: next ? 'on' : 'off' })) {
thinkingToggle.classList.toggle('active', next);
thinkingToggle.setAttribute('aria-checked', next ? 'true' : 'false');
thinkingRow.querySelector('.chat-context-toggle-state').textContent = next ? 'On' : 'Off';
}
thinkingToggle.disabled = false;
});
thinkingRow.appendChild(thinkingToggle);
popup.appendChild(thinkingRow);
const addSlider = (label, value, min, max, step, formatter, key, normalize) => {
const row = document.createElement('div');
row.className = 'chat-context-threshold-row';
row.innerHTML = '<div class="chat-context-threshold-top"><span>' + label + '</span><span>' + formatter(value) + '</span></div>';
const input = document.createElement('input');
Object.assign(input, { type: 'range', min: String(min), max: String(max), step: String(step), value: String(value), className: 'chat-context-threshold-slider preset-range' });
input.addEventListener('input', () => { row.querySelector('.chat-context-threshold-top span:last-child').textContent = formatter(Number(input.value)); });
input.addEventListener('change', async () => {
input.disabled = true;
await _savePaneGenerationSettings(paneIdx, { [key]: normalize(Number(input.value)) });
input.disabled = false;
});
row.appendChild(input);
popup.appendChild(row);
};
addSlider('Temperature', settings.temperature_override ?? 1, 0, 2, 0.1, value => Number(value).toFixed(1), 'temperature_override', value => value);
addSlider('Max tokens', settings.max_tokens_override ?? 8448, 256, 8448, 256, value => value > 8192 ? 'No limit' : Number(value).toLocaleString(), 'max_tokens_override', value => value > 8192 ? null : value);
document.body.appendChild(popup);
const rect = anchorBtn.getBoundingClientRect();
const margin = 8;
const popupRect = popup.getBoundingClientRect();
popup.style.left = Math.max(margin, Math.min(rect.left, window.innerWidth - popupRect.width - margin)) + 'px';
popup.style.top = Math.max(margin, Math.min(rect.bottom + 5, window.innerHeight - popupRect.height - margin)) + 'px';
anchorBtn.setAttribute('aria-expanded', 'true');
bindMenuDismiss(popup, () => {
popup.remove();
anchorBtn.setAttribute('aria-expanded', 'false');
}, event => !popup.contains(event.target) && event.target !== anchorBtn);
}
function _isMobileCompare() {
return window.matchMedia('(max-width: 768px)').matches;
}
function _mobilePaneLabel(index) {
if (state._blindMode) return 'Model ' + _slotChar(index);
return state._selectedModels[index]?.name || 'Model ' + (index + 1);
}
/** Keep the phone tab strip and its single visible pane in sync. */
function refreshMobilePaneTabs(preferredIndex = state._activeMobilePane) {
const tabs = document.querySelector('.compare-mobile-tabs');
const grid = document.querySelector('.compare-grid');
if (!tabs || !grid) return;
const panes = Array.from(grid.querySelectorAll(':scope > .compare-pane'));
if (!panes.length) {
tabs.replaceChildren();
return;
}
state._activeMobilePane = Math.max(0, Math.min(Number(preferredIndex) || 0, panes.length - 1));
const retained = new Set();
panes.forEach((pane, index) => {
const paneId = 'cmp-pane-' + index;
const tabId = 'cmp-mobile-tab-' + index;
pane.id = paneId;
pane.setAttribute('role', 'tabpanel');
pane.setAttribute('aria-labelledby', tabId);
let tab = tabs.querySelector(`[data-pane="${index}"]`);
if (!tab) {
tab = document.createElement('button');
tab.type = 'button';
tab.className = 'compare-mobile-tab';
tab.setAttribute('role', 'tab');
tab.innerHTML = '<span class="compare-mobile-tab-slot"></span><span class="compare-mobile-tab-label"></span><span class="compare-mobile-tab-state" aria-hidden="true"></span>';
}
retained.add(tab);
tab.id = tabId;
tab.dataset.pane = String(index);
tab.setAttribute('aria-controls', paneId);
tab.querySelector('.compare-mobile-tab-slot').textContent = _slotChar(index);
tab.querySelector('.compare-mobile-tab-label').textContent = _mobilePaneLabel(index);
tab.classList.toggle('is-streaming', pane.classList.contains('is-streaming'));
tab.classList.toggle('is-awaiting-input', pane.classList.contains('is-awaiting-input'));
tab.classList.toggle('is-done', pane.classList.contains('is-done'));
tab.classList.toggle('is-failed', pane.classList.contains('is-failed'));
tabs.appendChild(tab);
});
tabs.querySelectorAll('.compare-mobile-tab').forEach(tab => {
if (!retained.has(tab)) tab.remove();
});
let addButton = tabs.querySelector('.compare-mobile-add');
if (!addButton && typeof tabs._onAddPane === 'function') {
addButton = document.createElement('button');
addButton.type = 'button';
addButton.className = 'compare-mobile-add';
addButton.title = 'Add model';
addButton.setAttribute('aria-label', 'Add model');
addButton.innerHTML = '<span aria-hidden="true">+</span><span>Add</span>';
addButton.addEventListener('click', (event) => {
event.stopPropagation();
tabs._onAddPane(addButton);
});
}
if (addButton) {
addButton.disabled = !!state._streaming;
addButton.style.display = panes.length >= 8 ? 'none' : '';
tabs.appendChild(addButton);
}
const mobile = _isMobileCompare();
panes.forEach((pane, index) => {
const active = index === state._activeMobilePane;
pane.classList.toggle('compare-pane-mobile-active', active);
if (mobile) pane.setAttribute('aria-hidden', active ? 'false' : 'true');
else pane.removeAttribute('aria-hidden');
});
tabs.querySelectorAll('.compare-mobile-tab').forEach((tab, index) => {
const active = index === state._activeMobilePane;
tab.classList.toggle('active', active);
tab.setAttribute('aria-selected', active ? 'true' : 'false');
tab.tabIndex = active ? 0 : -1;
});
}
function activateMobilePane(index, { focus = false } = {}) {
state._activeMobilePane = index;
refreshMobilePaneTabs(index);
const tab = document.querySelector(`.compare-mobile-tab[data-pane="${state._activeMobilePane}"]`);
if (tab && _isMobileCompare()) {
tab.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
if (focus) tab.focus();
}
}
/** Mount a horizontally scrollable tab row without unmounting inactive streams. */
function mountMobilePaneTabs(container, grid, onAddPane) {
const tabs = document.createElement('div');
tabs.className = 'compare-mobile-tabs';
tabs.setAttribute('role', 'tablist');
tabs.setAttribute('aria-label', 'Compared models');
tabs._onAddPane = onAddPane;
container.insertBefore(tabs, grid);
const onClick = (event) => {
const tab = event.target.closest('.compare-mobile-tab');
if (tab) activateMobilePane(Number(tab.dataset.pane));
};
const onKeyDown = (event) => {
if (!event.target.closest('.compare-mobile-tab')) return;
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const last = Math.max(0, state._selectedModels.length - 1);
let next = state._activeMobilePane;
if (event.key === 'ArrowLeft') next = Math.max(0, next - 1);
if (event.key === 'ArrowRight') next = Math.min(last, next + 1);
if (event.key === 'Home') next = 0;
if (event.key === 'End') next = last;
activateMobilePane(next, { focus: true });
};
const onResize = () => refreshMobilePaneTabs();
tabs.addEventListener('click', onClick);
tabs.addEventListener('keydown', onKeyDown);
window.addEventListener('resize', onResize);
// On phones, swipe across the response card to move to the adjacent model.
// Keep the gesture vertical-scroll friendly and don't steal touches from
// buttons, links, selects, or editable response content.
let swipeStart = null;
const onTouchStart = (event) => {
if (!_isMobileCompare() || event.touches.length !== 1) {
swipeStart = null;
return;
}
const target = event.target instanceof Element ? event.target : null;
if (target?.closest('button, a, select, input, textarea, [contenteditable="true"]')) {
swipeStart = null;
return;
}
const touch = event.touches[0];
swipeStart = { x: touch.clientX, y: touch.clientY };
};
const onTouchEnd = (event) => {
if (!swipeStart || !_isMobileCompare() || event.changedTouches.length !== 1) return;
const touch = event.changedTouches[0];
const dx = touch.clientX - swipeStart.x;
const dy = touch.clientY - swipeStart.y;
swipeStart = null;
if (Math.abs(dx) < 48 || Math.abs(dx) < Math.abs(dy) * 1.25) return;
const last = Math.max(0, state._selectedModels.length - 1);
const next = dx < 0
? Math.min(last, state._activeMobilePane + 1)
: Math.max(0, state._activeMobilePane - 1);
if (next !== state._activeMobilePane) activateMobilePane(next);
};
grid.addEventListener('touchstart', onTouchStart, { passive: true });
grid.addEventListener('touchend', onTouchEnd, { passive: true });
const observer = new MutationObserver(() => refreshMobilePaneTabs());
observer.observe(grid, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: ['class', 'data-pane'],
});
tabs._cleanup = () => {
observer.disconnect();
window.removeEventListener('resize', onResize);
grid.removeEventListener('touchstart', onTouchStart);
grid.removeEventListener('touchend', onTouchEnd);
};
refreshMobilePaneTabs(0);
return tabs;
}
function _showShuffleNotice() {
const grid = document.querySelector('.compare-grid');
if (!grid) return;
grid.querySelector('.compare-shuffle-notice')?.remove();
const notice = document.createElement('div');
notice.className = 'compare-shuffle-notice';
notice.setAttribute('role', 'status');
notice.setAttribute('aria-live', 'polite');
notice.innerHTML = '<span class="compare-shuffle-notice-icon" aria-hidden="true">' + ICON_DICE + '</span><span>Shuffling</span>';
grid.appendChild(notice);
requestAnimationFrame(() => notice.classList.add('show'));
setTimeout(() => {
notice.classList.remove('show');
notice.addEventListener('transitionend', () => notice.remove(), { once: true });
setTimeout(() => notice.remove(), 220);
}, 1060);
}
// ── Stop / reroll ──
function stopAll() {
@@ -43,6 +380,9 @@ function stopAll() {
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach(b => {
b.disabled = false; b.style.opacity = '0.7'; b.style.pointerEvents = '';
});
document.querySelectorAll('.compare-pane').forEach(pane => {
pane.classList.remove('is-streaming', 'is-awaiting-input');
});
}
function stopPane(paneIdx) {
@@ -54,6 +394,8 @@ function stopPane(paneIdx) {
// Hide stop button, show reroll
const pane = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
if (pane) {
pane.classList.remove('is-streaming', 'is-awaiting-input', 'is-done');
pane.classList.add('is-failed');
const stopBtn = pane.querySelector('.pane-stop-btn');
if (stopBtn) stopBtn.style.display = 'none';
pane.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
@@ -97,6 +439,8 @@ async function rerollPane(paneIdx, overrideTimeout) {
if (badge) { badge.textContent = ''; badge.style.color = ''; }
const timer = document.getElementById('cmp-timer-' + paneIdx);
if (timer) timer.textContent = '';
const summary = document.getElementById('cmp-summary-' + paneIdx);
if (summary) { summary.textContent = ''; summary.title = ''; }
// Search mode: re-query the search provider
if (state._compareMode === 'search') {
@@ -282,10 +626,11 @@ async function _addPane(anchorBtn) {
// Toggle existing dropdown
const existing = document.querySelector('.add-pane-dropdown');
if (existing) { existing.remove(); return; }
if (existing) { if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove(); return; }
const dropdown = document.createElement('div');
dropdown.className = 'add-pane-dropdown';
let closeMenu = () => dropdown.remove();
// Search input for large model lists
if (filtered.length >= 5) {
@@ -326,7 +671,7 @@ async function _addPane(anchorBtn) {
item.addEventListener('click', async (e) => {
e.stopPropagation();
dropdown.remove();
closeMenu();
await _createAndAppendPane(m);
});
dropdown.appendChild(item);
@@ -337,6 +682,8 @@ async function _addPane(anchorBtn) {
// chat-container is wider than the viewport.
const btnRect = anchorBtn.getBoundingClientRect();
dropdown.style.position = 'fixed';
dropdown.style.right = 'auto';
dropdown.style.bottom = 'auto';
const vw = window.innerWidth;
const vh = window.innerHeight;
const margin = 8;
@@ -351,16 +698,18 @@ async function _addPane(anchorBtn) {
const ddRect = dropdown.getBoundingClientRect();
const ddW = ddRect.width;
const ddH = ddRect.height;
// Horizontal: align dropdown's right edge with the button's, then
// clamp so the dropdown stays within [margin, vw - margin].
// Align the dropdown's right edge with the button, then clamp so it stays
// within the viewport. This keeps the picker attached to the Add flap
// instead of opening at an unrelated edge of the compare surface.
let left = btnRect.right - ddW;
if (left + ddW > vw - margin) left = vw - margin - ddW;
if (left < margin) left = margin;
left = Math.max(margin, Math.min(left, vw - margin - ddW));
// Vertical: drop below the button if there's room, otherwise above.
const spaceBelow = vh - btnRect.bottom;
const spaceAbove = btnRect.top;
let top;
if (spaceBelow >= ddH + margin || spaceBelow >= spaceAbove) {
if (anchorBtn.classList.contains('compare-add-flap')) {
top = Math.max(margin, Math.min(btnRect.bottom + 6, vh - margin - ddH));
} else if (spaceBelow >= ddH + margin || spaceBelow >= spaceAbove) {
top = Math.min(btnRect.bottom + 4, vh - margin - Math.min(ddH, vh - margin * 2));
} else {
top = Math.max(margin, btnRect.top - 4 - ddH);
@@ -371,15 +720,8 @@ async function _addPane(anchorBtn) {
dropdown.style.bottom = 'auto';
dropdown.style.maxHeight = Math.min(ddH, vh - margin * 2) + 'px';
// Close on outside click
const close = (e) => {
if (!dropdown.contains(e.target) && e.target !== anchorBtn) {
dropdown.remove();
document.removeEventListener('click', close);
}
};
setTimeout(() => document.addEventListener('click', close), 0);
}
// Close on outside click or Escape (the latter via the registry).
closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (e) => !dropdown.contains(e.target) && e.target !== anchorBtn);}
/** Create a new pane for the given model and append it to the compare grid. */
async function _createAndAppendPane(m) {
@@ -387,7 +729,8 @@ async function _createAndAppendPane(m) {
// Create session
const fd = new FormData();
fd.append('name', '[CMP] ' + m.name);
// Blind mode: neutral slot name only — never leak the model (issue #1285).
fd.append('name', '[CMP] ' + (state._blindMode ? 'Model ' + _slotChar(i) : m.name));
fd.append('endpoint_url', m.url || '');
fd.append('model', m.id || '');
if (m.endpointId) {
@@ -401,6 +744,7 @@ async function _createAndAppendPane(m) {
// Update arrays
state._selectedModels.push({ model: m.id, endpoint: m.url, endpointId: m.endpointId, name: m.name, endpointName: m.endpointName || '' });
state._paneSessionIds.push(data.id);
state._paneGenerationSettings.push({ thinking_mode: '', temperature_override: null, max_tokens_override: null });
state._paneMetrics.push(null);
state._abortControllers.push(null);
_persistSelections();
@@ -413,16 +757,17 @@ async function _createAndAppendPane(m) {
pane.dataset.pane = String(i);
pane.innerHTML =
'<div class="pane-header">' +
'<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>' +
'<span class="pane-timer" id="cmp-timer-' + i + '"></span>' +
'<span class="pane-finish-badge" id="cmp-badge-' + i + '"></span>' +
'<div class="pane-actions">' +
'<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>' +
'<button class="pane-action-btn" data-action="reroll" data-pane="' + i + '" title="Re-roll">' + ICON_REROLL + '</button>' +
'<button class="pane-action-btn" data-action="copy" data-pane="' + i + '" title="Copy">' + ICON_COPY + '</button>' +
'<div class="pane-header-row pane-header-primary"><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><div class="pane-primary-actions">' +
'<button class="pane-action-btn" data-action="expand" data-pane="' + i + '" title="Expand">' + ICON_EXPAND + '</button>' +
'<button class="pane-action-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane">' + ICON_CLOSE + '</button>' +
'</div>' +
paneSettingsButtonHtml(i) +
'<button class="close-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane"></button></div></div>' +
'<div class="pane-header-row pane-header-secondary"><div class="pane-stats">' + _paneModeBadgeHtml(i) +
'<span class="pane-timer" id="cmp-timer-' + i + '"></span><span class="pane-summary" id="cmp-summary-' + i + '" role="button" tabindex="0" aria-label="Show response metrics"></span><span class="pane-finish-badge" id="cmp-badge-' + i + '"></span></div>' +
'<div class="pane-actions">' +
'<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>' +
'<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>' +
'<button class="pane-action-btn pane-needs-response" data-action="reroll" data-pane="' + i + '" title="Re-roll" style="display:none;">' + ICON_REROLL + '</button>' +
'<button class="pane-action-btn pane-needs-response" data-action="copy" data-pane="' + i + '" title="Copy" style="display:none;">' + ICON_COPY + '</button></div></div>' +
'</div>' +
'<div class="chat-history" id="cmp-history-' + i + '"></div>' +
'<iframe class="compare-pane-iframe" id="cmp-iframe-' + i + '" sandbox="allow-scripts" style="display:none;"></iframe>' +
@@ -440,6 +785,14 @@ async function _createAndAppendPane(m) {
// Update grid columns
const n = state._selectedModels.length;
grid.dataset.cols = String(Math.min(n, 4));
syncShuffleButtonPlacement(n > 2);
refreshMobilePaneTabs(i);
if (_isMobileCompare()) {
activateMobilePane(i);
requestAnimationFrame(() => {
document.querySelector('.compare-mobile-add')?.scrollIntoView({ block: 'nearest', inline: 'end', behavior: 'smooth' });
});
}
// Update header label
const headerSpan = document.querySelector('.compare-active > div:first-child span');
@@ -452,19 +805,6 @@ async function _createAndAppendPane(m) {
// Rebuild vote bar
buildVoteBar(n);
// Prompt to shuffle in blind mode — tooltip bubble next to Shuffle button
if (state._blindMode && n > 2) {
const shuffleBtn = document.getElementById('compare-shuffle-btn');
if (shuffleBtn) {
const bubble = document.createElement('div');
bubble.style.cssText = 'position:absolute;top:100%;right:0;margin-top:6px;background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:5px 10px;font-size:11px;white-space:nowrap;z-index:10000;box-shadow:0 4px 12px rgba(0,0,0,0.25);pointer-events:none;opacity:0;transition:opacity 0.2s;';
bubble.textContent = 'Shuffle models?';
shuffleBtn.style.position = 'relative';
shuffleBtn.appendChild(bubble);
requestAnimationFrame(() => { bubble.style.opacity = '1'; });
setTimeout(() => { bubble.style.opacity = '0'; setTimeout(() => bubble.remove(), 200); }, 4000);
}
}
}
/** Remove a pane from the compare grid. If only 1 remains, exit compare mode. */
@@ -483,6 +823,7 @@ function _removePane(paneIdx) {
// Remove from arrays
state._selectedModels.splice(paneIdx, 1);
state._paneSessionIds.splice(paneIdx, 1);
state._paneGenerationSettings.splice(paneIdx, 1);
state._paneMetrics.splice(paneIdx, 1);
state._abortControllers.splice(paneIdx, 1);
_persistSelections();
@@ -499,6 +840,7 @@ function _removePane(paneIdx) {
grid.querySelectorAll('.compare-pane').forEach(p => p.remove());
const n = state._selectedModels.length;
syncShuffleButtonPlacement(n > 2);
for (let i = 0; i < n; i++) {
const label = state._blindMode ? 'Model ' + _slotChar(i) : state._selectedModels[i].name;
const pane = document.createElement('div');
@@ -506,17 +848,17 @@ function _removePane(paneIdx) {
pane.dataset.pane = String(i);
pane.innerHTML =
'<div class="pane-header">' +
'<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>' +
'<span class="pane-timer" id="cmp-timer-' + i + '"></span>' +
'<span class="pane-finish-badge" id="cmp-badge-' + i + '"></span>' +
'<div class="pane-header-row pane-header-primary"><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><div class="pane-primary-actions">' +
'<button class="pane-action-btn" data-action="expand" data-pane="' + i + '" title="Expand">' + ICON_EXPAND + '</button>' +
paneSettingsButtonHtml(i) +
'<button class="close-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane"></button></div></div>' +
'<div class="pane-header-row pane-header-secondary"><div class="pane-stats">' + _paneModeBadgeHtml(i) +
'<span class="pane-timer" id="cmp-timer-' + i + '"></span><span class="pane-summary" id="cmp-summary-' + i + '" role="button" tabindex="0" aria-label="Show response metrics"></span><span class="pane-finish-badge" id="cmp-badge-' + i + '"></span></div>' +
'<div class="pane-actions">' +
'<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>' +
'<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>' +
'<button class="pane-action-btn pane-needs-response" data-action="reroll" data-pane="' + i + '" title="Re-roll" style="display:none;">' + ICON_REROLL + '</button>' +
'<button class="pane-action-btn pane-needs-response" data-action="copy" data-pane="' + i + '" title="Copy" style="display:none;">' + ICON_COPY + '</button>' +
'<button class="pane-action-btn" data-action="expand" data-pane="' + i + '" title="Expand">' + ICON_EXPAND + '</button>' +
'<button class="pane-action-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane">' + ICON_CLOSE + '</button>' +
'</div>' +
'<button class="pane-action-btn pane-needs-response" data-action="copy" data-pane="' + i + '" title="Copy" style="display:none;">' + ICON_COPY + '</button></div></div>' +
'</div>' +
'<div class="chat-history" id="cmp-history-' + i + '"></div>' +
'<iframe class="compare-pane-iframe" id="cmp-iframe-' + i + '" sandbox="allow-scripts" style="display:none;"></iframe>' +
@@ -542,16 +884,20 @@ function _removePane(paneIdx) {
// Rebuild vote bar
buildVoteBar(n);
refreshMobilePaneTabs(Math.min(paneIdx, n - 1));
}
/** Show a dropdown under the pane title to swap the model for that pane. */
function _showModelSwapDropdown(paneIdx, titleBtn) {
// Don't allow swaps while streaming
if (state._streaming) return;
if (state._streaming) {
uiModule.showToast('Stop the response or wait for it to finish before changing models.');
return;
}
// Remove any existing dropdown
const existing = document.querySelector('.pane-model-dropdown');
if (existing) { existing.remove(); return; }
if (existing) { if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove(); return; }
const _effectiveType = (state._compareMode === 'agent' || state._compareMode === 'research') ? 'chat' : state._compareMode;
const filtered = state._cachedModels.filter(m => m.type === _effectiveType);
@@ -559,6 +905,7 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
const dropdown = document.createElement('div');
dropdown.className = 'pane-model-dropdown';
let closeMenu = () => dropdown.remove();
filtered.forEach(m => {
const item = document.createElement('button');
@@ -573,34 +920,40 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
}
item.addEventListener('click', async (e) => {
e.stopPropagation();
dropdown.remove();
closeMenu();
// Update the model for this pane and persist
state._selectedModels[paneIdx] = {
model: m.id, endpoint: m.url, endpointId: m.endpointId, name: m.name,
};
_persistSelections();
if (window._updateCheckBtnState) window._updateCheckBtnState();
// Delete old session, create new one
const oldSid = state._paneSessionIds[paneIdx];
if (oldSid) {
fetch(`${state.API_BASE}/api/session/${oldSid}`, { method: 'DELETE' }).catch(() => {});
}
const fd = new FormData();
fd.append('name', '[CMP] ' + m.name);
// Blind mode: neutral slot name only — never leak the model (issue #1285).
fd.append('name', '[CMP] ' + (state._blindMode ? 'Model ' + _slotChar(paneIdx) : m.name));
fd.append('endpoint_url', m.url || '');
fd.append('model', m.id || '');
if (m.endpointId) {
fd.append('endpoint_id', m.endpointId);
fd.append('skip_validation', 'true');
}
let newSessionId = '';
try {
const res = await fetch(`${state.API_BASE}/api/session`, { method: 'POST', body: fd });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
state._paneSessionIds[paneIdx] = data.id;
newSessionId = data.id || '';
if (!newSessionId) throw new Error('Missing session id');
} catch (err) {
console.error('Failed to create session for swapped model:', err);
if (uiModule?.showError) uiModule.showError('Failed to swap compare model: ' + (err?.message || 'unknown'));
return;
}
const oldSid = state._paneSessionIds[paneIdx];
state._selectedModels[paneIdx] = {
model: m.id, endpoint: m.url, endpointId: m.endpointId, name: m.name,
};
state._paneSessionIds[paneIdx] = newSessionId;
await _savePaneGenerationSettings(paneIdx, {});
_persistSelections();
if (window._updateCheckBtnState) window._updateCheckBtnState();
if (oldSid) {
fetch(`${state.API_BASE}/api/session/${oldSid}`, { method: 'DELETE' }).catch(() => {});
}
// Update title display
@@ -621,6 +974,9 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
if (previewBtn) { previewBtn.style.display = 'none'; previewBtn.classList.remove('active'); }
const badge = document.getElementById('cmp-badge-' + paneIdx);
if (badge) { badge.textContent = ''; badge.style.color = ''; }
const summary = document.getElementById('cmp-summary-' + paneIdx);
if (summary) { summary.textContent = ''; summary.title = ''; }
refreshMobilePaneTabs(paneIdx);
});
dropdown.appendChild(item);
});
@@ -653,15 +1009,8 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
dropdown.style.top = top + 'px';
dropdown.style.maxHeight = Math.min(ddH, vh - margin * 2) + 'px';
// Close on outside click
const close = (e) => {
if (!dropdown.contains(e.target) && e.target !== titleBtn) {
dropdown.remove();
document.removeEventListener('click', close);
}
};
setTimeout(() => document.addEventListener('click', close), 0);
}
// Close on outside click or Escape (the latter via the registry).
closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (e) => !dropdown.contains(e.target) && e.target !== titleBtn);}
// ── Shuffle / reset ──
@@ -672,6 +1021,7 @@ function shufflePanePositions() {
if (shuffleBtn) { const b = shuffleBtn.querySelector('div'); if (b) b.remove(); }
const n = state._selectedModels.length;
if (n < 2) return;
_showShuffleNotice();
// Fisher-Yates shuffle to get new order
const indices = Array.from({ length: n }, (_, i) => i);
@@ -683,6 +1033,7 @@ function shufflePanePositions() {
// Reorder internal state
const newModels = indices.map(i => state._selectedModels[i]);
const newSessionIds = indices.map(i => state._paneSessionIds[i]);
const newGenerationSettings = indices.map(i => state._paneGenerationSettings[i]);
const newMetrics = indices.map(i => state._paneMetrics[i]);
// Collect pane contents (HTML) before swapping
@@ -698,6 +1049,7 @@ function shufflePanePositions() {
// Apply shuffled state
state._selectedModels = newModels;
state._paneSessionIds = newSessionIds;
state._paneGenerationSettings = newGenerationSettings;
state._paneMetrics = newMetrics;
// Spin the shuffle button dice icon
@@ -764,7 +1116,10 @@ function shufflePanePositions() {
state._blindMode = true;
// Rebuild vote bar with new labels
setTimeout(() => buildVoteBar(n), 250);
setTimeout(() => {
buildVoteBar(n);
refreshMobilePaneTabs();
}, 250);
}
function resetCompare() {
@@ -773,6 +1128,13 @@ function resetCompare() {
// Clear last prompt so vote buttons are disabled until next prompt
state._lastPrompt = '';
state._expectedAnswer = '';
const expected = document.getElementById('cmp-eval-expected');
if (expected) {
expected.classList.add('hidden');
const value = expected.querySelector('.cmp-eval-expected-value');
if (value) value.textContent = '';
}
// Reset finish badges, titles, winner/loser state
state._finishOrder = 0;
@@ -781,12 +1143,16 @@ function resetCompare() {
for (let i = 0; i < n; i++) {
const badge = document.getElementById('cmp-badge-' + i);
if (badge) { badge.textContent = ''; badge.style.color = ''; }
const summary = document.getElementById('cmp-summary-' + i);
if (summary) { summary.textContent = ''; summary.title = ''; }
const titleEl = document.getElementById('cmp-title-' + i);
if (titleEl) {
const lbl = state._blindMode ? 'Model ' + _slotChar(i) : state._selectedModels[i].name;
titleEl.innerHTML = escapeHtml(lbl) + ' <span class="pane-title-caret">&#x25BE;</span>';
}
if (panes[i]) { panes[i].classList.remove('winner', 'loser'); }
if (panes[i]) {
panes[i].classList.remove('winner', 'loser', 'is-streaming', 'is-awaiting-input', 'is-done', 'is-failed');
}
// Clear all messages from pane history
const hist = document.getElementById('cmp-history-' + i);
@@ -823,4 +1189,10 @@ export {
_showModelSwapDropdown,
shufflePanePositions,
resetCompare,
mountMobilePaneTabs,
syncShuffleButtonPlacement,
activateMobilePane,
refreshMobilePaneTabs,
paneSettingsButtonHtml,
togglePaneSettings,
};