// compare/panes.js — pane lifecycle, actions, layout import state from './state.js'; import { _persistSelections } from './models.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, ICON_DICE, } from './icons.js?v=20260908compareprompts1'; import { _clearProbeWaves } from './probe.js'; import Storage from '../storage.js'; import uiModule from '../ui.js?v=20260908weekhoverfix1'; import spinnerModule from '../spinner.js'; import { bindMenuDismiss } from '../escMenuStack.js'; var escapeHtml = uiModule.esc; // ── Lazy-registered functions from compare.js (avoids circular imports) ── let _setSendBtn = null; let _deactivate = null; let _streamToPane = null; let _renderSearchResults = null; let _fetchModels = null; /** Register external functions that live in compare.js or sibling modules. */ function registerPaneActions({ setSendBtn, deactivate, streamToPane, renderSearchResults, fetchModels }) { if (setSendBtn) _setSendBtn = setSendBtn; if (deactivate) _deactivate = deactivate; if (streamToPane) _streamToPane = streamToPane; if (renderSearchResults) _renderSearchResults = renderSearchResults; if (fetchModels) _fetchModels = fetchModels; } /** 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 '' + '' + '' + escapeHtml(label) + '' + (mode === 'agent' ? '' : '' + escapeHtml(detail) + '') + ''; } const PANE_SETTINGS_ICON = ''; function paneSettingsButtonHtml(paneIdx) { return ''; } 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 = '
Inference settings
'; const thinkingRequired = _paneModelRequiresThinking(paneIdx); const thinkingOn = thinkingRequired || settings.thinking_mode === 'on'; const thinkingRow = document.createElement('div'); thinkingRow.className = 'chat-context-toggle-row'; thinkingRow.innerHTML = '
Thinking' + (thinkingRequired ? 'Required' : (thinkingOn ? 'On' : 'Off')) + '
'; 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 = '
' + label + '' + formatter(value) + '
'; 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 = ''; } 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 = 'Add'; 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 = 'Shuffling'; 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() { state._abortControllers.forEach(ac => { if (ac) ac.abort(); }); state._abortControllers = []; state._streaming = false; if (_setSendBtn) _setSendBtn('send'); // Re-enable header buttons 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) { const ac = state._abortControllers[paneIdx]; if (ac) { ac.abort(); state._abortControllers[paneIdx] = null; } // 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 = ''); } // Remove spinner if present const hist = document.getElementById('cmp-history-' + paneIdx); if (hist) { const lastAi = hist.querySelector('.msg-ai:last-child'); if (lastAi && lastAi._spinner) { lastAi._spinner.destroy(); lastAi._spinner = null; } const body = lastAi && lastAi.querySelector('.body'); if (body && !body.textContent.trim()) { body.innerHTML = 'Stopped'; } } } async function rerollPane(paneIdx, overrideTimeout) { // Allow reroll even while other panes stream — just stop this pane first if (state._abortControllers[paneIdx]) stopPane(paneIdx); const hist = document.getElementById('cmp-history-' + paneIdx); // Reset preview state const _ri = document.getElementById('cmp-iframe-' + paneIdx); if (_ri) { _ri.srcdoc = ''; _ri.style.display = 'none'; _ri._htmlCode = null; } const _rp = document.getElementById('cmp-preview-' + paneIdx); if (_rp) { _rp.style.display = 'none'; _rp.classList.remove('active'); } if (hist) hist.style.display = ''; if (!hist) return; const userBodies = hist.querySelectorAll('.msg-user .body'); const firstUserText = userBodies.length > 0 ? userBodies[0].textContent : ''; if (!firstUserText) return; // Clear all messages and start fresh hist.innerHTML = ''; const userMsg = document.createElement('div'); userMsg.className = 'msg msg-user'; userMsg.innerHTML = '
You
' + escapeHtml(firstUserText) + '
'; hist.appendChild(userMsg); // Reset badge and timer const badge = document.getElementById('cmp-badge-' + paneIdx); 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') { 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; const m = state._selectedModels[paneIdx]; const fd = new FormData(); fd.append('query', firstUserText); fd.append('provider', m.model); fd.append('count', '10'); try { const ac = new AbortController(); state._abortControllers[paneIdx] = ac; const t0 = performance.now(); const res = await fetch(`${state.API_BASE}/api/search/query`, { method: 'POST', body: fd, signal: ac.signal }); const data = await res.json(); const elapsed = ((performance.now() - t0) / 1000).toFixed(2); 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'); parts.push(elapsed + 's'); span.textContent = parts.join(' | '); footer.appendChild(span); aiMsg.appendChild(footer); } catch (err) { aiBody.innerHTML = '
Error: ' + escapeHtml(err.message) + '
'; } state._abortControllers[paneIdx] = null; hist.scrollTop = hist.scrollHeight; return; } // Chat/agent mode: stream via session const aiMsg = document.createElement('div'); aiMsg.className = 'msg msg-ai'; aiMsg.innerHTML = '
AI
'; const aiBody = aiMsg.querySelector('.body'); if (spinnerModule) { const label = overrideTimeout ? 'Retrying (' + overrideTimeout + 's)...' : 'Re-rolling...'; const spinner = spinnerModule.create(label, 'right'); aiBody.appendChild(spinner.createElement()); spinner.start(); aiMsg._spinner = spinner; } hist.appendChild(aiMsg); hist.scrollTop = hist.scrollHeight; const opts = { skipBadge: true }; if (overrideTimeout) opts.timeout = overrideTimeout; await _streamToPane(paneIdx, state._paneSessionIds[paneIdx], firstUserText, aiMsg, opts); } // ── Expand / preview / copy ── function toggleExpandPane(paneIdx, btn) { const grid = document.querySelector('.compare-grid'); if (!grid) return; const panes = grid.querySelectorAll('.compare-pane'); const target = panes[paneIdx]; if (!target) return; if (target.classList.contains('expanded')) { target.classList.remove('expanded'); panes.forEach(p => { p.style.display = ''; }); if (btn) btn.innerHTML = ICON_EXPAND; } else { target.classList.add('expanded'); panes.forEach((p, i) => { if (i !== paneIdx) p.style.display = 'none'; }); if (btn) btn.innerHTML = ICON_COLLAPSE; } } /** * After streaming finishes, check for HTML code in the response. * If found, show the play button in the header. User clicks to run. */ function _autoPreviewHtml(paneIdx, accumulated) { if (!accumulated) return; const htmlCode = _extractHtmlFromText(accumulated); if (!htmlCode) return; const iframe = document.getElementById('cmp-iframe-' + paneIdx); const previewBtn = document.getElementById('cmp-preview-' + paneIdx); if (!iframe || !previewBtn) return; // Store the HTML on the iframe for when user clicks play iframe._htmlCode = htmlCode; // Show the play button previewBtn.style.display = ''; previewBtn.innerHTML = ICON_PLAY; previewBtn.title = 'Run preview'; } /** Toggle between iframe preview and code view for a pane. */ function togglePanePreview(paneIdx) { const iframe = document.getElementById('cmp-iframe-' + paneIdx); const hist = document.getElementById('cmp-history-' + paneIdx); const btn = document.getElementById('cmp-preview-' + paneIdx); if (!iframe || !hist || !btn) return; const showingPreview = iframe.style.display !== 'none'; if (showingPreview) { // Switch to code view iframe.style.display = 'none'; hist.style.display = ''; btn.innerHTML = ICON_PLAY; btn.title = 'Run preview'; btn.classList.remove('active'); } else { // Switch to preview — load on first click if (iframe._htmlCode) iframe.srcdoc = iframe._htmlCode; iframe.style.display = ''; hist.style.display = 'none'; btn.innerHTML = ICON_CODE; btn.title = 'Show code'; btn.classList.add('active'); } } /** Extract full HTML document from raw accumulated text. */ function _extractHtmlFromText(text) { // 1. Try markdown code fences const fenceRe = /`{3,}(?:html)?\s*\r?\n([\s\S]*?)`{3,}/gi; let match; while ((match = fenceRe.exec(text)) !== null) { const code = match[1].trim(); if (/]/i.test(code)) return code; } // 2. Bare HTML const bare = text.match(/()/i) || text.match(/(][\s\S]*<\/html>)/i); if (bare) return bare[1].trim(); return null; } async function copyPaneResponse(paneIdx) { const hist = document.getElementById('cmp-history-' + paneIdx); if (!hist) return; const aiMsgs = hist.querySelectorAll('.msg-ai'); if (aiMsgs.length === 0) return; const lastAi = aiMsgs[aiMsgs.length - 1]; // For image panes, copy the prompt text const text = lastAi._imageData ? (lastAi._imageData.prompt || '') : (lastAi.querySelector('.body')?.textContent || ''); try { await navigator.clipboard.writeText(text); } catch (e) { const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } if (uiModule) uiModule.showToast(lastAi._imageData ? 'Prompt copied!' : 'Copied!'); } // ── Add / create / remove panes ── /** Show a model picker dropdown anchored to the "+" button in the pane header. */ async function _addPane(anchorBtn) { if (state._streaming) return; const _effectiveType = (state._compareMode === 'agent' || state._compareMode === 'research') ? 'chat' : state._compareMode; const filtered = state._cachedModels.filter(m => m.type === _effectiveType); if (!filtered.length) return; // Toggle existing dropdown const existing = document.querySelector('.add-pane-dropdown'); 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) { const searchInput = document.createElement('input'); searchInput.type = 'text'; searchInput.placeholder = 'Search models\u2026'; searchInput.className = 'add-pane-search'; searchInput.addEventListener('input', () => { const q = searchInput.value.toLowerCase().trim(); dropdown.querySelectorAll('.pane-model-item').forEach(item => { item.style.display = item.textContent.toLowerCase().includes(q) ? '' : 'none'; }); }); searchInput.addEventListener('click', (e) => e.stopPropagation()); searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); const first = dropdown.querySelector('.pane-model-item:not([style*="display: none"])'); if (first) first.click(); } }); dropdown.appendChild(searchInput); // Desktop: auto-focus the search box so the user can start typing. // Mobile: skip — auto-focus pops the on-screen keyboard and covers // the model list. The user can tap the search box if they want to // filter, otherwise they just tap a model directly. if (window.innerWidth > 768) setTimeout(() => searchInput.focus(), 0); } filtered.forEach(m => { const item = document.createElement('button'); item.type = 'button'; item.className = 'pane-model-item'; const label = m.endpointName ? m.name + ' (' + m.endpointName + ')' : m.name; item.textContent = label; const alreadyUsed = state._selectedModels.some(s => s.model === m.id && s.endpointId === m.endpointId); if (alreadyUsed) item.classList.add('current'); item.addEventListener('click', async (e) => { e.stopPropagation(); closeMenu(); await _createAndAppendPane(m); }); dropdown.appendChild(item); }); // Position dropdown relative to the viewport (position: fixed) so it // can't end up off-screen even when the toolbar has scrolled or the // 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; // Render off-screen first so we can measure the dropdown's actual size. // Clamp the width to the viewport up front so long model names can't push // the dropdown off the screen edge, and lift z-index above the panes. dropdown.style.left = '-9999px'; dropdown.style.top = '0'; dropdown.style.maxWidth = (vw - margin * 2) + 'px'; dropdown.style.zIndex = '100000'; document.body.appendChild(dropdown); const ddRect = dropdown.getBoundingClientRect(); const ddW = ddRect.width; const ddH = ddRect.height; // 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; 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 (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); } dropdown.style.left = left + 'px'; dropdown.style.top = top + 'px'; dropdown.style.right = 'auto'; dropdown.style.bottom = 'auto'; dropdown.style.maxHeight = Math.min(ddH, vh - margin * 2) + 'px'; // 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) { const i = state._selectedModels.length; // New index // Create session const fd = new FormData(); // 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) { 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) return; const data = await res.json(); // 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(); if (window._updateCheckBtnState) window._updateCheckBtnState(); // Build pane DOM const label = state._blindMode ? 'Model ' + _slotChar(i) : m.name; const pane = document.createElement('div'); pane.className = 'compare-pane'; pane.dataset.pane = String(i); pane.innerHTML = '
' + '
' + '' + paneSettingsButtonHtml(i) + '
' + '
' + _paneModeBadgeHtml(i) + '
' + '
' + '' + '' + '' + '
' + '
' + '
' + '' + ''; // Append to grid const grid = document.querySelector('.compare-grid'); grid.appendChild(pane); // 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'); if (headerSpan) { const modeLabel = ({ search: ' search providers', agent: ' agents', research: ' research models' }[state._compareMode] || ' models'); headerSpan.textContent = 'Comparing' + modeLabel + (state._blindMode ? ' (blind)' : '') + ' \u00b7 ' + state._timeout + 's timeout'; } // Rebuild vote bar buildVoteBar(n); } /** Remove a pane from the compare grid. If only 1 remains, exit compare mode. */ function _removePane(paneIdx) { if (state._streaming) return; // Abort if streaming if (state._abortControllers[paneIdx]) state._abortControllers[paneIdx].abort(); // Delete the session const sid = state._paneSessionIds[paneIdx]; if (sid) { fetch(`${state.API_BASE}/api/session/${sid}`, { method: 'DELETE' }).catch(() => {}); } // 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(); if (window._updateCheckBtnState) window._updateCheckBtnState(); // If no panes left, exit compare mode if (state._selectedModels.length === 0) { if (_deactivate) _deactivate(true); return; } // Rebuild pane DOM — re-index all panes so IDs stay consistent const grid = document.querySelector('.compare-grid'); 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'); pane.className = 'compare-pane'; pane.dataset.pane = String(i); pane.innerHTML = '
' + '
' + '' + paneSettingsButtonHtml(i) + '
' + '
' + _paneModeBadgeHtml(i) + '
' + '
' + '' + '' + '' + '
' + '
' + '
' + '' + ''; grid.appendChild(pane); } // Update grid columns grid.dataset.cols = String(Math.min(n, 4)); // Update header label const headerSpan = document.querySelector('.compare-active > div:first-child span'); if (headerSpan) { const modeLabel = ({ search: ' search providers', agent: ' agents', research: ' research models' }[state._compareMode] || ' models'); headerSpan.textContent = 'Comparing' + modeLabel + (state._blindMode ? ' (blind)' : '') + ' \u00b7 ' + state._timeout + 's timeout'; } // 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) { 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) { 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); if (filtered.length === 0) return; const dropdown = document.createElement('div'); dropdown.className = 'pane-model-dropdown'; let closeMenu = () => dropdown.remove(); filtered.forEach(m => { const item = document.createElement('button'); item.type = 'button'; item.className = 'pane-model-item'; const label = m.endpointName ? m.name + ' (' + m.endpointName + ')' : m.name; item.textContent = label; // Highlight current model if (state._selectedModels[paneIdx] && state._selectedModels[paneIdx].model === m.id && state._selectedModels[paneIdx].endpointId === m.endpointId) { item.classList.add('current'); } item.addEventListener('click', async (e) => { e.stopPropagation(); closeMenu(); const fd = new FormData(); // 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(); 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 const titleEl = document.getElementById('cmp-title-' + paneIdx); if (titleEl) { const displayName = state._blindMode ? 'Model ' + _slotChar(paneIdx) : m.name; titleEl.innerHTML = escapeHtml(displayName) + ' ▾'; } // Clear pane history for fresh start const hist = document.getElementById('cmp-history-' + paneIdx); if (hist) { hist.innerHTML = ''; hist.style.display = ''; } const iframe = document.getElementById('cmp-iframe-' + paneIdx); if (iframe) { iframe.srcdoc = ''; iframe.style.display = 'none'; iframe._htmlCode = null; } const previewBtn = document.getElementById('cmp-preview-' + paneIdx); 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); }); // Position relative to the viewport (fixed) and append to document.body so // the dropdown can't be clipped by the narrow pane's overflow or run off the // screen edge on mobile (matches the "+" add-pane picker behaviour). const rect = titleBtn.getBoundingClientRect(); const vw = window.innerWidth, vh = window.innerHeight, margin = 8; dropdown.style.position = 'fixed'; dropdown.style.zIndex = '100000'; dropdown.style.maxWidth = (vw - margin * 2) + 'px'; dropdown.style.overflowY = 'auto'; dropdown.style.left = '-9999px'; dropdown.style.top = '0'; document.body.appendChild(dropdown); const ddRect = dropdown.getBoundingClientRect(); const ddW = ddRect.width, ddH = ddRect.height; let left = rect.left; if (left + ddW > vw - margin) left = vw - margin - ddW; if (left < margin) left = margin; const spaceBelow = vh - rect.bottom, spaceAbove = rect.top; let top; if (spaceBelow >= ddH + margin || spaceBelow >= spaceAbove) { top = Math.min(rect.bottom + 4, vh - margin - Math.min(ddH, vh - margin * 2)); } else { top = Math.max(margin, rect.top - 4 - ddH); } dropdown.style.left = left + 'px'; dropdown.style.top = top + 'px'; dropdown.style.maxHeight = Math.min(ddH, vh - margin * 2) + 'px'; // 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 ── function shufflePanePositions() { if (state._streaming) return; // Remove shuffle prompt bubble if present const shuffleBtn = document.getElementById('compare-shuffle-btn'); 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); for (let i = indices.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [indices[i], indices[j]] = [indices[j], indices[i]]; } // 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 const paneContents = []; const paneClasses = []; for (let i = 0; i < n; i++) { const hist = document.getElementById('cmp-history-' + i); paneContents.push(hist ? hist.innerHTML : ''); const pane = document.querySelector(`.compare-pane[data-pane="${i}"]`); paneClasses.push(pane ? { winner: pane.classList.contains('winner'), loser: pane.classList.contains('loser') } : {}); } // Apply shuffled state state._selectedModels = newModels; state._paneSessionIds = newSessionIds; state._paneGenerationSettings = newGenerationSettings; state._paneMetrics = newMetrics; // Spin the shuffle button dice icon const shuffleBtn2 = document.getElementById('compare-shuffle-btn'); if (shuffleBtn2) { const diceSvg = shuffleBtn2.querySelector('svg'); if (diceSvg) { diceSvg.style.transition = 'transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)'; diceSvg.style.transform = 'rotate(360deg)'; setTimeout(() => { diceSvg.style.transition = ''; diceSvg.style.transform = ''; }, 400); } } // Shake panes and flash titles for (let i = 0; i < n; i++) { const pane = document.querySelector(`.compare-pane[data-pane="${i}"]`); if (pane) { pane.style.animation = 'pane-shake 0.3s ease'; pane.addEventListener('animationend', () => { pane.style.animation = ''; }, { once: true }); } const titleEl = document.getElementById('cmp-title-' + i); if (titleEl) { titleEl.style.transition = 'opacity 0.12s ease, transform 0.12s ease'; titleEl.style.opacity = '0.3'; titleEl.style.transform = 'scale(0.9)'; titleEl.innerHTML = '?'; } const hist = document.getElementById('cmp-history-' + i); if (hist) { hist.style.transition = 'opacity 0.15s ease'; hist.style.opacity = '0'; } } setTimeout(() => { for (let i = 0; i < n; i++) { const hist = document.getElementById('cmp-history-' + i); const pane = document.querySelector(`.compare-pane[data-pane="${i}"]`); const titleEl = document.getElementById('cmp-title-' + i); const badge = document.getElementById('cmp-badge-' + i); const src = indices[i]; if (hist) hist.innerHTML = paneContents[src]; if (titleEl) { const lbl = state._blindMode ? 'Model ' + _slotChar(i) : state._selectedModels[i].name; titleEl.innerHTML = escapeHtml(lbl) + ' ▾'; titleEl.style.transition = 'opacity 0.25s ease, transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)'; titleEl.style.opacity = '1'; titleEl.style.transform = 'scale(1)'; } if (badge) { badge.textContent = ''; badge.style.color = ''; } if (pane) { pane.classList.toggle('winner', !!paneClasses[src].winner); pane.classList.toggle('loser', !!paneClasses[src].loser); } if (hist) { hist.style.transition = 'opacity 0.25s ease'; hist.style.opacity = '1'; } } }, 200); // Re-enable blind mode after shuffle state._blindMode = true; // Rebuild vote bar with new labels setTimeout(() => { buildVoteBar(n); refreshMobilePaneTabs(); }, 250); } function resetCompare() { if (state._streaming) stopAll(); const n = state._selectedModels.length; // 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; state._paneMetrics = new Array(n).fill(null); const panes = document.querySelectorAll('.compare-pane'); 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) + ' ▾'; } 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); if (hist) { hist.innerHTML = ''; hist.style.display = ''; } // Reset iframe preview const iframe = document.getElementById('cmp-iframe-' + i); if (iframe) { iframe.srcdoc = ''; iframe.style.display = 'none'; iframe._htmlCode = null; } const previewBtn = document.getElementById('cmp-preview-' + i); if (previewBtn) { previewBtn.style.display = 'none'; previewBtn.classList.remove('active'); } } // Re-enable vote bar buildVoteBar(n); // Focus input for next prompt const ta = document.getElementById('message'); if (ta) ta.focus(); } export { registerPaneActions, stopAll, stopPane, rerollPane, toggleExpandPane, togglePanePreview, _autoPreviewHtml, _extractHtmlFromText, copyPaneResponse, _addPane, _createAndAppendPane, _removePane, _showModelSwapDropdown, shufflePanePositions, resetCompare, mountMobilePaneTabs, syncShuffleButtonPlacement, activateMobilePane, refreshMobilePaneTabs, paneSettingsButtonHtml, togglePaneSettings, };