Files
odysseus/static/app.js
2026-06-02 23:52:22 +09:00

4113 lines
172 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================
// Odysseus UI — Main Application Orchestrator
// ES6 module — entry point, no exports (wires all modules together)
// ============================================
import Storage from './js/storage.js';
import uiModule from './js/ui.js';
import fileHandlerModule from './js/fileHandler.js';
import modelsModule from './js/models.js';
import ragModule from './js/rag.js';
import presetsModule from './js/presets.js';
import searchModule from './js/search.js';
import chatModule from './js/chat.js';
import compareModule from './js/compare/index.js';
import documentModule from './js/document.js';
import searchChatModule from './js/search-chat.js';
import markdownModule from './js/markdown.js';
import chatRenderer from './js/chatRenderer.js';
import sessionModule from './js/sessions.js';
import memoryModule from './js/memory.js';
import voiceRecorderModule from './js/voiceRecorder.js';
import censorModule from './js/censor.js';
import galleryModule from './js/gallery.js';
import tasksModule from './js/tasks.js';
import calendarModule from './js/calendar.js';
import notesModule from './js/notes.js';
import adminModule from './js/admin.js';
import settingsModule from './js/settings.js';
// Eagerly bind unified minimize/restore behavior across all tool modals.
import './js/modalManager.js';
// Desktop window tiling — drag a modal near an edge/corner to snap.
import './js/tileManager.js';
import themeModule from './js/theme.js';
// IMPORTANT: import cookbook.js with NO ?v= query — the same plain specifier
// every other importer (cookbook-hwfit.js / cookbook-diagnosis.js) uses. A query
// mismatch makes the browser load cookbook.js twice as separate modules (two
// _envState objects), which broke server selection. Keep all cookbook imports
// unversioned so this can't recur.
import cookbookModule from './js/cookbook.js';
import groupModule from './js/group.js';
import * as researchPanelModule from './js/research/panel.js';
import ttsModule from './js/tts-ai.js';
import spinnerModule from './js/spinner.js';
import { initKeyboardShortcuts } from './js/keyboard-shortcuts.js';
import { initSidebarLayout, syncRailSide } from './js/sidebar-layout.js';
import { initSectionCollapse, initSectionDrag } from './js/section-management.js';
const API_BASE = window.location.origin;
window.themeModule = themeModule;
window.sessionModule = sessionModule;
window.uiModule = uiModule;
window.adminModule = adminModule;
window.cookbookModule = cookbookModule;
// Redirect to login on 401 from any fetch
const _origFetch = window.fetch;
window.fetch = async function(...args) {
const res = await _origFetch.apply(this, args);
if (res.status === 401 && !String(args[0]).includes('/api/auth/')) {
window.location.href = '/login';
}
return res;
};
// Search settings
const el = uiModule.el;
// Default chat config — refreshed on every new-chat action so settings
// changes take effect immediately (previously cached once at page load and
// went stale when the user changed their default model).
let _defaultChat = null;
async function _refreshDefaultChat() {
try {
const d = await (await fetch('/api/default-chat')).json();
if (d && d.endpoint_url && d.model) {
_defaultChat = d;
try { window.__odysseusDefaultChat = d; } catch (_) {}
return d;
}
} catch (_) {}
return null;
}
// Prime the cache once at load for initial paint paths that read _defaultChat
// synchronously; later reads should call _refreshDefaultChat() first.
_refreshDefaultChat();
async function _createDirectChatFromPreferredModel() {
if (!sessionModule) return false;
const pending = sessionModule.getPendingChat && sessionModule.getPendingChat();
if (pending && pending.url && pending.modelId) {
sessionModule.createDirectChat(pending.url, pending.modelId, pending.endpointId);
return true;
}
const sessions = sessionModule.getSessions();
const currentId = sessionModule.getCurrentSessionId();
const current = sessions.find(s => s.id === currentId);
if (current && current.endpoint_url && current.model) {
sessionModule.createDirectChat(current.endpoint_url, current.model, current.endpoint_id);
return true;
}
const dc = await _refreshDefaultChat();
if (dc) {
sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id);
return true;
}
const withModel = sessions.filter(s => s.endpoint_url && s.model);
if (withModel.length > 0) {
const last = withModel[0]; // sessions are sorted by recent
sessionModule.createDirectChat(last.endpoint_url, last.model, last.endpoint_id);
return true;
}
return false;
}
// ============================================
// EVENT LISTENERS INITIALIZATION
// ============================================
function initializeEventListeners() {
// Chat form submission
// document.getElementById('chat-form').addEventListener('submit', chatModule.handleChatSubmit);
// File attachments (inside overflow menu)
const _overflowAttach = el('overflow-attach-btn');
if (_overflowAttach) _overflowAttach.addEventListener('click', fileHandlerModule.openPicker);
el('file-input').addEventListener('change', (e)=>{
for (const f of e.target.files) fileHandlerModule.addFiles([f]);
fileHandlerModule.renderAttachStrip();
// Refocus textarea after file picker closes (mobile keyboard)
const ta = el('message');
if (ta) setTimeout(() => ta.focus(), 100);
});
// Paste handler
window.addEventListener('paste', async (e)=>{
if (!e.clipboardData) return;
let changed = false;
for (const item of e.clipboardData.items){
if (item.kind === 'file'){
const f = item.getAsFile();
if (f) {
fileHandlerModule.addFiles([f]);
changed = true;
}
}
}
if (changed) fileHandlerModule.renderAttachStrip();
});
// Message count in the header — recount on any DOM change in
// #chat-history and write "· N msgs" next to the title. Counts top-
// level .msg elements (one per user/assistant turn); excludes the
// welcome screen since it isn't inside chat-history.
const _metaCountEl = el('current-meta-count');
const _chatHistEl = el('chat-history');
if (_metaCountEl && _chatHistEl) {
let _countScheduled = false;
const _updateMsgCount = () => {
_countScheduled = false;
const n = _chatHistEl.querySelectorAll(':scope > .msg').length;
_metaCountEl.textContent = n ? `· ${n} msg${n === 1 ? '' : 's'}` : '';
};
const _scheduleCount = () => {
if (_countScheduled) return;
_countScheduled = true;
requestAnimationFrame(_updateMsgCount);
};
new MutationObserver(_scheduleCount).observe(_chatHistEl, { childList: true });
_updateMsgCount();
}
// Scrolling
el('chat-history').addEventListener('scroll', uiModule.debounce(() => {
const box = el('chat-history');
const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 80;
uiModule.setAutoScroll(atBottom);
}, 100));
// Close all footer popups immediately on any scroll
el('chat-history').addEventListener('scroll', () => {
document.querySelectorAll('.ctx-popup, .memory-used-detail, .msg-overflow-menu').forEach(p => p.remove());
document.querySelectorAll('.memory-used-pill').forEach(p => { p._openDetail = null; });
}, { passive: true });
el('chat-history').addEventListener('wheel', (e) => {
// Only disable auto-scroll when user scrolls UP (deltaY < 0)
if (e.deltaY < 0) uiModule.setAutoScroll(false);
});
let _touchThrottled = false;
el('chat-history').addEventListener('touchmove', () => {
if (_touchThrottled) return;
_touchThrottled = true;
uiModule.setAutoScroll(false);
requestAnimationFrame(() => { _touchThrottled = false; });
}, { passive: true });
// Internal #session-id links from AI search results
el('chat-history').addEventListener('click', (e) => {
const link = e.target.closest('a.chat-link');
if (!link) return;
const href = link.getAttribute('href');
if (href && href.startsWith('#') && sessionModule) {
e.preventDefault();
sessionModule.selectSession(href.slice(1));
}
});
// Export dropdown button
const exportDlBtn = el('export-dl-btn');
// ── Unified popup dismissal ──
// Lightweight popups (header dropdowns, kebab menus, pickers) should vanish
// on any "other action" — opening the sidebar, opening a tool window, etc.
// Each popup used to wire its own outside-click/Escape close but missed
// non-click actions. closeAllPopups() centralizes it: toggled menus drop
// their `.open`; ephemeral body-appended menus are removed. Full modals/
// windows are deliberately NOT touched here — those close via their own
// controls.
window.closeAllPopups = function closeAllPopups(except) {
document.querySelectorAll(
'.export-dropdown-menu.open, .overflow-menu.open, .model-picker-menu.open, .doc-overflow-menu.open'
).forEach(m => { if (m !== except) m.classList.remove('open'); });
document.querySelectorAll(
'.skill-kebab-menu, .note-reminder-menu, .task-dropdown, .doclib-card-dropdown, .email-card-dropdown, .msg-overflow-menu'
).forEach(m => { if (m !== except) m.remove(); });
};
// Window-opening / nav controls (rail buttons, sidebar tool rows + session
// rows, section headers) count as "other actions" — dismiss popups when one
// is clicked. Bubble phase so it runs after the control's own handler (the
// window is already opening; we just clear stray popups). Popup triggers
// themselves aren't these selectors, so toggles aren't broken.
document.addEventListener('click', (e) => {
if (e.target.closest('.icon-rail-btn, #sidebar .list-item, .section-header-flex')) {
window.closeAllPopups();
}
});
const exportMenu = el('export-dropdown-menu');
if (exportDlBtn && exportMenu) {
exportDlBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (exportMenu.classList.contains('open')) {
exportMenu.classList.remove('open');
} else {
// Move menu to body so it's not affected by ancestor transforms
if (exportMenu.parentElement !== document.body) document.body.appendChild(exportMenu);
const rect = exportDlBtn.getBoundingClientRect();
exportMenu.style.top = (rect.bottom + 4) + 'px';
exportMenu.style.left = 'auto';
exportMenu.style.right = (window.innerWidth - rect.right) + 'px';
exportMenu.classList.add('open');
}
});
document.addEventListener('click', () => exportMenu.classList.remove('open'));
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && exportMenu.classList.contains('open')) {
exportMenu.classList.remove('open');
}
});
// Opening the sidebar should dismiss any open popup. Many code paths open
// the sidebar (toggle button, swipe, keyboard, rail), so watch its class
// for a hidden→visible transition rather than hooking each one.
const _sidebarEl = el('sidebar');
if (_sidebarEl) {
let _wasHidden = _sidebarEl.classList.contains('hidden');
new MutationObserver(() => {
const nowHidden = _sidebarEl.classList.contains('hidden');
if (_wasHidden && !nowHidden) window.closeAllPopups();
_wasHidden = nowHidden;
}).observe(_sidebarEl, { attributes: true, attributeFilter: ['class'] });
}
// Clicking session name also opens dropdown
const currentMeta = el('current-meta');
if (currentMeta) {
currentMeta.style.cursor = 'pointer';
currentMeta.addEventListener('click', (e) => {
e.stopPropagation();
exportDlBtn.click();
});
}
}
// Serialize the current chat history into a plain-text transcript.
// Includes user messages, assistant rounds, and agent tool calls in DOM order.
function _serializeChatTranscript() {
const box = document.getElementById('chat-history');
if (!box) return '';
const parts = [];
for (const child of box.children) {
if (child.classList?.contains('msg')) {
const isUser = child.classList.contains('msg-user');
let label;
if (isUser) {
label = 'User';
} else {
const roleEl = child.querySelector('.role');
const ts = roleEl?.querySelector('.role-timestamp');
let raw = roleEl ? roleEl.textContent : '';
if (ts) raw = raw.replace(ts.textContent, '');
label = (raw || '').trim() || 'Assistant';
}
const body = child.querySelector('.body');
const text = body ? (body.innerText || body.textContent || '').trim() : '';
if (text) parts.push(`${label}: ${text}`);
} else if (child.classList?.contains('agent-thread')) {
const lines = ['[Tool calls]'];
for (const n of child.querySelectorAll('.agent-thread-node')) {
const tool = n.querySelector('.agent-thread-tool')?.textContent?.trim() || 'tool';
const cmd = n.querySelector('.agent-thread-cmd')?.textContent?.trim() || '';
const output = n.querySelector('.agent-tool-output pre')?.textContent?.trim() || '';
const status = n.classList.contains('error') ? 'failed' : 'done';
let line = `- ${tool} [${status}]`;
if (cmd) line += `\n cmd: ${cmd}`;
if (output) {
const truncated = output.length > 2000 ? output.slice(0, 2000) + '…' : output;
line += `\n out: ${truncated}`;
}
lines.push(line);
}
parts.push(lines.join('\n'));
}
}
return parts.join('\n\n');
}
// Export: Copy all messages
const exportCopyBtn = el('export-copy-btn');
if (exportCopyBtn) {
exportCopyBtn.addEventListener('click', async (e) => {
e.stopPropagation();
exportMenu.classList.remove('open');
const transcript = _serializeChatTranscript();
// A new/empty chat has nothing to copy — don't write an empty string and
// falsely report "Copied".
if (!transcript.trim()) { uiModule.showToast('Nothing to copy yet'); return; }
await uiModule.copyToClipboard(transcript);
});
}
// Export: PDF
const exportPdfBtn = el('export-pdf-btn');
if (exportPdfBtn) {
exportPdfBtn.addEventListener('click', (e) => {
e.stopPropagation();
exportMenu.classList.remove('open');
const meta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
const sessionName = meta ? meta.name : 'Odysseus Chat';
const originalTitle = document.title;
document.title = sessionName;
const chatHistory = document.getElementById('chat-history');
if (chatHistory) chatHistory.dataset.printTitle = sessionName;
document.querySelectorAll('#chat-history details:not([open])').forEach(d => {
d.setAttribute('open', '');
d.dataset.printOpened = '1';
});
window.print();
document.title = originalTitle;
document.querySelectorAll('#chat-history details[data-print-opened]').forEach(d => {
d.removeAttribute('open');
d.removeAttribute('data-print-opened');
});
});
}
// Export: Save to Docs
const exportDocBtn = el('export-doc-btn');
if (exportDocBtn) {
exportDocBtn.addEventListener('click', async (e) => {
e.stopPropagation();
exportMenu.classList.remove('open');
try {
const sessionId = sessionModule.getCurrentSessionId();
const texts = _serializeChatTranscript();
const meta = sessionModule.getSessions().find(s => s.id === sessionId);
const title = meta?.name || 'Untitled';
const res = await fetch(`${API_BASE}/api/document`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, title, content: texts }),
});
if (!res.ok) throw new Error('Failed');
const doc = await res.json();
if (documentModule) documentModule.loadDocument(doc.id);
uiModule.showToast('Saved to documents');
} catch (err) {
console.error('Save to docs failed:', err);
uiModule.showError('Failed to save to documents');
}
});
}
// Rename session from top bar
const exportRenameBtn = el('export-rename-btn');
if (exportRenameBtn) {
exportRenameBtn.addEventListener('click', (e) => {
e.stopPropagation();
exportMenu.classList.remove('open');
let sid = sessionModule.getCurrentSessionId();
// A brand-new chat has no session id yet — still allow renaming if there's
// a pending chat (we materialize it on commit so the name sticks).
const hasPending = sessionModule.hasPendingChat && sessionModule.hasPendingChat();
if (!sid && !hasPending) return;
const meta = sid ? sessionModule.getSessions().find(s => s.id === sid) : null;
const currentName = meta?.name || '';
const metaEl = el('current-meta');
if (!metaEl) return;
// Replace title with an input
const input = document.createElement('input');
input.type = 'text';
input.value = currentName;
input.className = 'session-rename-input';
input.style.cssText = 'font-size:inherit;background:transparent;border:none;border-bottom:1px solid var(--accent, var(--red));color:var(--fg);outline:none;width:100%;padding:0;';
const origText = metaEl.textContent;
metaEl.textContent = '';
metaEl.appendChild(input);
input.focus();
input.select();
const commit = async () => {
const newName = input.value.trim();
if (newName && newName !== currentName) {
// Materialize a pending (new) chat first so it has an id to rename.
if (!sid && sessionModule.materializePendingSession) {
try { await sessionModule.materializePendingSession(); sid = sessionModule.getCurrentSessionId(); } catch (_) {}
}
if (!sid) { metaEl.textContent = newName; return; }
const fd = new FormData();
fd.append('name', newName);
await fetch(`${API_BASE}/api/session/${sid}`, { method: 'PATCH', body: fd });
const _m = sessionModule.getSessions().find(s => s.id === sid);
if (_m) _m.name = newName;
metaEl.textContent = newName;
uiModule.showToast('Renamed');
sessionModule.loadSessions();
} else {
metaEl.textContent = origText;
}
};
input.addEventListener('blur', commit);
input.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); }
if (ev.key === 'Escape') { input.removeEventListener('blur', commit); metaEl.textContent = origText; }
});
});
}
// Custom preset modal handlers
const closeCustomPreset = el('close-custom-preset');
const cancelCustomPreset = el('cancel-custom-preset');
const saveCustomPreset = el('save-custom-preset');
if (closeCustomPreset) {
closeCustomPreset.addEventListener('click', () => {
el('custom-preset-modal').classList.add('hidden');
});
}
if (cancelCustomPreset) {
cancelCustomPreset.addEventListener('click', () => {
el('custom-preset-modal').classList.add('hidden');
});
}
if (saveCustomPreset) {
saveCustomPreset.addEventListener('click', async () => {
// Skip character save when Group tab is active — group.js handles it
const activeTab = document.querySelector('.preset-tab.active');
if (activeTab && activeTab.dataset.chartab === 'group') return;
await presetsModule.saveCustomPreset(uiModule.showToast, uiModule.showError);
});
}
// Settings dropdown removed — items are now inline in sidebar section
// Close popups one by one with Escape key (topmost first)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// If a confirm dialog is open, let it handle the Escape
const confirmOverlay = document.getElementById('styled-confirm-overlay');
if (confirmOverlay && !confirmOverlay.classList.contains('hidden')) return;
// If editing a memory inline, cancel the edit instead of closing the modal
const editingMemory = document.querySelector('.memory-item-editing');
if (editingMemory) {
if (window.memoryModule) window.memoryModule.renderMemoryList();
return;
}
// Priority order: topmost overlay first. Close exactly one per press
// so a window stacked on another (e.g. scoreboard over compare) only
// dismisses the top one, not both.
// Scoreboard sits on top of the compare window — close it first.
const scoreboardOverlay = document.getElementById('scoreboard-overlay');
if (scoreboardOverlay) {
scoreboardOverlay.remove();
return;
}
if (searchChatModule && searchChatModule.isOpen()) {
searchChatModule.closeSearch();
return;
}
// Compare model selector
const cmpOverlay = document.getElementById('compare-model-overlay');
if (cmpOverlay) {
cmpOverlay.remove();
return;
}
// Theme popup
const themeModal = document.getElementById('theme-modal');
if (themeModal && !themeModal.classList.contains('hidden')) {
themeModule.closePopup();
return;
}
// Calendar owns a few inner Escape layers (settings panel, event form,
// then the calendar modal itself). Let calendar.js handle those instead
// of falling through to unrelated page-level fallbacks like document
// panel minimize.
const calendarModal = document.getElementById('calendar-modal');
if (calendarModal && !calendarModal.classList.contains('hidden') && getComputedStyle(calendarModal).display !== 'none') {
return;
}
// Close one modal at a time (last in DOM = topmost)
// Map modal id → sidebar list-item id to clear active state
const modalItemMap = {
'cookbook-modal': null,
'rename-session-modal': null,
'rename-ai-modal': null,
'custom-preset-modal': null,
'memory-modal': null,
};
// Dynamic modals (removed from DOM on close)
const dynamicModals = ['library-modal', 'archive-modal', 'doclib-modal', 'gallery-modal', 'tasks-modal'];
for (const id of dynamicModals) {
const m = document.getElementById(id);
if (id === 'gallery-modal') {
const editor = document.getElementById('gallery-editor-container');
const editing = !!window.__galleryEditLive || !!(
editor &&
getComputedStyle(editor).display !== 'none' &&
editor.querySelector('.gallery-editor')
);
if (editing) {
e.preventDefault();
e.stopImmediatePropagation();
return;
}
}
if (m) { dismissModal(m); return; }
}
for (const modalId of Object.keys(modalItemMap)) {
const modal = el(modalId);
if (modal && !modal.classList.contains('hidden')) {
dismissModal(modal);
return;
}
}
// No modals/popups open — minimize the document panel if open.
// Esc should tab the doc down to a dock chip (same as the chevron),
// NOT fully close it — closePanel('down') registers the chip +
// Modals.minimize so the doc is preserved and restorable.
if (documentModule && documentModule.isPanelOpen()) {
// If there's a text selection in the document editor, let Escape clear that first
const docTextarea = document.getElementById('doc-editor-textarea');
if (docTextarea && docTextarea.selectionStart !== docTextarea.selectionEnd) {
return;
}
documentModule.closePanel('down');
return;
}
}
});
// ── Shared modal dismiss helper ──
const _modalSidebarMap = {
'memory-modal': null,
'theme-modal': null,
};
const _dynamicModalIds = ['library-modal', 'archive-modal', 'doclib-modal', 'gallery-modal', 'tasks-modal'];
function dismissModal(modal) {
if (!modal || modal.classList.contains('hidden')) return;
if (modal.id === 'gallery-modal') {
const editor = document.getElementById('gallery-editor-container');
const editing = !!window.__galleryEditLive || !!(
editor &&
getComputedStyle(editor).display !== 'none' &&
editor.querySelector('.gallery-editor')
);
if (editing) return;
}
const content = modal.querySelector('.modal-content') || modal.querySelector('#theme-popup');
if (content && !content.classList.contains('modal-closing')) {
content.classList.remove('sheet-ready');
content.style.transform = '';
content.style.transition = '';
content.classList.add('modal-closing');
content.addEventListener('animationend', () => {
if (_dynamicModalIds.includes(modal.id)) {
modal.remove();
} else {
modal.classList.add('hidden');
content.classList.remove('modal-closing');
}
}, { once: true });
// Fallback in case animationend doesn't fire
setTimeout(() => {
if (modal.parentElement && !modal.classList.contains('hidden')) {
if (_dynamicModalIds.includes(modal.id)) modal.remove();
else { modal.classList.add('hidden'); content.classList.remove('modal-closing'); }
}
}, 250);
} else {
if (content) content.classList.remove('sheet-ready');
if (_dynamicModalIds.includes(modal.id)) modal.remove();
else modal.classList.add('hidden');
}
}
// Click outside modal content → close modal
document.addEventListener('click', (e) => {
if (uiModule.isTouchInsideModal()) return; // suppress synthetic events from touch scrolling
const modal = e.target.closest('.modal');
if (!modal || modal.classList.contains('hidden')) return;
if (e.target.closest('.modal-content')) return;
dismissModal(modal);
});
// Mobile bottom-sheet swipe-to-dismiss is handled by ui.js (header-only)
// ── Helper: start a fresh chat (deselect current, clear history, show welcome) ──
function _startFreshChat() {
try {
const prevId = sessionModule && sessionModule.getCurrentSessionId ? sessionModule.getCurrentSessionId() : null;
if (chatModule && chatModule.detachCurrentStream) chatModule.detachCurrentStream(prevId);
else if (chatModule && chatModule.abortCurrentRequest) chatModule.abortCurrentRequest();
} catch (e) {
console.warn('fresh chat stream detach failed:', e);
}
if (sessionModule) sessionModule.setCurrentSessionId(null);
const box = el('chat-history');
if (box) box.innerHTML = '';
if (chatModule && chatModule.showWelcomeScreen) {
chatModule.showWelcomeScreen();
}
// Close document panel if open
if (documentModule && documentModule.closePanel) documentModule.closePanel();
if (researchPanelModule && researchPanelModule.isOpen()) researchPanelModule.closePanel();
// Reset research overflow dot (but don't touch research state — caller manages that)
const _overflowRes = el('overflow-research-btn');
if (_overflowRes) _overflowRes.classList.remove('active');
if (typeof updatePlusDot === 'function') updatePlusDot();
// Reset agent mode to Chat
const modeToggle = el('agent-mode-toggle');
if (modeToggle && modeToggle.checked) { modeToggle.checked = false; modeToggle.dispatchEvent(new Event('change')); }
// Clear character/persona
if (presetsModule && presetsModule.deactivateCharacter) presetsModule.deactivateCharacter();
}
/** Sync Research indicator button + overflow + tool sidebar active state. */
function _syncResearchIndicator(active) {
const btn = el('research-toggle-btn');
const overflow = el('overflow-research-btn');
const toolBtn = el('tool-research-btn');
const chk = el('research-toggle');
if (btn) {
btn.style.display = active ? '' : 'none';
btn.classList.toggle('active', active);
}
// Hide from overflow menu when showing in chatbox (avoid duplicate)
if (overflow) {
overflow.classList.toggle('active', active);
overflow.style.display = active ? 'none' : '';
}
if (toolBtn) toolBtn.classList.toggle('active', active);
if (chk) chk.checked = active;
// Research disables shell access
const bashChk = el('bash-toggle');
const bashBtn = el('bash-toggle-btn');
if (active) {
if (bashChk && bashChk.checked) {
bashChk.checked = false;
if (bashBtn) bashBtn.classList.remove('active');
saveToolPref('bash', (loadToggleState().mode || 'chat'), false);
}
}
const s = loadToggleState(); s.research = active; saveToggleState(s);
updatePlusDot();
document.dispatchEvent(new CustomEvent('overflow-state-change'));
}
/** Sync Group Chat indicator button + overflow. */
function _syncGroupIndicator(active) {
const btn = el('group-toggle-btn');
const overflow = el('overflow-group-btn');
const chk = el('group-toggle');
if (btn) {
btn.style.display = active ? '' : 'none';
btn.classList.toggle('active', active);
}
if (overflow) {
overflow.classList.toggle('active', active);
overflow.style.display = active ? 'none' : '';
}
if (chk) chk.checked = active;
// Hide/show model picker
const _mpw = el('model-picker-wrap');
if (_mpw) _mpw.style.display = active ? 'none' : '';
// Mutual exclusion: group disables research + web search
if (active) {
_syncResearchIndicator(false);
const _webChk = el('web-toggle');
if (_webChk && _webChk.checked) {
_webChk.checked = false;
saveToolPref('web', (loadToggleState().mode || 'chat'), false);
}
}
const s = loadToggleState(); s.group = active; saveToggleState(s);
updatePlusDot();
document.dispatchEvent(new CustomEvent('overflow-state-change'));
// Update welcome screen for research mode
const ws = el('welcome-screen');
const welcomeName = document.querySelector('.welcome-name');
const welcomeSub = el('welcome-sub');
const tipEl = el('welcome-tip');
const _resIco = '<svg class="welcome-boat" style="position:relative;top:0.5px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>';
if (active) {
if (welcomeName) {
if (!welcomeName.dataset.researchOrigHtml) welcomeName.dataset.researchOrigHtml = welcomeName.innerHTML;
welcomeName.innerHTML = _resIco + 'Deep Research';
}
if (welcomeSub) {
if (!welcomeSub.dataset.researchOrigText) welcomeSub.dataset.researchOrigText = welcomeSub.textContent;
welcomeSub.textContent = 'Deep multi-step research with source gathering and synthesis.';
}
if (tipEl) {
if (!tipEl.dataset.researchOrigTip) tipEl.dataset.researchOrigTip = tipEl.textContent;
tipEl.textContent = '';
tipEl.style.display = 'none';
}
// Hide Nobody toggle during research mode
const _incBtn = el('incognito-btn');
if (_incBtn) { _incBtn.dataset.researchOrigDisplay = _incBtn.style.display; _incBtn.style.display = 'none'; }
// Close document panel if open
if (window.documentModule && window.documentModule.isPanelOpen()) {
window.documentModule.closePanel();
}
} else {
if (welcomeName && welcomeName.dataset.researchOrigHtml) {
welcomeName.innerHTML = welcomeName.dataset.researchOrigHtml;
delete welcomeName.dataset.researchOrigHtml;
}
if (welcomeSub && welcomeSub.dataset.researchOrigText) {
welcomeSub.textContent = welcomeSub.dataset.researchOrigText;
delete welcomeSub.dataset.researchOrigText;
}
if (tipEl && tipEl.dataset.researchOrigTip) {
tipEl.textContent = tipEl.dataset.researchOrigTip;
tipEl.style.opacity = '';
tipEl.style.display = '';
delete tipEl.dataset.researchOrigTip;
}
// Restore Nobody toggle
const _incBtn2 = el('incognito-btn');
if (_incBtn2 && _incBtn2.dataset.researchOrigDisplay !== undefined) {
_incBtn2.style.display = _incBtn2.dataset.researchOrigDisplay;
delete _incBtn2.dataset.researchOrigDisplay;
}
}
if (ws) { ws.style.animation = 'none'; ws.offsetHeight; ws.style.animation = 'welcome-enter 0.3s ease-out both'; }
}
// ── Close compare if active (used by all tool/sidebar activations) ──
// Returns true if compare was active (page will reload), caller should return early
function _closeCompareIfActive() {
if (compareModule && compareModule.isActive()) {
compareModule.deactivate(true);
return true;
}
return false;
}
// ── Tools section click handlers ──
const toolCompareBtn = el('tool-compare-btn');
if (toolCompareBtn) {
toolCompareBtn.addEventListener('click', () => {
if (compareModule) {
if (compareModule.isActive()) {
// Already active — toggle off
compareModule.toggleMode();
return;
}
// Close other exclusive tools before opening compare
const resChk = el('research-toggle');
if (resChk && resChk.checked) {
_syncResearchIndicator(false);
}
_startFreshChat();
compareModule.toggleMode();
}
});
}
const toolResearchBtn = el('tool-research-btn');
if (toolResearchBtn) {
toolResearchBtn.addEventListener('click', () => {
researchPanelModule.toggle();
});
}
// ── Cookbook modal toggle ──
const toolCookbookBtn = el('tool-cookbook-btn');
if (toolCookbookBtn) {
toolCookbookBtn.addEventListener('click', async () => {
if (!cookbookModule) return;
// Try minimized→restore or open→minimize via the manager first
const Modals = await import('./js/modalManager.js');
if (!Modals.toggle('cookbook-modal')) {
// Not registered yet → fresh open
cookbookModule.open();
}
});
}
// Document library tool button
const toolDoclibBtn = el('tool-doclib-btn');
if (toolDoclibBtn) {
toolDoclibBtn.addEventListener('click', () => {
if (_closeCompareIfActive()) return;
if (documentModule) {
if (documentModule.isLibraryOpen()) {
documentModule.closeLibrary();
} else {
documentModule.openLibrary();
}
}
});
}
// Gallery tool button
const toolGalleryBtn = el('tool-gallery-btn');
if (toolGalleryBtn) {
toolGalleryBtn.addEventListener('click', async () => {
if (!galleryModule) return;
const Modals = await import('./js/modalManager.js');
if (!Modals.toggle('gallery-modal')) {
if (galleryModule.isGalleryOpen()) galleryModule.closeGallery();
else galleryModule.openGallery();
}
});
}
// Tasks tool button
const toolTasksBtn = el('tool-tasks-btn');
if (toolTasksBtn) {
// Agents buttons (sidebar + rail)
const agentsBtns = [el("rail-agents"), el("tool-agents-btn")].filter(Boolean);
agentsBtns.forEach(btn => {
btn.addEventListener("click", () => {
});
});
toolTasksBtn.addEventListener('click', () => {
if (tasksModule) {
tasksModule.isTasksOpen() ? tasksModule.closeTasks() : tasksModule.openTasks();
}
});
}
// Calendar tool button
const toolCalendarBtn = el('tool-calendar-btn');
if (toolCalendarBtn) {
toolCalendarBtn.addEventListener('click', async () => {
if (!calendarModule) return;
const Modals = await import('./js/modalManager.js');
// toggle returns true when a registered modal was minimized/restored;
// returns false when nothing is registered → open fresh.
if (!Modals.toggle('calendar-modal')) {
if (calendarModule.isCalendarOpen()) calendarModule.closeCalendar();
else calendarModule.openCalendar();
}
});
}
// Notes tool button
const toolNotesBtn = el('tool-notes-btn');
if (toolNotesBtn) {
toolNotesBtn.addEventListener('click', () => {
if (notesModule) {
notesModule.togglePanel();
}
});
}
// Refresh notes due-reminder badge on load and every 5 minutes
if (notesModule && notesModule.refreshDueBadge) {
notesModule.refreshDueBadge();
setInterval(() => notesModule.refreshDueBadge(), 5 * 60 * 1000);
}
// URL-based panel routing — bookmark /calendar, /notes, /cookbook etc
// and the matching tool opens automatically on page load.
const urlPath = window.location.pathname;
// Current width of the always-visible icon rail. The rail is resizable
// and hides on narrow viewports, so read it live each call rather than
// baking 48px in. Returns 0 when the rail isn't rendered.
const _iconRailWidth = () => {
const r = document.getElementById('icon-rail');
if (!r) return 0;
const cs = window.getComputedStyle(r);
if (cs.display === 'none' || cs.visibility === 'hidden') return 0;
return Math.round(r.getBoundingClientRect().width);
};
// Collapse the wide sidebar so the icon rail (48px mini sidebar) shows
// in its place. The two are mutually exclusive — sidebar-layout.js:57
// only displays the rail when `.sidebar.hidden` is set. Used by /email
// and /notes route openers so those fullscreen views keep the rail
// visible as the user's navigation strip. Records the prior state on
// body so a paired close-handler can restore it without overriding a
// manual toggle the user did in between.
const _collapseSidebarToRail = () => {
const sb = document.getElementById('sidebar');
const rail = document.getElementById('icon-rail');
if (!sb || !rail) return;
const wasVisible = !sb.classList.contains('hidden');
if (wasVisible) {
document.body.dataset.routeCollapsedSidebar = '1';
}
sb.classList.add('hidden');
rail.classList.remove('rail-hidden');
// syncRailSide() flips iconRail.style.display based on the classes
// we just set. Exposed by sidebar-layout.js on window.
try { window.syncRailSide && window.syncRailSide(); } catch (_) {}
};
// Paired restore: if the route opener collapsed the sidebar, re-expand
// it when the fullscreen view closes. Only restores if the user didn't
// manually toggle in between (we clear the marker on manual hamburger
// clicks via a MutationObserver on `.sidebar.hidden`).
const _restoreSidebarIfRouteCollapsed = () => {
if (document.body.dataset.routeCollapsedSidebar !== '1') return;
delete document.body.dataset.routeCollapsedSidebar;
const sb = document.getElementById('sidebar');
if (!sb) return;
sb.classList.remove('hidden');
try { window.syncRailSide && window.syncRailSide(); } catch (_) {}
};
// Expose so closeEmailLibrary / notes close can call this without
// needing to import app.js directly.
window._restoreSidebarIfRouteCollapsed = _restoreSidebarIfRouteCollapsed;
// Clear the marker the moment the sidebar becomes visible again (user
// hamburger click, or our own _restoreSidebarIfRouteCollapsed call —
// both endpoints are the same observable state change).
{
const sb = document.getElementById('sidebar');
if (sb && typeof MutationObserver !== 'undefined') {
new MutationObserver(() => {
if (!sb.classList.contains('hidden')) {
delete document.body.dataset.routeCollapsedSidebar;
}
}).observe(sb, { attributes: true, attributeFilter: ['class'] });
}
}
const _routeOpen = {
'/notes': () => {
if (!notesModule) return;
_collapseSidebarToRail();
notesModule.openPanel();
// Promote to fullscreen-with-rail-visible. The pane wires up its own
// fullscreen toggle (#notes-fullscreen-toggle); piggyback on that
// path so the button icon flips and overflow:hidden gets applied
// alongside. Retry on rAF in case the panel mounts a tick later.
const _go = () => {
const btn = document.getElementById('notes-fullscreen-toggle');
const pane = document.querySelector('.notes-pane');
if (!pane) return false;
if (!pane.classList.contains('notes-pane-fullscreen') && btn) btn.click();
return true;
};
if (!_go()) {
requestAnimationFrame(_go);
setTimeout(_go, 50);
setTimeout(_go, 200);
}
},
'/calendar': () => calendarModule && calendarModule.openCalendar(),
'/cookbook': () => document.getElementById('tool-cookbook-btn')?.click(),
'/email': () => {
// Collapse the wide sidebar → icon rail (48px) so the user keeps
// navigation visible alongside the fullscreen email view.
_collapseSidebarToRail();
// Spawn a fresh chat first so a reply (or any AI work the user
// chains off the email) lives in its own session instead of grafting
// onto whatever was last open. The rail button has the full
// default-chat / fallback-model resolution logic baked in, so just
// delegate to it.
try { document.getElementById('rail-new-session')?.click(); } catch (_) {}
// The email library is opened by clicking the email section's HEADER
// row (.section-header-flex), not the title span. Trigger that, then
// snap the modal to fullscreen on the next frame.
const hdr = document.querySelector('#email-section .section-header-flex');
if (hdr) hdr.click();
// The modal is built synchronously inside openEmailLibrary, so a
// single frame later it's in the DOM and ready to be flagged.
// Fullscreen leaves the icon-rail visible on the left so navigation
// stays one click away (per #93). Width = viewport minus rail.
// Just add the class — the CSS rule for .email-lib-fullscreen .modal-content
// owns all the positioning (with !important so it beats openEmailLibrary's
// post-mount centering rAF) and reads the rail width from --icon-rail-w.
const _goFullscreen = () => {
const modal = document.getElementById('email-lib-modal');
if (!modal) return false;
modal.classList.add('email-lib-fullscreen');
return true;
};
_goFullscreen();
requestAnimationFrame(_goFullscreen);
setTimeout(_goFullscreen, 50);
setTimeout(_goFullscreen, 200);
},
'/memory': () => document.getElementById('tool-memory-btn')?.click(),
'/gallery': () => document.getElementById('tool-gallery-btn')?.click(),
'/tasks': () => document.getElementById('tool-tasks-btn')?.click(),
'/library': () => sessionModule && sessionModule.openLibrary && sessionModule.openLibrary(),
};
const _opener = _routeOpen[urlPath];
// Defer the opener — at this point in init, the modules whose handlers
// we trigger (#rail-new-session click handler, the email-section header
// click handler in emailInbox, sessionModule's loaded session list) are
// still being wired up further down in this same function. Stash the
// opener so it runs from sessionModule.loadSessions().finally() below.
if (_opener) window._odysseusRouteOpener = _opener;
// Archive browser tool button
const toolLibraryBtn = el('tool-library-btn');
if (toolLibraryBtn) {
toolLibraryBtn.addEventListener('click', () => {
if (sessionModule) sessionModule.openLibrary();
});
}
// "+" on the Library row → create a new blank document and open it in the
// editor (mirrors the email section's compose "+"). stopPropagation so it
// doesn't also fire the row's open-library click.
const libraryNewDocBtn = el('library-new-doc-btn');
if (libraryNewDocBtn) {
libraryNewDocBtn.addEventListener('click', async (e) => {
e.stopPropagation();
try {
if (documentModule && documentModule.newDocument) await documentModule.newDocument();
} catch (err) {
console.error('New document from Library failed:', err);
if (uiModule && uiModule.showError) uiModule.showError('Could not create document');
}
});
}
// Manage Chats — opens Full Library modal (decoupled from Chats accordion toggle)
const chatsLibraryBtn = el('chats-library-btn');
if (chatsLibraryBtn) {
chatsLibraryBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (sessionModule) sessionModule.openLibrary('chats');
});
}
const toolArchiveBtn = el('tool-archive-btn');
if (toolArchiveBtn) {
toolArchiveBtn.addEventListener('click', () => {
if (sessionModule) sessionModule.openLibrary('archive');
});
}
const toolThemeBtn = el('tool-theme-btn');
if (toolThemeBtn) {
toolThemeBtn.addEventListener('click', () => {
const tm = document.getElementById('theme-modal');
if (tm) tm.classList.remove('hidden');
});
}
// Sidebar toggle
const toggleSidebarOption = el('toggle-sidebar-option');
if (toggleSidebarOption) {
toggleSidebarOption.addEventListener('click', () => {
const sidebar = el('sidebar');
sidebar.classList.toggle('hidden');
});
}
// Sidebar user bar — settings, admin, profile
const userBarSettings = el('user-bar-settings');
const userBarProfile = el('user-bar-profile');
const userBarAdmin = el('user-bar-admin');
if (userBarSettings) {
userBarSettings.addEventListener('click', () => settingsModule.open());
}
if (userBarProfile) {
// Clicking the user (avatar + name) jumps straight to the Account tab
// instead of landing on whatever was last selected.
userBarProfile.addEventListener('click', () => settingsModule.open('account'));
}
if (userBarAdmin) {
userBarAdmin.addEventListener('click', () => adminModule.open());
}
// Fetch auth status — populate user bar and show admin button if admin
fetch(`${API_BASE}/api/auth/status`, { credentials: 'same-origin' })
.then(r => r.json())
.then(d => {
window._isAdmin = !!d.is_admin;
if (d.is_admin && userBarAdmin) userBarAdmin.style.display = '';
const userBarName = el('user-bar-name');
const userBarAvatar = el('user-bar-avatar');
if (userBarName && d.username) {
let displayName = d.username;
// Mask email addresses
if (displayName.includes('@')) {
const [local, domain] = displayName.split('@');
const ext = domain.includes('.') ? domain.slice(domain.lastIndexOf('.')) : '';
displayName = local.charAt(0) + '•••@••••' + ext;
}
userBarName.textContent = displayName;
if (userBarAvatar) userBarAvatar.textContent = d.username.charAt(0).toUpperCase();
}
// Apply per-user privilege restrictions
if (d.privileges) {
window._userPrivileges = d.privileges;
const p = d.privileges;
// Hide agent mode toggle
if (!p.can_use_agent) {
const modeToggle = document.getElementById('mode-toggle');
if (modeToggle) modeToggle.closest('.chat-input-toggle')?.style.setProperty('display', 'none');
}
// Hide bash toggle
if (!p.can_use_bash) {
const bashToggle = document.getElementById('bash-toggle');
if (bashToggle) bashToggle.closest('.chat-input-toggle')?.style.setProperty('display', 'none');
const bashBtn = document.getElementById('tool-bash-btn');
if (bashBtn) bashBtn.style.display = 'none';
}
// Hide document button
if (!p.can_use_documents) {
const docBtn = document.getElementById('overflow-doc-btn');
if (docBtn) docBtn.style.display = 'none';
const docInd = document.getElementById('doc-indicator-btn');
if (docInd) docInd.style.display = 'none';
}
// Hide research toggle
if (!p.can_use_research) {
const resBtn = document.getElementById('research-toggle-btn');
if (resBtn) resBtn.style.display = 'none';
const resOverflow = document.getElementById('overflow-research-btn');
if (resOverflow) resOverflow.style.display = 'none';
}
// Hide image generation options
if (!p.can_generate_images) {
const imgBtn = document.getElementById('tool-image-btn');
if (imgBtn) imgBtn.style.display = 'none';
}
}
})
.catch(() => {});
// Session sort dropdown
const sortBtn = el('session-sort-btn');
const sortDropdown = el('session-sort-dropdown');
if (sortBtn && sortDropdown) {
sortBtn.addEventListener('click', (e) => {
e.stopPropagation();
sortDropdown.style.display = sortDropdown.style.display === 'block' ? 'none' : 'block';
});
document.addEventListener('click', () => { sortDropdown.style.display = 'none'; });
sortDropdown.addEventListener('click', (e) => e.stopPropagation());
// Sort mode options (newest, oldest, last active) — toggleable
sortDropdown.querySelectorAll('.sort-option').forEach(opt => {
opt.addEventListener('click', () => {
const mode = opt.dataset.sort;
const current = sessionModule.getSortMode();
// Toggle: clicking the active sort reverts to manual
if (current === mode) {
sessionModule.setSortMode(null);
sortDropdown.style.display = 'none';
uiModule.showToast('Manual order');
} else {
sessionModule.setSortMode(mode);
sortDropdown.style.display = 'none';
uiModule.showToast(`Sorted: ${opt.textContent.trim().toLowerCase()}`);
}
_syncSortChecks();
});
});
// Sync checkmarks on sort options
function _syncSortChecks() {
const current = sessionModule.getSortMode();
sortDropdown.querySelectorAll('.sort-option').forEach(o => {
const check = o.querySelector('.sort-check') || document.createElement('span');
check.className = 'sort-check';
check.style.cssText = 'float:right;font-size:20px;line-height:1;position:relative;top:3px;color:var(--accent, var(--red));opacity:' + (o.dataset.sort === current ? '1' : '0');
check.textContent = '\u2022';
if (!o.querySelector('.sort-check')) o.appendChild(check);
});
// Highlight filter icon when a sort is active
if (sortBtn) sortBtn.classList.toggle('active', !!current);
}
// Sync on dropdown open + initial load
sortBtn.addEventListener('click', _syncSortChecks);
_syncSortChecks();
// AI auto-sort — spinner on the sort button itself. Used by both
// the main "★ Tidy" button (AI) and the sub-row "Tidy" button
// (no AI, Phase 1 cleanup only) via the skipLlm flag.
async function _runTidy(skipLlm) {
const btnIcon = sortBtn.querySelector('.sort-icon');
if (btnIcon) btnIcon.style.display = 'none';
const wp = spinnerModule.create('', 'clean', 'whirlpool');
const wpEl = wp.createElement();
wpEl.style.cssText = 'width:13px;height:13px;display:inline-block;vertical-align:middle;margin-top:-5px;';
sortBtn.appendChild(wpEl);
wp.start();
sortDropdown.style.display = 'none';
try {
const url = `${API_BASE}/api/sessions/auto-sort${skipLlm ? '?skip_llm=true' : ''}`;
const res = await fetch(url, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Auto-sort failed');
if (data.status === 'ok') {
sessionModule.setSortMode(null); // clear sort — tidy creates manual folder order
_syncSortChecks();
if (skipLlm) {
// No-AI path: just report what got cleaned. No "unfiled
// remaining" prompt because we never tried to file anything.
const cleaned = (data.deleted_empty || 0) + (data.deleted_throwaway || 0);
uiModule.showToast(cleaned ? `Cleaned ${cleaned} empty/throwaway chat${cleaned === 1 ? '' : 's'}` : 'Already clean');
} else {
// Tidy now works in batches (15 most-recent unfiled per click)
// so the user gets fast feedback and a manageable LLM call
// even with hundreds of chats. Tell them what's left.
const remaining = data.unfiled_remaining || 0;
let msg;
if (data.updated > 0) {
msg = `Sorted ${data.updated} into ${data.folders.length} folder${data.folders.length === 1 ? '' : 's'}`;
if (remaining > 0) msg += `${remaining} unfiled left, hit Tidy again`;
} else if (remaining > 0) {
msg = `${remaining} unfiled chats — hit Tidy again`;
} else {
msg = 'All sorted';
}
uiModule.showToast(msg);
}
if (sessionModule) await sessionModule.loadSessions();
} else {
uiModule.showToast(data.reason || 'Nothing to sort');
}
} catch (e) {
uiModule.showError('Auto-sort: ' + e.message);
} finally {
wp.destroy();
if (wpEl.parentNode) wpEl.parentNode.removeChild(wpEl);
if (btnIcon) btnIcon.style.display = '';
}
}
const autoSortBtn = el('auto-sort-sessions-btn');
if (autoSortBtn) autoSortBtn.addEventListener('click', () => _runTidy(false));
// Chevron next to the Tidy row toggles the no-AI sub-item.
const autoSortMoreBtn = el('auto-sort-sessions-more');
const autoSortNoaiBtn = el('auto-sort-sessions-noai-btn');
if (autoSortMoreBtn && autoSortNoaiBtn) {
autoSortMoreBtn.addEventListener('click', (e) => {
e.stopPropagation();
autoSortNoaiBtn.style.display = autoSortNoaiBtn.style.display === 'none' ? 'block' : 'none';
});
autoSortNoaiBtn.addEventListener('click', () => _runTidy(true));
}
}
// Model sort dropdown
const modelSortBtn = el('model-sort-btn');
const modelSortDropdown = el('model-sort-dropdown');
if (modelSortBtn && modelSortDropdown) {
modelSortBtn.addEventListener('click', (e) => {
e.stopPropagation();
modelSortDropdown.style.display = modelSortDropdown.style.display === 'block' ? 'none' : 'block';
});
document.addEventListener('click', () => { modelSortDropdown.style.display = 'none'; });
modelSortDropdown.addEventListener('click', (e) => e.stopPropagation());
modelSortDropdown.querySelectorAll('.sort-option').forEach(opt => {
opt.addEventListener('click', () => {
const mode = opt.dataset.sort;
Storage.set('odysseus-model-sort', mode);
if (modelsModule) modelsModule.refreshModels();
modelSortDropdown.style.display = 'none';
uiModule.showToast('Models sorted: ' + opt.textContent.trim().toLowerCase());
});
});
}
// Feature visibility — hide admin-disabled features
// Use prefetched data from login page if available
const _prefetchedFeatures = sessionStorage.getItem('ody-prefetch-features');
sessionStorage.removeItem('ody-prefetch-features');
window._initFeaturesReady = (_prefetchedFeatures
? Promise.resolve(JSON.parse(_prefetchedFeatures))
: fetch(`${API_BASE}/api/auth/features`, { credentials: 'same-origin' }).then(r => r.json())
).then(features => {
const map = {
web_search: ['web-toggle-btn'],
deep_research: ['research-toggle-btn', 'tool-research-btn', 'overflow-research-btn', 'rail-research'],
document_editor: ['overflow-doc-btn', 'rail-documents'],
gallery: ['tool-gallery-btn', 'rail-gallery'],
};
Object.entries(map).forEach(([key, ids]) => {
if (features[key] === false) {
ids.forEach(id => { const e = el(id); if (e) e.style.display = 'none'; });
}
});
// Re-apply the user's Appearance UI-vis preferences after the
// features fetch finishes hiding things — otherwise an admin-
// disabled feature leaves the sidebar entry hidden even when the
// user's "Show in sidebar" toggle is on. The user has to toggle
// off then on to trigger applyUIVis a second time, which is the
// bug they report as "deep research only shows after I toggle".
try { if (window.applyUIVis && window.loadUIVis) window.applyUIVis(window.loadUIVis()); } catch (_) {}
})
.catch(() => {});
// Hide Gallery when image generation is disabled in settings
const _prefetchedSettings = sessionStorage.getItem('ody-prefetch-settings');
sessionStorage.removeItem('ody-prefetch-settings');
window._initSettingsReady = (_prefetchedSettings
? Promise.resolve(JSON.parse(_prefetchedSettings))
: fetch(`${API_BASE}/api/auth/settings`, { credentials: 'same-origin' }).then(r => r.json())
).then(settings => {
// NOTE: image_gen_enabled only governs *generating* images in chat — the
// tool is blocked server-side (chat_routes / agent_loop). The Gallery
// holds uploads and past images too, so it stays visible regardless;
// use the `gallery` feature flag to hide the Gallery entirely.
// Hide TTS overflow button when TTS is disabled or no provider configured
const ttsOff = settings.tts_enabled === false || !settings.tts_provider || settings.tts_provider === 'disabled';
const overflowTts = el('overflow-tts-btn');
if (overflowTts) {
overflowTts.style.display = ttsOff ? 'none' : '';
}
})
.catch(() => {});
// (Logout handler moved to sidebar user bar above)
// Rename AI modal
const renameAiOption = el('rename-ai-option');
const renameAiModal = el('rename-ai-modal');
const closeRenameAi = el('close-rename-ai');
const cancelRenameAi = el('cancel-rename-ai');
const saveAiName = el('save-ai-name');
const aiNameInput = el('ai-name-input');
if (renameAiOption) {
renameAiOption.addEventListener('click', () => {
const currentName = aiNameInput.value;
renameAiModal.classList.remove('hidden');
});
}
if (closeRenameAi) {
closeRenameAi.addEventListener('click', () => {
renameAiModal.classList.add('hidden');
});
}
if (cancelRenameAi) {
cancelRenameAi.addEventListener('click', () => {
renameAiModal.classList.add('hidden');
});
}
if (saveAiName) {
saveAiName.addEventListener('click', async () => {
const newName = aiNameInput.value.trim();
if (!newName) {
uiModule.showError('Please enter a name for the AI');
return;
}
try {
const response = await fetch(`${API_BASE}/api/ai/name`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name: newName })
});
const result = await response.json();
if (result.success) {
uiModule.showToast(`AI renamed to ${newName}`);
renameAiModal.classList.add('hidden');
aiNameInput.value = '';
}
} catch (e) {
uiModule.showError('Failed to rename AI: ' + e.message);
}
});
}
// Memory management
const memoryModal = el('memory-modal');
const closeMemoryBtn = el('close-memory-modal');
// Theme popup close button
const closeThemeBtn = el('close-theme-popup');
if (closeThemeBtn && themeModule) {
closeThemeBtn.addEventListener('click', () => {
themeModule.closePopup();
});
}
// Rename session modal
const renameSessionModal = el('rename-session-modal');
const closeRenameSession = el('close-rename-session');
const cancelRenameSession = el('cancel-rename-session');
const saveSessionName = el('save-session-name');
const sessionNameInput = el('session-name-input');
// Close handlers for rename session modal
if (closeRenameSession) {
closeRenameSession.addEventListener('click', () => {
renameSessionModal.classList.add('hidden');
});
}
if (cancelRenameSession) {
cancelRenameSession.addEventListener('click', () => {
renameSessionModal.classList.add('hidden');
});
}
if (saveSessionName) {
saveSessionName.addEventListener('click', async () => {
const newName = sessionNameInput.value.trim();
if (!newName) {
uiModule.showError('Please enter a name for the session');
return;
}
try {
const response = await fetch(`${API_BASE}/api/session/${sessionModule.getCurrentSessionId()}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name: newName })
});
const result = await response.json();
if (response.ok) {
uiModule.showToast(`Session renamed to ${newName}`);
renameSessionModal.classList.add('hidden');
sessionNameInput.value = '';
// Update the current session name in the UI
const meta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
if (meta) {
meta.name = newName;
const ver = window._appVersion ? ` v${window._appVersion}` : '';
el('current-meta').textContent = `Session: ${meta.name}${meta.model ? ' ' + meta.model.split('/').pop() : ''}${meta.rag ? ' [RAG]' : ''}${ver}`;
}
// Refresh the sessions list
await sessionModule.loadSessions();
} else {
throw new Error(result.detail || 'Failed to rename session');
}
} catch (e) {
uiModule.showError('Failed to rename session: ' + e.message);
}
});
}
if (closeMemoryBtn) {
closeMemoryBtn.addEventListener('click', () => {
dismissModal(memoryModal);
});
}
// Sidebar Memory button
const toolMemoryBtn = el('tool-memory-btn');
if (toolMemoryBtn && memoryModal) {
toolMemoryBtn.addEventListener('click', () => {
memoryModal.classList.remove('hidden');
if (memoryModule && memoryModule.renderMemoryList) memoryModule.renderMemoryList();
if (memoryModule && memoryModule.updateMemoryCount) memoryModule.updateMemoryCount();
});
}
const addMemBtn = el('add-memory-btn');
if (addMemBtn) {
addMemBtn.addEventListener('click', memoryModule.addNewMemory);
}
const memorySearchInput = el('memory-search');
if (memorySearchInput) {
memorySearchInput.addEventListener('input', () => {
memoryModule.renderMemoryList();
memoryModule.updateMemoryCount();
});
}
const newMemoryInput = el('new-memory-input');
if (newMemoryInput) {
newMemoryInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
memoryModule.addNewMemory();
}
});
}
// Voice recording is handled by the dual-purpose send/mic button (see below)
// ── Toggle persistence — delegates to Storage module ──
function loadToggleState() {
return Storage.loadToggleState();
}
function saveToggleState(state) {
Storage.saveToggleState(state);
}
// Mode-affected tools: default ON in Agent mode, default OFF in Chat mode,
// but the user's explicit per-mode override is persisted and honored.
const MODE_TOOLS = [
{ btnId: 'web-toggle-btn', checkboxId: 'web-toggle', stateKey: 'web' },
{ btnId: 'bash-toggle-btn', checkboxId: 'bash-toggle', stateKey: 'bash' },
];
function _modeKey(stateKey, mode) { return `${stateKey}_${mode}`; }
function loadToolPref(stateKey, mode) {
const state = loadToggleState();
const key = _modeKey(stateKey, mode);
if (Object.prototype.hasOwnProperty.call(state, key)) return !!state[key];
return mode === 'agent'; // default: ON in agent, OFF in chat
}
function saveToolPref(stateKey, mode, value) {
const state = loadToggleState();
state[_modeKey(stateKey, mode)] = value;
saveToggleState(state);
}
const TOOL_TOGGLE_TOAST_LABELS = {
web: 'Web search',
bash: 'Shell',
};
function showToolToggleToast(stateKey, active) {
const label = TOOL_TOGGLE_TOAST_LABELS[stateKey];
if (!label || !uiModule?.showToast) return;
uiModule.showToast(`${label} ${active ? 'on' : 'off'}`, 1800);
}
function applyModeToToggles(mode) {
MODE_TOOLS.forEach(({ btnId, checkboxId, stateKey }) => {
const btn = el(btnId);
if (!btn || btn.style.display === 'none') return;
const on = loadToolPref(stateKey, mode);
btn.classList.toggle('active', on);
if (checkboxId) { const chk = el(checkboxId); if (chk) chk.checked = on; }
});
}
// ── Agent / Chat mode toggle ──
(function initModeToggle() {
const agentBtn = el('mode-agent-btn');
const chatBtn = el('mode-chat-btn');
if (!agentBtn || !chatBtn) return;
const state = loadToggleState();
let currentMode = state.mode || 'chat';
function setMode(mode) {
currentMode = mode;
const st = loadToggleState();
st.mode = mode;
saveToggleState(st);
agentBtn.classList.toggle('active', mode === 'agent');
chatBtn.classList.toggle('active', mode === 'chat');
agentBtn.setAttribute('aria-pressed', String(mode === 'agent'));
chatBtn.setAttribute('aria-pressed', String(mode === 'chat'));
// Slide the pill to the active button
const toggle = agentBtn.closest('.mode-toggle');
if (toggle) toggle.classList.toggle('mode-chat', mode === 'chat');
// Delay tool glow-up for a staggered effect
setTimeout(() => applyModeToToggles(mode), 500);
}
agentBtn.addEventListener('click', () => {
// Agent mode turns off research if active
const resChk = el('research-toggle');
if (resChk && resChk.checked) _syncResearchIndicator(false);
setMode('agent');
});
chatBtn.addEventListener('click', () => setMode('chat'));
setMode(currentMode);
})();
// ── Tool splash explainer messages (shown first 2 times per tool) ──
const SPLASH_COUNT_KEY = 'odysseus-tool-splash-counts';
const SPLASH_MAX = 2;
const _toolSplashes = {
web: { role: 'Web Search', text: 'Searches the web for relevant information to include in the response. Results are fetched and summarized before the AI answers.' },
bash: { role: 'Shell Access', text: 'Gives the AI access to a sandboxed shell for running commands, installing packages, and executing scripts. Use with caution.' },
builder: { role: 'Tool Builder', text: 'Create custom mini-apps and tools the AI can use. Describe what you need and the AI will build a tool you can reuse across conversations.' },
research: { role: 'Deep Research', text: 'Multi-round web search with source analysis. Takes longer but produces comprehensive, well-sourced answers. Your next message will trigger a deep research cycle.' },
};
function _showToolSplash(key) {
const splash = _toolSplashes[key];
if (!splash) return;
// Only show the first SPLASH_MAX times per tool
const counts = Storage.getJSON(SPLASH_COUNT_KEY, {});
const seen = counts[key] || 0;
if (seen >= SPLASH_MAX) return;
counts[key] = seen + 1;
Storage.setJSON(SPLASH_COUNT_KEY, counts);
// Hide welcome screen so splash is visible
if (chatModule && chatModule.hideWelcomeScreen) {
chatModule.hideWelcomeScreen();
}
const chatBox = document.getElementById('chat-history');
if (!chatBox) return;
const div = document.createElement('div');
div.className = 'msg msg-ai tool-splash';
div.innerHTML = '<div class="role">' + splash.role + '</div><div class="body" style="opacity:0.7;font-size:0.92em">' + splash.text + '</div>';
chatBox.appendChild(div);
if (uiModule) uiModule.scrollHistory();
}
// ── Checkbox-backed toggle buttons (with per-mode persistence) ──
function setupToggle(btnId, checkboxId, stateKey) {
const btn = el(btnId);
if (!btn) return;
// Restore per-mode saved state for both Agent and Chat modes.
const mode = (loadToggleState().mode) || 'chat';
const saved = loadToolPref(stateKey, mode);
const chk = el(checkboxId);
if (chk) chk.checked = saved;
btn.classList.toggle('active', saved);
btn.setAttribute('aria-pressed', String(saved));
btn.addEventListener('click', () => {
const curMode = (loadToggleState().mode) || 'chat';
const chk = el(checkboxId);
chk.checked = !chk.checked;
btn.classList.toggle('active', chk.checked);
btn.setAttribute('aria-pressed', String(chk.checked));
saveToolPref(stateKey, curMode, chk.checked);
showToolToggleToast(stateKey, chk.checked);
if (chk.checked) _showToolSplash(stateKey);
// Web search and Research are mutually exclusive — Research takes priority
if (stateKey === 'web' && chk.checked) {
const resChk = el('research-toggle');
if (resChk && resChk.checked) {
_syncResearchIndicator(false);
}
}
});
}
setupToggle('web-toggle-btn', 'web-toggle', 'web');
setupToggle('bash-toggle-btn', 'bash-toggle', 'bash');
// Document editor toggle (special: uses module panel, not a checkbox)
const overflowDocBtn = el('overflow-doc-btn');
if (overflowDocBtn) {
overflowDocBtn.addEventListener('click', async () => {
if (!documentModule) return;
if (documentModule.isPanelOpen()) {
documentModule.closePanel();
overflowDocBtn.classList.remove('active');
const st = loadToggleState(); st.doc = false; saveToggleState(st);
} else {
let sessionId = sessionModule.getCurrentSessionId();
// If there's a pending "New Chat", materialize it first
if (!sessionId && sessionModule.hasPendingChat && sessionModule.hasPendingChat()) {
await sessionModule.materializePendingSession();
sessionId = sessionModule.getCurrentSessionId();
}
if (sessionId) {
documentModule.loadSessionDocs(sessionId, { forceOpen: true });
} else {
documentModule.ensureDocPanel();
}
overflowDocBtn.classList.add('active');
const st = loadToggleState(); st.doc = true; saveToggleState(st);
}
});
}
// Document indicator button (shown outside overflow when docs exist)
const docIndicatorBtn = el('doc-indicator-btn');
if (docIndicatorBtn) {
docIndicatorBtn.addEventListener('click', () => {
const ob = el('overflow-doc-btn');
if (ob) ob.click();
});
}
// ── RAG toggle (overflow + indicator) ──
function _syncRagIndicator(active) {
const indicator = el('rag-indicator-btn');
const overflow = el('overflow-rag-btn');
const chk = el('rag-toggle');
if (chk) chk.checked = active;
if (indicator) {
indicator.style.display = active ? '' : 'none';
indicator.classList.toggle('active', active);
}
if (overflow) overflow.classList.toggle('active', active);
const s = loadToggleState(); s.rag = active; saveToggleState(s);
updatePlusDot();
}
window._syncRagIndicator = _syncRagIndicator;
window._syncResearchIndicator = _syncResearchIndicator;
// Must be assigned at module level (not inside the function body) so the very
// first external caller — group.js / sessions.js fire it before it has ever
// run locally — finds it instead of silently no-op'ing (the "group indicator
// sometimes doesn't appear" bug).
window._syncGroupIndicator = _syncGroupIndicator;
// Init RAG state on load
{
const st = loadToggleState();
const ragState = st.rag || false;
_syncRagIndicator(ragState);
}
// ── Overflow "..." menu (Research) ──
function updatePlusDot() {
const plusBtn = el('overflow-plus-btn');
if (!plusBtn) return;
const menu = el('overflow-menu');
const anyActive = menu ? Array.from(menu.querySelectorAll('.overflow-menu-item.active')).some(item => item.style.display !== 'none') : false;
plusBtn.classList.toggle('has-active', anyActive);
}
// External modules (compare) dispatch this when their overflow state changes
document.addEventListener('overflow-state-change', () => updatePlusDot());
// ── Prevent toolbar buttons from stealing focus (avoids mobile keyboard bounce) ──
const chatInputBar = document.querySelector('.chat-input-bar');
// ── Keep textarea focused when interacting with chat bar controls (mobile keyboard fix) ──
const _msgTextarea = el('message');
if (chatInputBar && _msgTextarea) {
let _refocusOnBlur = false;
function _flagRefocus(e) {
if (e.target.closest('textarea, input')) return;
// Don't refocus for attach — file picker needs full focus control
if (e.target.closest('#overflow-attach-btn')) return;
// Don't refocus for model picker button — focus should go to picker search input
if (e.target.closest('.model-picker-btn')) return;
// Don't refocus when tapping the +/chevron tools button — the user
// is explicitly trying to dismiss the keyboard and open the tools
// menu. Without this, the textarea blurs (keyboard down), then this
// handler re-focuses it (keyboard bounces back up).
if (e.target.closest('#overflow-plus-btn')) return;
if (document.activeElement === _msgTextarea) _refocusOnBlur = true;
}
chatInputBar.addEventListener('touchstart', _flagRefocus, { passive: true });
// Overflow menu is position:fixed — may not bubble through chatInputBar on mobile
const _overflowMenu = el('overflow-menu');
if (_overflowMenu) _overflowMenu.addEventListener('touchstart', _flagRefocus, { passive: true });
// Model picker menu too
const _pickerMenu = document.getElementById('model-picker-menu');
if (_pickerMenu) _pickerMenu.addEventListener('touchstart', _flagRefocus, { passive: true });
// Attach strip (outside chat-input-bar)
const _attachStrip = el('attach-strip');
if (_attachStrip) _attachStrip.addEventListener('touchstart', _flagRefocus, { passive: true });
_msgTextarea.addEventListener('blur', () => {
if (_refocusOnBlur) {
_refocusOnBlur = false;
setTimeout(() => _msgTextarea.focus(), 0);
}
});
// Clear flag if touch ends without causing blur
document.addEventListener('touchend', () => { setTimeout(() => { _refocusOnBlur = false; }, 50); }, { passive: true });
}
(function initOverflowMenu() {
const plusBtn = el('overflow-plus-btn');
const menu = el('overflow-menu');
if (!plusBtn || !menu) return;
// `.chat-input-bar` has `container-type: inline-size`, which makes it the
// containing block for `position: fixed` descendants — so this menu gets
// trapped in the composer's stacking context and renders BEHIND the
// attach-strip (worse the more files you add). Portal it to <body> while
// open so its fixed position + z-index apply against the viewport, then
// restore it to its wrapper on close.
const ownerWrap = menu.parentElement;
const pickerWrap = el('model-picker-wrap');
let _vvReposition = null;
// Pin the menu's bottom 8px above the chevron (viewport-relative, since it's
// portaled to <body>). Only cap height + show a scrollbar when the list is
// genuinely taller than the room above the button.
function positionMenu() {
const r = plusBtn.getBoundingClientRect();
menu.style.left = r.left + 'px';
menu.style.right = 'auto';
menu.style.bottom = 'auto';
menu.style.maxHeight = ''; // reset so we can measure the natural height
menu.style.overflowY = '';
const avail = r.top - 16; // room above the chevron
const natural = menu.scrollHeight;
const h = Math.min(natural, avail);
if (natural > avail) { // only cap + scroll when it doesn't fit
menu.style.maxHeight = avail + 'px';
menu.style.overflowY = 'auto';
}
menu.style.top = (r.top - 8 - h) + 'px';
}
// Tapping the chevron must NOT steal focus from the message box, or the
// mobile keyboard collapses. preventDefault on pointerdown keeps the
// textarea focused (keyboard stays up) while click still opens the menu.
plusBtn.addEventListener('pointerdown', (e) => { e.preventDefault(); });
plusBtn.addEventListener('click', (e) => {
e.stopPropagation();
// Closing path needs to play the fold-in animation, not just flip
// .hidden — route through closeOverflowMenu so the second-click
// close looks the same as click-outside / Escape / item-pick.
const isOpen = !menu.classList.contains('hidden') && !menu.classList.contains('closing');
if (isOpen) {
closeOverflowMenu();
return;
}
// Re-opening while a fold-in is mid-animation: cancel it cleanly.
menu.classList.remove('closing');
menu.classList.remove('hidden');
plusBtn.classList.add('expanded');
document.body.appendChild(menu); // escape the composer's container-type trap
// Hide pill bar label so it doesn't show through the menu
if (pickerWrap) pickerWrap.style.visibility = 'hidden';
// Keep the textarea focused so the keyboard stays up if it was open (the
// pointerdown handler above prevents the focus-steal). Still watch
// visualViewport so the menu follows the chevron if the viewport shifts.
positionMenu();
if (window.visualViewport && !_vvReposition) {
_vvReposition = () => positionMenu();
window.visualViewport.addEventListener('resize', _vvReposition);
window.visualViewport.addEventListener('scroll', _vvReposition);
}
});
function closeOverflowMenu() {
if (menu.classList.contains('hidden')) return;
if (menu.classList.contains('closing')) return;
if (_vvReposition && window.visualViewport) {
window.visualViewport.removeEventListener('resize', _vvReposition);
window.visualViewport.removeEventListener('scroll', _vvReposition);
_vvReposition = null;
}
// Play the fold-in animation (items peel top-down, then container
// scales back into the chevron) before flipping to display:none.
menu.classList.add('closing');
plusBtn.classList.remove('expanded');
if (pickerWrap) pickerWrap.style.visibility = '';
// Item delays max at 0.18s + 0.20s anim = 0.38s for items, container
// delay 0.16s + 0.22s = 0.38s. 400ms covers both with margin.
setTimeout(() => {
menu.classList.add('hidden');
menu.classList.remove('closing');
if (ownerWrap) ownerWrap.appendChild(menu); // restore from <body> portal
}, 400);
}
// Close menu when clicking any item inside it. preventDefault on pointerdown
// so tapping an item (e.g. Attach files) doesn't steal focus from the message
// box — keeps the mobile keyboard up.
menu.querySelectorAll('.overflow-menu-item').forEach(item => {
item.addEventListener('pointerdown', (e) => { e.preventDefault(); });
item.addEventListener('click', () => closeOverflowMenu());
});
document.addEventListener('click', (e) => {
if (!menu.contains(e.target) && e.target !== plusBtn) closeOverflowMenu();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !menu.classList.contains('hidden')) closeOverflowMenu();
});
// Research toggle
const researchBtn = el('research-toggle-btn');
if (researchBtn) {
const st = loadToggleState();
const resState = st.research || false;
el('research-toggle').checked = resState;
researchBtn.classList.toggle('active', resState);
researchBtn.style.display = resState ? '' : 'none';
// Sync overflow + tool sidebar on load
const overflowRes = el('overflow-research-btn');
if (overflowRes) overflowRes.classList.toggle('active', resState);
const toolRes = el('tool-research-btn');
if (toolRes) toolRes.classList.toggle('active', resState);
// On load: if both research and web are ON, research wins
if (resState) {
const webChk = el('web-toggle');
const webBtn = el('web-toggle-btn');
if (webChk && webChk.checked) {
webChk.checked = false;
if (webBtn) webBtn.classList.remove('active');
saveToolPref('web', (st.mode || 'chat'), false);
}
}
researchBtn.addEventListener('click', () => {
const chk = el('research-toggle');
const turningOn = chk ? !chk.checked : false;
_syncResearchIndicator(turningOn);
if (turningOn) {
_showToolSplash('research');
// Clear character — mutually exclusive with research
if (presetsModule && presetsModule.deactivateCharacter) presetsModule.deactivateCharacter();
// Research and Web search are mutually exclusive
const webChk = el('web-toggle');
const webBtn = el('web-toggle-btn');
if (webChk && webChk.checked) {
webChk.checked = false;
if (webBtn) webBtn.classList.remove('active');
saveToolPref('web', (loadToggleState().mode || 'chat'), false);
}
// Research requires chat mode — force switch from agent
const rs = loadToggleState();
if (rs.mode === 'agent') {
rs.mode = 'chat';
saveToggleState(rs);
const ab = el('mode-agent-btn'), cb = el('mode-chat-btn');
if (ab) ab.classList.remove('active');
if (cb) cb.classList.add('active');
applyModeToToggles('chat');
}
}
});
}
updatePlusDot();
})();
// ── Auto-collapse toolbar buttons into overflow when space is tight ──
(function initToolbarOverflow() {
const inputLeft = document.querySelector('.chat-input-left');
const overflowMenu = el('overflow-menu');
const overflowWrapper = document.querySelector('.overflow-wrapper');
if (!inputLeft || !overflowMenu || !overflowWrapper) return;
// Buttons that can be collapsed (in reverse priority — last collapsed first)
const collapsibleIds = ['bash-toggle-btn', 'web-toggle-btn'];
const collapsibleBtns = collapsibleIds.map(id => el(id)).filter(Boolean);
// Map of toolbar btn id → overflow mirror element (created dynamically)
const overflowMirrors = new Map();
// Create overflow mirror items for each collapsible button
collapsibleBtns.forEach(btn => {
const mirror = document.createElement('button');
mirror.type = 'button';
mirror.className = 'overflow-menu-item toolbar-overflow-mirror';
mirror.dataset.mirrorOf = btn.id;
const title = btn.title || btn.id.replace(/-/g, ' ');
mirror.innerHTML = btn.querySelector('svg').outerHTML + '<span>' + title + '</span>' +
'<span class="overflow-active-dot"></span>';
mirror.style.display = 'none';
mirror.addEventListener('click', () => btn.click());
// Insert at top of overflow menu (before existing items)
overflowMenu.insertBefore(mirror, overflowMenu.firstChild);
overflowMirrors.set(btn.id, mirror);
});
function syncMirrorStates() {
overflowMirrors.forEach((mirror, btnId) => {
const btn = el(btnId);
if (btn) mirror.classList.toggle('active', btn.classList.contains('active'));
});
updatePlusDot();
}
function checkToolbarOverflow() {
const inputBottom = inputLeft.parentElement;
if (!inputBottom) return;
const rightEl = document.querySelector('.chat-input-right');
const available = inputBottom.clientWidth -
(rightEl ? rightEl.offsetWidth : 0) - 16;
// Uncollapse all to measure natural widths
collapsibleBtns.forEach(btn => btn.classList.remove('toolbar-collapsed'));
overflowMirrors.forEach(m => m.style.display = 'none');
// Temporarily allow overflow for accurate measurement
const prevOverflow = inputLeft.style.overflow;
inputLeft.style.overflow = 'visible';
inputLeft.style.flexWrap = 'nowrap';
// Force reflow then measure each child
void inputLeft.offsetWidth;
// Measure the overflow wrapper (always visible)
const wrapperWidth = overflowWrapper.offsetWidth + 4;
// Measure each collapsible button's natural width
const btnWidths = collapsibleBtns.map(btn => btn.offsetWidth + 4);
// Measure non-collapsible, non-wrapper children (tool indicators etc)
let otherWidth = 0;
Array.from(inputLeft.children).forEach(c => {
if (c === overflowWrapper) return;
if (collapsibleBtns.includes(c)) return;
if (c.offsetWidth) otherWidth += c.offsetWidth + 4;
});
let totalWidth = wrapperWidth + otherWidth + btnWidths.reduce((a, b) => a + b, 0);
// Force-collapse shell & search when research mode + doc panel are both active
const _resChk = el('research-toggle');
const _researchOn = _resChk && _resChk.checked;
const _docViewOn = document.body.classList.contains('doc-view');
if (_researchOn && _docViewOn) {
collapsibleBtns.forEach(btn => {
btn.classList.add('toolbar-collapsed');
const mirror = overflowMirrors.get(btn.id);
if (mirror) mirror.style.display = '';
});
inputLeft.style.overflow = prevOverflow;
inputLeft.style.flexWrap = '';
syncMirrorStates();
return;
}
// Collapse from lowest priority until it fits
if (totalWidth > available) {
for (let i = 0; i < collapsibleBtns.length; i++) {
collapsibleBtns[i].classList.add('toolbar-collapsed');
const mirror = overflowMirrors.get(collapsibleBtns[i].id);
if (mirror) mirror.style.display = '';
totalWidth -= btnWidths[i];
if (totalWidth <= available) break;
}
}
// Restore
inputLeft.style.overflow = prevOverflow;
inputLeft.style.flexWrap = '';
syncMirrorStates();
}
// Observe active class changes to sync mirror states
const observer = new MutationObserver(() => syncMirrorStates());
collapsibleBtns.forEach(btn => {
observer.observe(btn, { attributes: true, attributeFilter: ['class'] });
});
// Run on resize and on load
window.addEventListener('resize', () => requestAnimationFrame(checkToolbarOverflow));
// Run immediately (state is already restored by this point)
checkToolbarOverflow();
// Re-check when sidebar toggles (changes available width)
document.addEventListener('overflow-state-change', () =>
requestAnimationFrame(checkToolbarOverflow));
// Also re-check when sidebar visibility changes
const sidebarEl = el('sidebar');
if (sidebarEl) {
new MutationObserver(() => requestAnimationFrame(checkToolbarOverflow))
.observe(sidebarEl, { attributes: true, attributeFilter: ['class'] });
}
// Re-check when doc panel opens/closes (body.doc-view toggled)
new MutationObserver(() => requestAnimationFrame(checkToolbarOverflow))
.observe(document.body, { attributes: true, attributeFilter: ['class'] });
// Re-check when input bar itself resizes (e.g. doc panel drag)
const inputBottom = inputLeft.parentElement;
if (inputBottom) {
new ResizeObserver(() => requestAnimationFrame(checkToolbarOverflow)).observe(inputBottom);
}
})();
// ── Auto-hide model picker when textarea area is too narrow ──
(function initModelPickerResponsive() {
const inputTop = document.querySelector('.chat-input-top');
const pickerWrap = el('model-picker-wrap');
if (!inputTop || !pickerWrap) return;
const PLACEHOLDER_HIDE_WIDTH = 400;
const PICKER_HIDE_WIDTH = 220;
const TOOLBAR_HIDE_WIDTH = 160;
const textarea = el('message');
const inputBottom = document.querySelector('.chat-input-bottom');
const _isMobile = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
function checkPickerOverflow() {
// Skip responsive collapse on mobile — keyboard open/close causes flicker
if (_isMobile) return;
const w = inputTop.clientWidth;
// Hide model picker
pickerWrap.classList.toggle('picker-auto-hidden', w < PICKER_HIDE_WIDTH);
// Hide placeholder text
if (textarea) {
textarea.setAttribute('placeholder', w < PLACEHOLDER_HIDE_WIDTH ? '' : 'Message Odysseus...');
}
// Hide entire bottom toolbar (tools, mode toggle) — only send button remains
if (inputBottom) {
inputBottom.classList.toggle('toolbar-auto-hidden', w < TOOLBAR_HIDE_WIDTH);
}
}
const ro = new ResizeObserver(() => requestAnimationFrame(checkPickerOverflow));
ro.observe(inputTop);
checkPickerOverflow();
})();
// TTS Mode toggle (separate from overflow IIFE for safety)
(function initTTSToggle() {
const ttsBtn = document.getElementById('overflow-tts-btn');
if (!ttsBtn) return;
try {
const st = loadToggleState();
if (st.ttsMode) {
ttsBtn.classList.add('active');
if (window.aiTTSManager) window.aiTTSManager.autoPlay = true;
}
} catch(e) {}
ttsBtn.addEventListener('click', () => {
const isActive = !ttsBtn.classList.contains('active');
ttsBtn.classList.toggle('active', isActive);
if (window.aiTTSManager) window.aiTTSManager.autoPlay = isActive;
const s = loadToggleState(); s.ttsMode = isActive; saveToggleState(s);
updatePlusDot();
});
})();
// ── Compare indicator (sidebar only, no overflow) ──
const compareIndicatorBtn = el('compare-indicator-btn');
if (compareIndicatorBtn) {
compareIndicatorBtn.addEventListener('click', () => {
if (compareModule && compareModule.isActive()) {
compareModule.closeCompare();
}
});
}
// ── Overflow RAG toggle ──
const overflowRagBtn = el('overflow-rag-btn');
const ragIndicatorBtn = el('rag-indicator-btn');
if (overflowRagBtn) {
overflowRagBtn.addEventListener('click', () => {
const chk = el('rag-toggle');
const isActive = chk ? !chk.checked : true;
_syncRagIndicator(isActive);
});
}
if (ragIndicatorBtn) {
ragIndicatorBtn.addEventListener('click', () => {
_syncRagIndicator(false);
});
}
// ── Overflow Research toggle ──
const overflowResearchBtn = el('overflow-research-btn');
if (overflowResearchBtn) {
overflowResearchBtn.addEventListener('click', () => {
const chk = el('research-toggle');
const turningOn = chk ? !chk.checked : false;
_syncResearchIndicator(turningOn);
if (turningOn) {
_showToolSplash('research');
// Clear character — mutually exclusive with research
if (presetsModule && presetsModule.deactivateCharacter) presetsModule.deactivateCharacter();
// Mutual exclusion with web search
const webChk = el('web-toggle');
const webBtn = el('web-toggle-btn');
if (webChk && webChk.checked) {
webChk.checked = false;
if (webBtn) webBtn.classList.remove('active');
saveToolPref('web', (loadToggleState().mode || 'chat'), false);
}
// Research requires chat mode
const rs2 = loadToggleState();
if (rs2.mode === 'agent') {
rs2.mode = 'chat';
saveToggleState(rs2);
const ab2 = el('mode-agent-btn'), cb2 = el('mode-chat-btn');
if (ab2) ab2.classList.remove('active');
if (cb2) cb2.classList.add('active');
applyModeToToggles('chat');
}
}
});
}
// ── Overflow Group Chat toggle ──
const overflowGroupBtn = el('overflow-group-btn');
if (overflowGroupBtn) {
overflowGroupBtn.addEventListener('click', async () => {
const chk = el('group-toggle');
const turningOn = chk ? !chk.checked : false;
if (turningOn) {
const picked = await groupModule.showModelPicker();
if (!picked || picked.length < 2) return;
groupModule.setActive(true); // Set early so updateModelPicker sees it
_syncGroupIndicator(true);
_startFreshChat();
// Clear any leftover splash screens
const _chatBox = document.getElementById('chat-history');
if (_chatBox) {
_chatBox.querySelectorAll('.tool-splash').forEach(s => s.remove());
// Also hide welcome screen
if (chatModule && chatModule.hideWelcomeScreen) chatModule.hideWelcomeScreen();
}
// Start group — create participant sessions immediately
const sid = sessionModule.getCurrentSessionId() || 'group-' + Date.now();
await groupModule.startGroup(picked, sid);
// Re-hide picker after everything settles
const _mpw = el('model-picker-wrap');
if (_mpw) _mpw.style.display = 'none';
uiModule.showToast(`Group chat ready — ${picked.length} models`);
} else {
_syncGroupIndicator(false);
groupModule.stopGroup();
// Restore model picker
const _mpWrap2 = el('model-picker-wrap');
if (_mpWrap2) _mpWrap2.style.display = '';
}
});
}
// ── Group toggle button (chatbox indicator) — click to deactivate ──
const groupToggleBtn = el('group-toggle-btn');
if (groupToggleBtn) {
groupToggleBtn.addEventListener('click', () => {
_syncGroupIndicator(false);
groupModule.stopGroup();
});
}
// ── Incognito mode toggle (on welcome screen) ──
const incognitoBtn = el('incognito-btn');
const INCOGNITO_EYE_OPEN = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
const INCOGNITO_EYE_CLOSED = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>';
const SESSION_ICON_CHAT = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const SESSION_ICON_INCOGNITO = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
function _syncSessionIncognitoIcon(active) {
const activeSession = document.querySelector('.list-item.active-session .session-icon');
if (activeSession) {
activeSession.innerHTML = active ? SESSION_ICON_INCOGNITO : SESSION_ICON_CHAT;
activeSession.style.color = active ? 'var(--accent)' : '';
}
}
if (incognitoBtn) {
incognitoBtn.addEventListener('mousedown', (e) => e.preventDefault());
incognitoBtn.addEventListener('click', () => {
// Don't toggle mid-chat — incognito only changeable from welcome screen
const ws = el('welcome-screen');
if (ws && ws.classList.contains('hidden')) return;
const chk = el('incognito-toggle');
chk.checked = !chk.checked;
incognitoBtn.classList.toggle('active', chk.checked);
const tipEl = el('welcome-tip');
incognitoBtn.title = chk.checked ? 'Disable Nobody mode' : 'Enable Nobody mode — no memory, no history saved';
const welcomeName = document.querySelector('.welcome-name');
if (chk.checked) {
incognitoBtn.innerHTML = INCOGNITO_EYE_CLOSED + '<span class="incognito-label">Nobody</span>';
if (welcomeName) {
welcomeName.dataset.originalHtml = welcomeName.innerHTML;
welcomeName.innerHTML = '<svg class="welcome-boat" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>Nobody';
// Restart the L→R clip-wipe reveal on the new label
welcomeName.style.animation = 'none';
welcomeName.offsetHeight;
welcomeName.style.animation = '';
}
if (ws) { ws.style.animation = 'none'; ws.offsetHeight; ws.style.animation = 'welcome-enter 0.3s ease-out both'; }
const welcomeSub = el('welcome-sub');
if (welcomeSub) {
if (!welcomeSub.dataset.originalText) welcomeSub.dataset.originalText = welcomeSub.textContent;
welcomeSub.textContent = "Who am I? I'm nobody.";
welcomeSub.style.display = '';
}
if (tipEl) { tipEl.dataset.originalTip = tipEl.textContent; tipEl.textContent = 'Temporary session \u2014 won\u2019t be saved and no memory activation.'; tipEl.style.opacity = '0.5'; tipEl.style.marginTop = '8px'; }
// Default to plain chat: disable tools visually, switch to chat mode.
// IMPORTANT: don't overwrite the user's persisted per-mode tool prefs
// (`web_agent`, `bash_agent`, `web_chat`, `bash_chat`). Nobody mode is
// ephemeral — their agent-mode defaults must come back on toggle-off.
const _offIds = ['web-toggle', 'bash-toggle', 'research-toggle'];
_offIds.forEach(id => { const c = el(id); if (c) c.checked = false; });
['web-toggle-btn', 'bash-toggle-btn'].forEach(id => { const b = el(id); if (b) b.classList.remove('active'); });
const _ab = el('mode-agent-btn'), _cb = el('mode-chat-btn');
if (_ab) _ab.classList.remove('active');
if (_cb) _cb.classList.add('active');
const ts = Storage.getJSON(Storage.KEYS.TOGGLES, {});
ts.research = false; ts.mode = 'chat';
Storage.setJSON(Storage.KEYS.TOGGLES, ts);
} else {
incognitoBtn.innerHTML = INCOGNITO_EYE_OPEN + '<span class="incognito-label">Nobody</span>';
if (welcomeName && welcomeName.dataset.originalHtml) {
welcomeName.innerHTML = welcomeName.dataset.originalHtml;
// Restart the L→R clip-wipe reveal on the restored label
welcomeName.style.animation = 'none';
welcomeName.offsetHeight;
welcomeName.style.animation = '';
}
if (ws) { ws.style.animation = 'none'; ws.offsetHeight; ws.style.animation = 'welcome-enter 0.3s ease-out both'; }
const welcomeSub2 = el('welcome-sub');
if (welcomeSub2) {
if (welcomeSub2.dataset.originalText) {
welcomeSub2.textContent = welcomeSub2.dataset.originalText;
delete welcomeSub2.dataset.originalText;
}
welcomeSub2.style.display = '';
}
if (tipEl && tipEl.dataset.originalTip) { tipEl.textContent = tipEl.dataset.originalTip; tipEl.style.opacity = ''; tipEl.style.marginTop = ''; }
// Heal any previously-persisted false values from the old Nobody bug
// so agent-mode defaults (web/bash ON) come back.
const _ts = Storage.getJSON(Storage.KEYS.TOGGLES, {});
let _dirty = false;
['web_agent', 'bash_agent', 'web_chat', 'bash_chat'].forEach(k => {
if (_ts[k] === false) { delete _ts[k]; _dirty = true; }
});
if (_dirty) Storage.setJSON(Storage.KEYS.TOGGLES, _ts);
// Reapply the current mode's real defaults to the visible toggles
const _curMode = (Storage.getJSON(Storage.KEYS.TOGGLES, {}) || {}).mode || 'chat';
try { applyModeToToggles(_curMode); } catch (_) {}
}
// If toggled off mid-chat (welcome screen hidden), hide the button
if (!chk.checked && ws && ws.classList.contains('hidden')) {
incognitoBtn.style.display = 'none';
}
// Show/hide persistent incognito indicator in top bar
const _incInd = el('incognito-indicator');
if (_incInd) _incInd.style.display = chk.checked ? '' : 'none';
// Update active session icon in sidebar
_syncSessionIncognitoIcon(chk.checked);
});
}
// Incognito indicator click — deactivate incognito
const incognitoIndicator = el('incognito-indicator');
if (incognitoIndicator) {
incognitoIndicator.addEventListener('click', () => {
if (incognitoBtn) incognitoBtn.click();
else {
const chk = el('incognito-toggle');
if (chk) { chk.checked = false; }
incognitoIndicator.style.display = 'none';
}
});
}
// ── Deactivate incognito mode (called on new session) ──
function _deactivateIncognito() {
const chk = el('incognito-toggle');
if (!chk || !chk.checked) return;
if (incognitoBtn) incognitoBtn.click();
}
// ── UI Visibility (Customize UI modal) ──
const UI_VIS_KEY = 'odysseus-ui-visibility';
// Selector map: key → CSS selector(s) for targets
const UI_VIS_MAP = {
'sidebar-brand': '.sidebar-brand-title',
'sidebar-new-chat': '#sidebar-new-chat-btn',
'sidebar-search': '#sidebar-search-btn',
'sessions-section': '#sessions-section',
'email-section': '#email-section',
'models-section': '#models-section',
'tools-section': '#tools-section',
// Per-tool visibility — fine-grained control over which entries show
// inside the Tools section in the sidebar.
'tool-calendar': '#tool-calendar-btn',
'tool-compare': '#tool-compare-btn',
'tool-cookbook': '#tool-cookbook-btn',
'tool-research': '#tool-research-btn',
'tool-gallery': '#tool-gallery-btn',
'tool-library': '#tool-library-btn',
'tool-memory': '#tool-memory-btn',
'tool-notes': '#tool-notes-btn',
'tool-tasks': '#tool-tasks-btn',
'tool-theme': '#tool-theme-btn',
'user-bar': '#user-bar-profile',
'sidebar-settings-btn':'#user-bar-settings',
'chat-meta': '.chat-meta-overlay',
'welcome-text': '.welcome-name, .welcome-sub, #welcome-tip',
'incognito-btn': '.incognito-btn',
'web-toggle-btn': '#web-toggle-btn',
'doc-toggle-btn': '#overflow-doc-btn',
'rag-toggle-btn': '#overflow-rag-btn',
'bash-toggle-btn': '#bash-toggle-btn',
'overflow-plus-btn': '.overflow-wrapper',
'mode-toggle': '.mode-toggle',
'preset-mini-btn': '#overflow-preset-btn',
'attach-btn': '#overflow-attach-btn',
'research-btn': '#overflow-research-btn',
'rail-new-chat': '#rail-new-session',
};
// Keys hidden by default on first run (no localStorage yet)
const UI_VIS_DEFAULT_OFF = new Set(['models-section', 'rag-toggle-btn']);
// Keys that need admin to toggle off (reserved for future use)
const UI_VIS_ADMIN_ONLY = new Set([]);
function loadUIVis() {
return Storage.getJSON(UI_VIS_KEY, {});
}
function saveUIVis(state) {
Storage.setJSON(UI_VIS_KEY, state);
}
function applyUIVis(state) {
Object.entries(UI_VIS_MAP).forEach(([key, selector]) => {
// section-drag-reorder uses a body class instead of inline styles
if (key === 'section-drag-reorder') return;
const visible = key in state ? state[key] !== false : !UI_VIS_DEFAULT_OFF.has(key);
document.querySelectorAll(selector).forEach(el => {
el.style.display = visible ? '' : 'none';
});
});
// Drag reorder: use body class so dynamically created handles are covered
const dragEnabled = state['section-drag-reorder'] === true;
document.body.classList.toggle('rearrange-mode', dragEnabled);
document.querySelectorAll('.section[draggable]').forEach(el => {
el.setAttribute('draggable', dragEnabled ? 'true' : 'false');
});
// Text-only emojis toggle. Default is ON (the checkbox defaults to
// checked because text-emojis isn't in UI_VIS_DEFAULT_OFF), so treat
// an absent value as enabled — otherwise the toggle looked on at
// startup but the effect only activated after the user flipped it.
applyTextEmojis(state['text-emojis'] !== false);
// Hide thinking sections toggle (show-thinking: checked=show, unchecked=hide)
document.body.classList.toggle('hide-thinking', state['show-thinking'] === false);
}
// Rearrange toggles in session/model sort dropdowns
function syncRearrangeChecks() {
const on = loadUIVis()['section-drag-reorder'] === true;
document.querySelectorAll('.rearrange-toggle .rearrange-check').forEach(ch => {
ch.style.opacity = on ? '1' : '0';
});
}
document.querySelectorAll('.rearrange-toggle').forEach(toggle => {
toggle.addEventListener('click', () => {
const state = loadUIVis();
const wasOn = state['section-drag-reorder'] === true;
state['section-drag-reorder'] = !wasOn;
saveUIVis(state);
applyUIVis(state);
syncRearrangeChecks();
uiModule.showToast(!wasOn ? 'Rearrange enabled' : 'Rearrange disabled');
// Close the dropdown the toggle lives in — the sort dropdown's own
// click-stopPropagation means it won't close on its own.
const dd = toggle.closest('[id$="-sort-dropdown"]');
if (dd) dd.style.display = 'none';
});
});
// Esc exits rearrange mode (no matter where focus/mouse is) — matches the
// global Esc-cancels-select pattern. Capture phase so a sort dropdown that
// happens to be open doesn't swallow it first.
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (!document.body.classList.contains('rearrange-mode')) return;
e.preventDefault();
e.stopPropagation();
const state = loadUIVis();
state['section-drag-reorder'] = false;
saveUIVis(state);
applyUIVis(state);
syncRearrangeChecks();
uiModule.showToast('Rearrange disabled');
}, true);
// Sync checkmarks when dropdowns open
const _sessionSortBtn = el('session-sort-btn');
const _modelSortBtn = el('model-sort-btn');
if (_sessionSortBtn) _sessionSortBtn.addEventListener('click', syncRearrangeChecks);
if (_modelSortBtn) _modelSortBtn.addEventListener('click', syncRearrangeChecks);
syncRearrangeChecks();
// ── Text-only emoji conversion ──
// Regex matching most emoji codepoints (Emoji_Presentation + common sequences)
const EMOJI_RE = /(?:\p{Emoji_Presentation}|\p{Extended_Pictographic})(?:\uFE0F|\u200D(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}))*/gu;
// Common emoji → text description map
const EMOJI_MAP = {
'😀':'grinning','😃':'smiley','😄':'smile','😁':'grin','😆':'laughing','😅':'sweat smile',
'🤣':'rofl','😂':'joy','🙂':'slightly smiling','🙃':'upside down','😉':'wink',
'😊':'blush','😇':'innocent','🥰':'smiling hearts','😍':'heart eyes','🤩':'star struck',
'😘':'kissing heart','😗':'kissing','😚':'kissing closed eyes','😙':'kissing smiling eyes',
'🥲':'smiling tear','😋':'yum','😛':'tongue','😜':'winking tongue','🤪':'zany',
'😝':'squinting tongue','🤑':'money mouth','🤗':'hugging','🤭':'hand over mouth',
'🤫':'shushing','🤔':'thinking','🫡':'saluting','🤐':'zipper mouth','🤨':'raised eyebrow',
'😐':'neutral','😑':'expressionless','😶':'no mouth','🫥':'dotted line face',
'😏':'smirk','😒':'unamused','🙄':'eye roll','😬':'grimacing','🤥':'lying',
'😌':'relieved','😔':'pensive','😪':'sleepy','🤤':'drooling','😴':'sleeping',
'😷':'mask','🤒':'thermometer','🤕':'head bandage','🤢':'nauseated','🤮':'vomiting',
'🥵':'hot','🥶':'cold','🥴':'woozy','😵':'dizzy','🤯':'exploding head',
'🤠':'cowboy','🥳':'party','🥸':'disguised','😎':'sunglasses','🤓':'nerd',
'🧐':'monocle','😕':'confused','🫤':'diagonal mouth','😟':'worried','🙁':'slightly frowning',
'😮':'open mouth','😯':'hushed','😲':'astonished','😳':'flushed','🥺':'pleading',
'🥹':'holding back tears','😦':'frowning open mouth','😧':'anguished','😨':'fearful',
'😰':'anxious sweat','😥':'sad relieved','😢':'crying','😭':'sobbing','😱':'screaming',
'😖':'confounded','😣':'persevering','😞':'disappointed','😓':'downcast sweat',
'😩':'weary','😫':'tired','🥱':'yawning','😤':'triumph','😡':'pouting',
'😠':'angry','🤬':'swearing','😈':'smiling devil','👿':'angry devil',
'💀':'skull','☠️':'skull crossbones','💩':'poop','🤡':'clown','👹':'ogre','👺':'goblin',
'👻':'ghost','👽':'alien','👾':'space invader','🤖':'robot',
'😺':'smiling cat','😸':'grinning cat','😹':'tears of joy cat','😻':'heart eyes cat',
'😼':'wry cat','😽':'kissing cat','🙀':'weary cat','😿':'crying cat','😾':'pouting cat',
'🙈':'see no evil','🙉':'hear no evil','🙊':'speak no evil',
'👋':'wave','🤚':'raised back of hand','🖐️':'hand with fingers splayed','✋':'raised hand',
'🖖':'vulcan salute','🫱':'rightward hand','🫲':'leftward hand',
'👌':'ok hand','🤌':'pinched fingers','🤏':'pinching hand','✌️':'victory',
'🤞':'crossed fingers','🫰':'hand with index finger and thumb crossed',
'🤟':'love you','🤘':'rock on','🤙':'call me','👈':'point left','👉':'point right',
'👆':'point up','🖕':'middle finger','👇':'point down','☝️':'index up',
'🫵':'point at viewer','👍':'thumbs up','👎':'thumbs down','✊':'raised fist',
'👊':'fist bump','🤛':'left fist','🤜':'right fist','👏':'clap','🙌':'raising hands',
'🫶':'heart hands','👐':'open hands','🤲':'palms up','🤝':'handshake','🙏':'pray',
'✍️':'writing','💅':'nail polish','🤳':'selfie','💪':'flexed biceps',
'❤️':'red heart','🧡':'orange heart','💛':'yellow heart','💚':'green heart',
'💙':'blue heart','💜':'purple heart','🖤':'black heart','🤍':'white heart',
'🩷':'pink heart','🩵':'light blue heart','🩶':'grey heart','🤎':'brown heart',
'💔':'broken heart','❤️‍🔥':'heart on fire','❤️‍🩹':'mending heart',
'💕':'two hearts','💞':'revolving hearts','💓':'heartbeat','💗':'growing heart',
'💖':'sparkling heart','💘':'heart with arrow','💝':'heart with ribbon',
'💟':'heart decoration','🔥':'fire','💯':'100','✨':'sparkles','⭐':'star',
'🌟':'glowing star','💫':'dizzy star','🎉':'party popper','🎊':'confetti ball',
'🎈':'balloon','🎁':'gift','🏆':'trophy','🥇':'1st place','🥈':'2nd place','🥉':'3rd place',
'⚡':'zap','💡':'light bulb','🔑':'key','🔒':'locked','🔓':'unlocked',
'🔔':'bell','🔕':'bell off','📢':'loudspeaker','📣':'megaphone',
'💬':'speech bubble','💭':'thought bubble','🗯️':'anger bubble',
'✅':'check mark','❌':'cross mark','❓':'question','❗':'exclamation',
'⚠️':'warning','🚫':'prohibited','⛔':'no entry','🔴':'red circle','🟢':'green circle',
'🔵':'blue circle','🟡':'yellow circle','⚪':'white circle','⚫':'black circle',
'🟠':'orange circle','🟣':'purple circle','🟤':'brown circle',
'📁':'folder','📂':'open folder','📄':'document','📝':'memo','📎':'paperclip',
'📌':'pin','📍':'round pin','🔗':'link','📊':'bar chart','📈':'chart up','📉':'chart down',
'🔍':'magnifying glass left','🔎':'magnifying glass right',
'🌐':'globe','🌍':'globe europe','🌎':'globe americas','🌏':'globe asia',
'🕐':'clock 1','🕑':'clock 2','🕒':'clock 3','🕓':'clock 4',
'⏰':'alarm clock','⏳':'hourglass flowing','⌛':'hourglass done',
'🚀':'rocket','✈️':'airplane','🚗':'car','🚂':'train','🚢':'ship',
'🏠':'house','🏢':'building','🏗️':'construction','🏭':'factory',
'🎵':'musical note','🎶':'musical notes','🎤':'microphone','🎧':'headphones',
'📷':'camera','📸':'camera flash','🎬':'clapperboard','📺':'television',
'💻':'laptop','🖥️':'desktop','📱':'mobile phone','☎️':'telephone',
'🔧':'wrench','🔨':'hammer','⚙️':'gear','🧲':'magnet','🧪':'test tube','🔬':'microscope',
'📚':'books','📖':'open book','✏️':'pencil','🖊️':'pen','🖋️':'fountain pen',
'🎯':'bullseye','♟️':'chess pawn','🎲':'game die','🧩':'puzzle piece',
'🍕':'pizza','🍔':'burger','🍟':'fries','🌮':'taco','🍣':'sushi','🍩':'donut',
'☕':'coffee','🍺':'beer','🍷':'wine','🥤':'cup with straw',
'🐶':'dog','🐱':'cat','🐭':'mouse','🐹':'hamster','🐰':'rabbit','🦊':'fox',
'🐻':'bear','🐼':'panda','🐨':'koala','🐯':'tiger','🦁':'lion','🐮':'cow',
'🐷':'pig','🐸':'frog','🐵':'monkey','🐔':'chicken','🐧':'penguin','🐦':'bird',
'🦅':'eagle','🦆':'duck','🦉':'owl','🐺':'wolf','🐗':'boar','🐴':'horse',
'🦄':'unicorn','🐝':'bee','🐛':'bug','🦋':'butterfly','🐌':'snail','🐞':'ladybug',
'🐍':'snake','🐢':'turtle','🐙':'octopus','🦀':'crab','🐠':'tropical fish',
'🐳':'whale','🐋':'whale','🦈':'shark','🐊':'crocodile','🦕':'sauropod','🦖':'t-rex',
'🌸':'cherry blossom','🌹':'rose','🌻':'sunflower','🌺':'hibiscus','🌷':'tulip',
'🌱':'seedling','🌲':'evergreen tree','🌳':'deciduous tree','🍀':'four leaf clover',
'🍎':'red apple','🍐':'pear','🍊':'tangerine','🍋':'lemon','🍌':'banana',
'🍉':'watermelon','🍇':'grapes','🍓':'strawberry','🫐':'blueberries','🍑':'peach',
'🌈':'rainbow','☀️':'sun','🌤️':'sun behind cloud','⛅':'sun behind cloud','☁️':'cloud',
'🌧️':'rain','⛈️':'thunder','❄️':'snowflake','🌊':'wave',
'👀':'eyes','👁️':'eye','👂':'ear','👃':'nose','👄':'mouth','👅':'tongue',
'🧠':'brain','🦴':'bone','🦷':'tooth','👶':'baby','🧒':'child','👦':'boy','👧':'girl',
'🧑':'person','👨':'man','👩':'woman','🧓':'older person',
'👮':'police officer','🧑‍💻':'technologist','👨‍💻':'man technologist',
'👩‍💻':'woman technologist',
'🎓':'graduation cap','🧢':'billed cap','👑':'crown','💎':'gem','👓':'glasses','🕶️':'sunglasses',
'🩸':'drop of blood','💊':'pill','🩹':'bandage','🧬':'dna','🦠':'microbe',
'☢️':'radioactive','☣️':'biohazard','♻️':'recycling',
'🏳️':'white flag','🏴':'black flag','🚩':'red flag','🏁':'checkered flag',
'➡️':'right arrow','⬅️':'left arrow','⬆️':'up arrow','⬇️':'down arrow',
'↗️':'upper right arrow','↘️':'lower right arrow','↙️':'lower left arrow','↖️':'upper left arrow',
'↩️':'left curve','↪️':'right curve','🔄':'counterclockwise','🔃':'clockwise',
'':'plus','':'minus','➗':'division','✖️':'multiply','♾️':'infinity',
'‼️':'double exclamation','⁉️':'exclamation question',
'©️':'copyright','®️':'registered','™️':'trademark',
};
function emojiToText(str) {
return str.replace(EMOJI_RE, (match) => {
const desc = EMOJI_MAP[match];
if (desc) return ':' + desc + ':';
// Fallback: use the emoji's Unicode name if available, or skip
return ':emoji:';
});
}
const _DEOJ_SKIP = '.sources-section, .thinking-toggle, .memory-used-pill';
/** Walk all text nodes inside an element and replace emojis with text descriptions */
function deEmojify(root) {
if (!root || !root.querySelectorAll) return;
// Monochrome SVG spans from svgifyEmoji — Unicode lives in aria-label only
root.querySelectorAll('.emoji[aria-label]').forEach((span) => {
if (span.closest(_DEOJ_SKIP)) return;
const label = span.getAttribute('aria-label') || '';
span.replaceWith(document.createTextNode(emojiToText(label)));
});
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
for (const node of nodes) {
// Skip UI elements that use unicode symbols as functional icons
if (node.parentElement && node.parentElement.closest(_DEOJ_SKIP)) continue;
if (EMOJI_RE.test(node.textContent)) {
EMOJI_RE.lastIndex = 0; // reset regex state
node.textContent = emojiToText(node.textContent);
}
}
}
/** Apply or remove text-emoji mode on all chat messages */
function applyTextEmojis(enabled) {
document.body.classList.toggle('text-emojis', enabled);
if (enabled) {
document.querySelectorAll('.msg .body').forEach(deEmojify);
}
}
// Observe chat history for new/changed messages — de-emojify on the fly
let _deEmojifyTimer = null;
const _chatObs = new MutationObserver(() => {
if (!document.body.classList.contains('text-emojis')) return;
clearTimeout(_deEmojifyTimer);
_deEmojifyTimer = setTimeout(() => {
document.querySelectorAll('.msg .body').forEach(deEmojify);
}, 150);
});
const _chatBox = document.getElementById('chat-history');
if (_chatBox) _chatObs.observe(_chatBox, { childList: true, subtree: true });
// Migrate old toolbar visibility key if present
(function migrateOldToolbarVis() {
const OLD_KEY = 'odysseus-toolbar-visibility';
try {
const old = Storage.getJSON(OLD_KEY, null);
if (old && typeof old === 'object') {
const current = loadUIVis();
let migrated = false;
Object.entries(old).forEach(([btnId, val]) => {
if (current[btnId] === undefined) {
current[btnId] = val;
migrated = true;
}
});
if (migrated) saveUIVis(current);
Storage.remove(OLD_KEY);
}
} catch {}
})();
// Expose UI visibility functions for admin.js
window.loadUIVis = loadUIVis;
window.saveUIVis = saveUIVis;
window.applyUIVis = applyUIVis;
window.UI_VIS_ADMIN_ONLY = UI_VIS_ADMIN_ONLY;
window.UI_VIS_DEFAULT_OFF = UI_VIS_DEFAULT_OFF;
(function initUIVisibility() {
// Apply saved visibility on load
applyUIVis(loadUIVis());
// Generic draggable for all .modal elements
const _sharedDragModalIds = new Set(['settings-modal']);
try { document.querySelectorAll('.modal').forEach(m => {
if (_sharedDragModalIds.has(m.id)) return;
const content = m.querySelector('.modal-content');
const header = m.querySelector('.modal-header');
if (!content || !header) return;
let dragX, dragY, startLeft, startTop, dragging = false;
// Reset to flex-centered position each time modal opens
new MutationObserver(() => {
if (!m.classList.contains('hidden')) {
content.style.position = '';
content.style.left = '';
content.style.top = '';
content.style.right = '';
content.style.bottom = '';
content.style.margin = '';
}
}).observe(m, { attributes: true, attributeFilter: ['class'] });
function startDrag(clientX, clientY) {
dragging = true;
const rect = content.getBoundingClientRect();
dragX = clientX; dragY = clientY;
startLeft = rect.left; startTop = rect.top;
// Switch to fixed so it can be freely positioned
content.style.position = 'fixed';
content.style.left = startLeft + 'px';
content.style.top = startTop + 'px';
content.style.margin = '0';
}
header.addEventListener('mousedown', (e) => {
if (e.target.closest('.close-btn')) return;
e.preventDefault();
startDrag(e.clientX, e.clientY);
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', stopDrag);
});
function onDrag(e) {
if (!dragging) return;
content.style.left = (startLeft + e.clientX - dragX) + 'px';
content.style.top = (startTop + e.clientY - dragY) + 'px';
}
function stopDrag() {
dragging = false;
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', stopDrag);
}
// Touch drag is desktop-only — on mobile, modals are bottom sheets and
// ui.js handles swipe-down-to-dismiss. Attaching this listener fights
// the swipe-dismiss gesture.
if (window.innerWidth > 768) {
header.addEventListener('touchstart', (e) => {
if (e.target.closest('.close-btn')) return;
const t = e.touches[0];
startDrag(t.clientX, t.clientY);
document.addEventListener('touchmove', onTouchDrag, { passive: false });
document.addEventListener('touchend', stopTouchDrag);
});
}
function onTouchDrag(e) {
if (!dragging) return;
e.preventDefault();
const t = e.touches[0];
content.style.left = (startLeft + t.clientX - dragX) + 'px';
content.style.top = (startTop + t.clientY - dragY) + 'px';
}
function stopTouchDrag() {
dragging = false;
document.removeEventListener('touchmove', onTouchDrag);
document.removeEventListener('touchend', stopTouchDrag);
}
}); } catch(e) { console.error('Modal drag init error:', e); }
})();
// ── Modal minimize → dock ──
// Adds a "_" button next to every modal's close button. Clicking it hides
// the modal and adds an entry to a fixed bottom dock; clicking the dock
// entry restores the modal. Works for hand-rolled and dynamically-created
// modals via a MutationObserver on document.body.
(function initModalMinimize() {
// custom-preset-modal (the Prompt window) is handled by the new
// modalManager dock (registered in _AUTO_WIRE), so the legacy dock must
// not also inject a `_`/chip for it.
const SKIP_IDS = new Set(['styled-confirm-overlay', 'custom-preset-modal']);
const dockEntries = new Map(); // modal element -> dock entry element
let dock = document.getElementById('modal-dock');
if (!dock) {
dock = document.createElement('div');
dock.id = 'modal-dock';
document.body.appendChild(dock);
}
// Keep the dock clear of the sidebar (which can be collapsed, resized,
// hidden, or flipped to the right side).
function updateDockOffset() {
const sidebar = document.getElementById('sidebar');
const iconRail = document.getElementById('icon-rail');
let leftPx = 0;
let rightPx = 0;
const sidebarRight = sidebar && sidebar.classList.contains('right-side');
const sidebarVisible = sidebar &&
!sidebar.classList.contains('hidden') &&
sidebar.offsetWidth > 0;
const railVisible = iconRail && iconRail.offsetWidth > 0;
const sidebarW = sidebarVisible ? sidebar.offsetWidth : 0;
const railW = railVisible ? iconRail.offsetWidth : 0;
if (sidebarRight) {
rightPx = sidebarW + railW;
} else {
leftPx = sidebarW + railW;
}
dock.style.left = leftPx + 'px';
dock.style.right = rightPx + 'px';
}
updateDockOffset();
// Recompute when sidebar resizes, collapses, or moves sides
if (window.ResizeObserver) {
const ro = new ResizeObserver(updateDockOffset);
const sb = document.getElementById('sidebar');
const ir = document.getElementById('icon-rail');
if (sb) ro.observe(sb);
if (ir) ro.observe(ir);
}
window.addEventListener('resize', updateDockOffset);
// Side-flip / collapse toggles class names on body or sidebar
new MutationObserver(updateDockOffset).observe(document.body, {
attributes: true, attributeFilter: ['class'],
});
const sbEl = document.getElementById('sidebar');
if (sbEl) {
new MutationObserver(updateDockOffset).observe(sbEl, {
attributes: true, attributeFilter: ['class', 'style'],
});
}
function modalTitle(modal) {
const h = modal.querySelector('.modal-header h4, .modal-header h3, .modal-header h2');
if (h && h.textContent.trim()) return h.textContent.trim();
if (modal.id) return modal.id.replace(/-modal$|-overlay$|-popup$/, '').replace(/-/g, ' ');
return 'Window';
}
function removeDockEntry(modal) {
const entry = dockEntries.get(modal);
if (entry) {
entry.remove();
dockEntries.delete(modal);
}
}
function restoreModal(modal) {
modal.classList.remove('minimized');
modal.classList.remove('hidden');
removeDockEntry(modal);
// Bring to front (matches existing focus-on-click behavior)
modal.style.zIndex = '';
}
function minimizeModal(modal) {
if (modal.classList.contains('hidden')) return;
modal.classList.add('minimized');
if (dockEntries.has(modal)) return;
const entry = document.createElement('div');
entry.className = 'modal-dock-item';
entry.title = `Restore ${modalTitle(modal)}`;
const label = document.createElement('span');
label.className = 'modal-dock-label';
label.textContent = modalTitle(modal);
const closeX = document.createElement('button');
closeX.className = 'modal-dock-close';
closeX.textContent = '×';
closeX.title = 'Close';
closeX.addEventListener('click', (e) => {
e.stopPropagation();
modal.classList.remove('minimized');
modal.classList.add('hidden');
modal.style.display = '';
removeDockEntry(modal);
});
entry.appendChild(label);
entry.appendChild(closeX);
entry.addEventListener('click', () => restoreModal(modal));
dock.appendChild(entry);
dockEntries.set(modal, entry);
}
function injectMinimizeButton(modal) {
if (!modal || !modal.classList || !modal.classList.contains('modal')) return;
if (modal.id && SKIP_IDS.has(modal.id)) return;
// Modals managed by the new modalManager (Modals.register) get their own
// .modal-minimize-btn and chips via the .minimized-dock-chip system.
// Skip them entirely so we don't double-up minimize buttons or chips.
if (modal.id && /^email-reader-/.test(modal.id)) return;
if (modal.id && window.Modals && window.Modals.isRegistered && window.Modals.isRegistered(modal.id)) return;
const header = modal.querySelector('.modal-header');
if (!header) return;
if (header.querySelector('.minimize-btn, .modal-minimize-btn')) return;
const closeBtn = header.querySelector('.close-btn, .modal-close');
if (!closeBtn) return;
const minBtn = document.createElement('button');
minBtn.className = 'minimize-btn';
minBtn.type = 'button';
minBtn.title = 'Minimize';
minBtn.textContent = '_';
minBtn.addEventListener('mousedown', (e) => e.stopPropagation()); // don't start drag
minBtn.addEventListener('click', (e) => {
e.stopPropagation();
minimizeModal(modal);
});
closeBtn.parentElement.insertBefore(minBtn, closeBtn);
// Watch this modal's class so close-from-elsewhere clears the dock entry
new MutationObserver(() => {
if (modal.classList.contains('hidden') && !modal.classList.contains('minimized')) {
removeDockEntry(modal);
}
}).observe(modal, { attributes: true, attributeFilter: ['class'] });
}
// Initial pass over existing modals
document.querySelectorAll('.modal').forEach(injectMinimizeButton);
// Watch for dynamically-created modals
new MutationObserver((mutations) => {
for (const m of mutations) {
for (const n of m.addedNodes) {
if (n.nodeType !== 1) continue;
if (n.classList && n.classList.contains('modal')) {
injectMinimizeButton(n);
}
if (n.querySelectorAll) {
n.querySelectorAll('.modal').forEach(injectMinimizeButton);
}
}
}
}).observe(document.body, { childList: true, subtree: true });
})();
// Preset button (in overflow menu)
const overflowPresetBtn = el('overflow-preset-btn');
if (overflowPresetBtn) {
overflowPresetBtn.addEventListener('click', () => {
if (presetsModule && presetsModule.openCustomPresetModal) {
presetsModule.openCustomPresetModal();
}
});
}
// RAG directory
const addDirBtn = el('add-directory-btn');
if (addDirBtn) {
addDirBtn.addEventListener('click', () => {
ragModule.addRagDirectory(uiModule.showToast, uiModule.showError);
});
}
const directoryInput = el('rag-directory');
if (directoryInput) {
directoryInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
ragModule.addRagDirectory(uiModule.showToast, uiModule.showError);
}
});
}
// Sidebar layout (extracted to js/sidebar-layout.js)
initSidebarLayout(Storage, {
documentModule, _closeCompareIfActive, _deactivateIncognito,
presetsModule, sessionModule, el, _defaultChat, _syncResearchIndicator
});
// Mobile: horizontal swipe on a tabbed window switches tabs. Works for any
// tab bar whose buttons are siblings and switch on click (Prompt, Library,
// Brain, Theme) — we just click the prev/next tab so the existing switch
// logic runs. Swipes that start on interactive controls (sliders, inputs,
// the chip dock) are ignored so they don't fight text selection / dragging.
(function initTabSwipe() {
if (window.innerWidth > 768) return;
// [tab-bar selector, tab-button selector] for each tabbed window.
const SYSTEMS = [
['.preset-tabs', '.preset-tab'],
['.lib-tabs', '.lib-tab'],
['.memory-tabs', '.memory-tab'],
['.admin-tabs', '.admin-tab'],
];
const _IGNORE = 'input, textarea, select, [contenteditable="true"], .preset-range, ' +
'.note-cl-row, .minimized-dock-chip, canvas, .email-card-reader';
let sx = 0, sy = 0, tracking = false;
document.addEventListener('touchstart', (e) => {
if (window.innerWidth > 768 || e.touches.length !== 1) { tracking = false; return; }
if (e.target.closest && e.target.closest(_IGNORE)) { tracking = false; return; }
sx = e.touches[0].clientX; sy = e.touches[0].clientY; tracking = true;
}, { passive: true });
document.addEventListener('touchend', (e) => {
if (!tracking) return;
tracking = false;
const t = e.changedTouches[0];
if (!t) return;
const dx = t.clientX - sx, dy = t.clientY - sy;
// Require a deliberate, mostly-horizontal swipe.
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
for (const [barSel, tabSel] of SYSTEMS) {
const bar = document.querySelector(barSel);
if (!bar || bar.offsetParent === null) continue; // not the visible window
// Only act if the swipe happened inside this bar's window (not some
// other on-screen element).
const host = bar.closest('.modal, #notes-pane, .preset-modal-content, .admin-card') || bar.parentElement;
const startEl = document.elementFromPoint(sx, sy);
if (host && startEl && !host.contains(startEl)) continue;
const tabs = [...bar.querySelectorAll(tabSel)];
if (tabs.length < 2) continue;
let idx = tabs.findIndex(tb => tb.classList.contains('active'));
if (idx < 0) idx = 0;
// Swipe left (dx<0) → next tab; swipe right (dx>0) → previous.
const nextIdx = dx < 0 ? idx + 1 : idx - 1;
if (nextIdx < 0 || nextIdx >= tabs.length) return; // at an edge
tabs[nextIdx].click();
return;
}
}, { passive: true });
})();
// Elastic overscroll (rubber-band bounce) — desktop wheel only, on chat-history not container
(function initElasticScroll() {
const hist = el('chat-history');
if (!hist) return;
const SNAP_BACK = 'transform 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
let wheelPull = 0;
let wheelTimer = null;
hist.addEventListener('wheel', (e) => {
const atTop = hist.scrollTop <= 0 && e.deltaY < 0;
const atBottom = hist.scrollTop + hist.clientHeight >= hist.scrollHeight - 1 && e.deltaY > 0;
if (!atTop && !atBottom) { wheelPull = 0; return; }
wheelPull += e.deltaY * -0.03;
wheelPull = Math.max(-7, Math.min(7, wheelPull));
hist.style.transition = 'none';
hist.style.transform = `translateY(${wheelPull}px)`;
clearTimeout(wheelTimer);
wheelTimer = setTimeout(() => {
wheelPull = 0;
hist.style.transition = SNAP_BACK;
hist.style.transform = '';
}, 120);
}, { passive: true });
})();
// New session button on icon rail
const railNewSession = el('rail-new-session');
if (railNewSession) {
railNewSession.addEventListener('click', async () => {
if (!sessionModule) return;
if (_closeCompareIfActive()) return;
_deactivateIncognito();
// Clear character on new chat
if (presetsModule && presetsModule.deactivateCharacter) presetsModule.deactivateCharacter();
// Clear research mode if active
const _resChk = el('research-toggle');
if (_resChk && _resChk.checked) _syncResearchIndicator(false);
if (await _createDirectChatFromPreferredModel()) return;
// No models at all — show welcome screen
sessionModule.setCurrentSessionId(null);
if (documentModule && documentModule.isPanelOpen && documentModule.isPanelOpen()) documentModule.closePanel();
const docBtn3 = el('overflow-doc-btn');
if (docBtn3) docBtn3.classList.remove('active', 'has-docs');
const box = el('chat-history');
if (box) box.innerHTML = '';
if (chatModule && chatModule.showWelcomeScreen) {
chatModule.showWelcomeScreen();
}
document.querySelectorAll('.session-item.active').forEach(s => s.classList.remove('active'));
});
}
// Mobile new chat button — always go to blank welcome screen.
// The send path at chat.js:354 will auto-create a session using /api/default-chat
// on first submit, so users can start typing before models finish loading and
// the default model attaches when they hit send.
const mobileNewChat = el('mobile-new-chat-btn');
if (mobileNewChat) {
mobileNewChat.addEventListener('click', () => {
if (!sessionModule) return;
if (_closeCompareIfActive()) return;
_deactivateIncognito();
_startFreshChat();
document.querySelectorAll('.session-item.active').forEach(s => s.classList.remove('active'));
// Focus the composer synchronously so mobile keyboards pop open.
// iOS Safari only honours programmatic focus inside the original click
// callback — a setTimeout breaks the user-gesture chain.
const _input = el('message-input');
if (_input) { try { _input.focus(); } catch (_) {} }
});
}
// Logo click → new chat (same logic as rail new-session button)
const brandBtn = el('sidebar-brand-btn');
if (brandBtn) {
brandBtn.addEventListener('click', async () => {
if (!sessionModule) return;
if (_closeCompareIfActive()) return;
_deactivateIncognito();
if (presetsModule && presetsModule.deactivateCharacter) presetsModule.deactivateCharacter();
// Clear research toggle when starting a fresh chat (not via research button)
_syncResearchIndicator(false);
if (await _createDirectChatFromPreferredModel()) return;
// No models at all — show welcome screen
sessionModule.setCurrentSessionId(null);
if (documentModule && documentModule.isPanelOpen && documentModule.isPanelOpen()) documentModule.closePanel();
const docBtn2 = el('overflow-doc-btn');
if (docBtn2) docBtn2.classList.remove('active', 'has-docs');
const box = el('chat-history');
if (box) box.innerHTML = '';
if (chatModule && chatModule.showWelcomeScreen) chatModule.showWelcomeScreen();
document.querySelectorAll('.session-item.active').forEach(s => s.classList.remove('active'));
});
}
const sidebarNewChatBtn = el('sidebar-new-chat-btn');
if (sidebarNewChatBtn) {
sidebarNewChatBtn.addEventListener('click', () => {
const brandBtn = el('sidebar-brand-btn');
if (brandBtn) brandBtn.click();
});
}
// Delete session button on icon rail
const railDelete = el('rail-delete-session');
if (railDelete) {
railDelete.addEventListener('click', async () => {
if (!sessionModule) return;
const currentId = sessionModule.getCurrentSessionId();
if (!currentId) return;
const sessions = sessionModule.getSessions();
const current = sessions.find(s => s.id === currentId);
const name = current ? current.name : 'this session';
if (!await uiModule.styledConfirm(`Delete "${name}"?`, { confirmText: 'Delete', danger: true })) return;
try {
// Find the next session below the current one before deleting
const idx = sessions.findIndex(s => s.id === currentId);
const nextSession = sessions.filter(s => !s.archived && s.id !== currentId)[Math.max(0, idx)] ||
sessions.find(s => !s.archived && s.id !== currentId);
const res = await fetch(`${API_BASE}/api/session/${currentId}`, { method: 'DELETE' });
if (res.ok) {
await sessionModule.loadSessions();
if (nextSession) {
await sessionModule.selectSession(nextSession.id);
}
uiModule.showToast('Session deleted');
} else {
uiModule.showError('Failed to delete session');
}
} catch (e) {
uiModule.showError('Failed to delete session: ' + e);
}
});
}
// Textarea auto-resize
const textarea = el('message');
if (textarea) {
uiModule.autoResize(textarea);
textarea.addEventListener('input', () => {
uiModule.autoResize(textarea);
});
textarea.addEventListener('paste', () => {
setTimeout(() => uiModule.autoResize(textarea), 1);
});
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
// If ghost autocomplete is active, accept the suggestion instead of submitting
if (window._ghostAutocomplete && window._ghostAutocomplete.isActive()) {
e.preventDefault();
e.stopPropagation();
window._ghostAutocomplete.accept();
return;
}
e.preventDefault();
e.stopPropagation();
// Check if already submitting before triggering form submission
const form = el('chat-form');
if (form) {
const submitBtn = form.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.click();
}
}
});
}
// ── Ghost text autocomplete for /new and /create commands ──
(function initGhostAutocomplete() {
const textarea = el('message');
const ghost = document.getElementById('message-ghost');
if (!textarea || !ghost) return;
let modelCache = null; // { models: [{ mid, url, endpointId, displayName }], ts }
let filtered = []; // currently matching models
let cycleIdx = 0; // index into filtered[]
let active = false; // is ghost visible?
const CACHE_TTL = 60000; // re-fetch after 60s
const CMD_RE = /^\/(new|create)\s/i;
async function fetchModels() {
if (modelCache && Date.now() - modelCache.ts < CACHE_TTL) return modelCache.models;
try {
const res = await fetch(`${API_BASE}/api/models`, { credentials: 'same-origin' });
const data = await res.json();
const models = [];
(data.items || []).forEach(ep => {
const displayNames = ep.models_display || ep.models || [];
(ep.models || []).forEach((mid, i) => {
models.push({
mid,
url: ep.url,
endpointId: ep.endpoint_id || null,
displayName: displayNames[i] || mid,
});
});
});
modelCache = { models, ts: Date.now() };
return models;
} catch (e) {
console.warn('Ghost autocomplete: failed to fetch models', e);
return modelCache ? modelCache.models : [];
}
}
function hide() {
active = false;
filtered = [];
cycleIdx = 0;
ghost.textContent = '';
ghost.style.display = 'none';
}
function show(typed, suggestion) {
active = true;
ghost.innerHTML = '';
// Invisible portion matches what user typed (keeps alignment)
const span1 = document.createElement('span');
span1.style.visibility = 'hidden';
span1.textContent = typed;
// Visible faded suggestion portion
const span2 = document.createElement('span');
span2.className = 'ghost-suggestion';
span2.textContent = suggestion;
ghost.appendChild(span1);
ghost.appendChild(span2);
ghost.style.display = 'block';
}
function syncSize() {
// Match ghost overlay dimensions to textarea
const cs = getComputedStyle(textarea);
ghost.style.width = cs.width;
ghost.style.height = cs.height;
}
async function update() {
const val = textarea.value;
const match = val.match(CMD_RE);
if (!match) { hide(); return; }
const prefix = val.slice(match[0].length); // text after "/new " or "/create "
const models = await fetchModels();
if (!models.length) { hide(); return; }
// Filter models whose mid or displayName starts with the typed prefix (case-insensitive)
const lp = prefix.toLowerCase();
filtered = models.filter(m =>
m.mid.toLowerCase().startsWith(lp) || m.displayName.toLowerCase().startsWith(lp)
);
if (!filtered.length) { hide(); return; }
// Clamp cycle index
cycleIdx = cycleIdx % filtered.length;
const chosen = filtered[cycleIdx];
// Determine which name matched for completion
const name = chosen.mid.toLowerCase().startsWith(lp) ? chosen.mid : chosen.displayName;
const remainder = name.slice(prefix.length);
if (!remainder && filtered.length <= 1) { hide(); return; }
syncSize();
show(val, remainder);
}
// --- Event listeners ---
textarea.addEventListener('input', () => {
cycleIdx = 0;
update();
});
textarea.addEventListener('keydown', (e) => {
if (!active) return;
if (e.key === 'Tab') {
// Tab fills the current suggestion into the textarea
e.preventDefault();
e.stopPropagation();
const val = textarea.value;
const match = val.match(CMD_RE);
if (match && filtered.length) {
const prefix = val.slice(match[0].length);
const chosen = filtered[cycleIdx % filtered.length];
const lp = prefix.toLowerCase();
const name = chosen.mid.toLowerCase().startsWith(lp) ? chosen.mid : chosen.displayName;
textarea.value = match[0] + name;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
cycleIdx = (cycleIdx + 1) % filtered.length;
update();
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
cycleIdx = (cycleIdx - 1 + filtered.length) % filtered.length;
update();
return;
}
if (e.key === 'Escape') {
e.preventDefault();
hide();
return;
}
});
textarea.addEventListener('blur', hide);
// Observe textarea resize (from autoResize) to keep ghost in sync
const ro = new ResizeObserver(() => { if (active) syncSize(); });
ro.observe(textarea);
// Public API for the Enter handler above
window._ghostAutocomplete = {
isActive() { return active && filtered.length > 0; },
accept() {
if (!active || !filtered.length) return;
const val = textarea.value;
const match = val.match(CMD_RE);
if (!match) { hide(); return; }
const prefix = val.slice(match[0].length);
const chosen = filtered[cycleIdx % filtered.length];
const lp = prefix.toLowerCase();
const name = chosen.mid.toLowerCase().startsWith(lp) ? chosen.mid : chosen.displayName;
textarea.value = match[0] + name;
hide();
// Trigger input event so autoResize fires
textarea.dispatchEvent(new Event('input', { bubbles: true }));
// Now submit the form (the /new command handler will process it)
setTimeout(() => {
const form = el('chat-form');
if (form) form.querySelector('button[type="submit"]').click();
}, 0);
}
};
})();
// Keyboard shortcuts (extracted to js/keyboard-shortcuts.js)
initKeyboardShortcuts({
el, Storage, sessionModule, uiModule, chatModule,
adminModule, settingsModule, searchChatModule,
_closeCompareIfActive, _deactivateIncognito, API_BASE
});
}
// ============================================
// INITIALIZATION ON PAGE LOAD
// ============================================
function startOdysseusApp() {
if (window.__odysseusAppStarted) return;
window.__odysseusAppStarted = true;
// Set CSS variables
document.documentElement.style.setProperty('--line-height', '20px');
// Smooth keyboard open/close on mobile — keep chat scrolled to bottom
if (window.visualViewport && 'ontouchstart' in window) {
let _prevVPH = visualViewport.height;
visualViewport.addEventListener('resize', () => {
const delta = visualViewport.height - _prevVPH;
_prevVPH = visualViewport.height;
// Keyboard opened (viewport shrank significantly)
if (delta < -50) {
const hist = document.getElementById('chat-history');
if (hist) {
hist.style.scrollBehavior = 'smooth';
hist.scrollTop = hist.scrollHeight;
// Reset after animation
setTimeout(() => { hist.style.scrollBehavior = ''; }, 300);
}
}
});
}
// Initialize all event listeners
try { initializeEventListeners(); } catch(e) { console.error('Event init error:', e); }
// Reveal the toolbar now that all toggle/overflow state is resolved
// (hidden via inline style="visibility:hidden" in HTML to prevent FOUC)
const _inputBottom = document.querySelector('.chat-input-bottom');
if (_inputBottom) _inputBottom.style.visibility = '';
fileHandlerModule.init(API_BASE);
modelsModule.init(API_BASE);
ragModule.init(API_BASE);
presetsModule.init(API_BASE);
searchModule.init(API_BASE);
chatModule.init(API_BASE);
chatModule.initListeners();
groupModule.init(API_BASE);
// Initialize compare module
if (compareModule) {
compareModule.init(API_BASE);
}
researchPanelModule.init(API_BASE, markdownModule, sessionModule);
// Initialize document editor module
if (documentModule) {
documentModule.init(API_BASE);
// Restore document panel if it was open before refresh
const _curSession = sessionModule && sessionModule.getCurrentSessionId();
if (_curSession && localStorage.getItem('odysseus-doc-open-' + _curSession) === '1') {
documentModule.loadSessionDocs(_curSession);
}
}
// Initialize search chat module
if (searchChatModule) {
searchChatModule.init(API_BASE);
}
// Search buttons — icon rail + sidebar
const railSearchBtn = el('rail-search-btn');
if (railSearchBtn) {
railSearchBtn.addEventListener('click', () => {
if (searchChatModule) searchChatModule.openSearch();
});
}
// Rail tool buttons — delegate to sidebar tool buttons
const _railToolMap = {
'rail-compare': 'tool-compare-btn',
'rail-research': 'tool-research-btn',
'rail-cookbook': 'tool-cookbook-btn',
'rail-archive': 'tool-library-btn',
'rail-gallery': 'tool-gallery-btn',
'rail-tasks': 'tool-tasks-btn',
'rail-calendar': 'tool-calendar-btn',
'rail-notes': 'tool-notes-btn',
'rail-memory': 'tool-memory-btn',
'rail-theme': 'tool-theme-btn',
'rail-email': 'email-section-title',
};
Object.entries(_railToolMap).forEach(([railId, toolId]) => {
const railBtn = el(railId);
if (railBtn) {
railBtn.addEventListener('click', () => {
const toolBtn = el(toolId);
if (toolBtn) toolBtn.click();
});
}
});
// Rail chats — click to open the completed background session
const _railChatsBtn = el('rail-chats');
if (_railChatsBtn) {
_railChatsBtn.addEventListener('click', () => {
const targetSid = _railChatsBtn.dataset.targetSession;
if (targetSid && window.sessionModule) {
window.sessionModule.selectSession(targetSid);
}
// Clear notification — session will call clearStreamComplete on load
_railChatsBtn.classList.remove('rail-notify', 'rail-notify-success');
delete _railChatsBtn.dataset.targetSession;
_syncRailDynamic();
});
}
// Rail documents — toggle doc panel on/off (not library)
const _railDocsBtn = el('rail-documents');
if (_railDocsBtn) {
_railDocsBtn.addEventListener('click', () => {
const ob = el('overflow-doc-btn');
if (ob) ob.click();
});
}
// Rail: settings button
const _railSettings = el('rail-settings');
if (_railSettings) {
_railSettings.addEventListener('click', () => {
const sidebar = document.getElementById('sidebar');
if (sidebar) sidebar.classList.remove('hidden');
syncRailSide();
// Scroll to bottom where settings typically are
const sidebarInner = document.querySelector('.sidebar-inner');
if (sidebarInner) sidebarInner.scrollTo({ top: sidebarInner.scrollHeight, behavior: 'smooth' });
});
}
// Rail: admin button
const _railAdmin = el('rail-admin');
if (_railAdmin) {
_railAdmin.addEventListener('click', () => {
// Try to open admin modal
const adminBtn = document.querySelector('[data-modal="admin-modal"]') || el('tool-admin-btn');
if (adminBtn) adminBtn.click();
});
}
// Sync the contextual rail icons. Tool launchers (calendar/compare/cookbook/
// research/gallery/tasks/archive/memory/notes/theme/email) are now
// always-visible launchers, so only the doc + background-chat indicators
// are shown/hidden dynamically here.
function _syncRailDynamic() {
// Show doc icon if panel is open OR session has documents
const docPanelOpen = window.documentModule && window.documentModule.isPanelOpen();
const docIndicator = el('doc-indicator-btn');
const hasDocs = docIndicator && docIndicator.classList.contains('visible');
const docOpen = docPanelOpen || hasDocs;
const hasChatNotif = el('rail-chats')?.classList.contains('rail-notify');
const _show = (id, visible) => { const b = el(id); if (b) b.style.display = visible ? '' : 'none'; };
_show('rail-documents', docOpen);
_show('rail-chats', !!hasChatNotif);
}
window._syncRailDynamic = _syncRailDynamic;
// Sync periodically and on key events
setInterval(_syncRailDynamic, 1000);
document.addEventListener('overflow-state-change', _syncRailDynamic);
const sidebarSearchBtn = el('sidebar-search-btn');
if (sidebarSearchBtn) {
sidebarSearchBtn.addEventListener('click', () => {
if (searchChatModule) searchChatModule.openSearch();
});
}
// Modify form submit to handle special modes
const chatForm = document.getElementById('chat-form');
const originalSubmit = chatModule.handleChatSubmit;
let _submitting = false;
function handleSubmit(e) {
if (e) e.preventDefault();
// Debounce: prevent double-submit while a request is being initiated
if (_submitting) return;
_submitting = true;
// Release after a short delay (stream start sets its own isStreaming guard)
setTimeout(() => { _submitting = false; }, 300);
// Compare mode: route submit to compare handler (same message to all panes)
if (compareModule && compareModule.isActive()) {
return compareModule.handleCompareSubmit(e);
}
// Group chat: route to group module
if (groupModule && groupModule.isActive()) {
console.log('[group] Submit intercepted');
const msgInput = document.getElementById('message');
const msg = msgInput ? msgInput.value.trim() : '';
if (!msg) { console.log('[group] Empty message, skipping'); return; }
console.log('[group] Sending:', msg);
chatRenderer.hideWelcomeScreen();
chatRenderer.addMessage('user', msg);
msgInput.value = '';
groupModule.sendMessage(msg);
return;
}
return originalSubmit.call(chatModule, e);
}
chatForm.onsubmit = handleSubmit;
// ── Dual-purpose send/mic button ──
const sendBtn = document.querySelector('.send-btn');
const messageInput = el('message');
const modelPickerWrap = document.getElementById('model-picker-wrap');
const _sendIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>';
const _micIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
const _stopIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
const _newChatIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';
// Expose icons globally so chat.js updateSubmitButton can use them
window._odysseusBtnIcons = { send: _sendIcon, mic: _micIcon, stop: _stopIcon, newChat: _newChatIcon };
function _isSttEnabled() {
return voiceRecorderModule._sttProvider && voiceRecorderModule._sttProvider !== 'disabled';
}
function _hasAttachments() {
return fileHandlerModule.getPendingCount && fileHandlerModule.getPendingCount() > 0;
}
function _updateSendBtnIcon() {
if (!sendBtn) return;
// Don't override if streaming (stop button) or recording
if (sendBtn.dataset.mode === 'streaming' || sendBtn.dataset.mode === 'recording') return;
const prevMode = sendBtn.dataset.mode || '';
const hasText = messageInput && messageInput.value.trim().length > 0;
const hasFiles = _hasAttachments();
let newMode;
if (!hasText && !hasFiles && _isSttEnabled()) {
clearTimeout(sendBtn._collapseTimer);
sendBtn.innerHTML = _micIcon;
sendBtn.title = 'Record voice';
newMode = 'mic';
sendBtn.classList.add('mic-mode');
sendBtn.classList.remove('newchat-mode', 'newchat-expanded');
} else if (!hasText && !hasFiles && !_isSttEnabled()) {
clearTimeout(sendBtn._collapseTimer);
// Group chat: always show send button, never newchat mode
if (groupModule && groupModule.isActive()) {
sendBtn.innerHTML = _sendIcon;
sendBtn.title = 'Send to group';
newMode = 'idle';
sendBtn.classList.remove('mic-mode', 'newchat-mode', 'newchat-expanded');
} else {
// Check if we're already on a fresh empty session (welcome screen visible)
const isEmptySession = document.getElementById('chat-container')?.classList.contains('welcome-active');
if (isEmptySession) {
// Already on new chat — show arrow in muted style (ready to type)
sendBtn.innerHTML = _sendIcon;
sendBtn.title = 'Send message';
newMode = 'idle';
sendBtn.classList.add('newchat-mode'); // muted gray style
sendBtn.classList.remove('mic-mode', 'newchat-expanded');
clearTimeout(sendBtn._expandTimer);
} else {
sendBtn.innerHTML = _newChatIcon + '<span class="send-btn-label">+ New</span>';
sendBtn.title = 'New chat';
newMode = 'newchat';
sendBtn.classList.add('newchat-mode');
sendBtn.classList.remove('mic-mode');
// The button stays a 32px compact icon (no auto-expand to label —
// the "+ New" label inside is for screen readers only; sighted users
// see the spinning + on hover + the title tooltip).
clearTimeout(sendBtn._expandTimer);
sendBtn.classList.remove('newchat-expanded');
}
} // close group-else
} else {
newMode = 'send';
clearTimeout(sendBtn._expandTimer);
const wasExpanded = sendBtn.classList.contains('newchat-expanded');
const wasNewchat = prevMode === 'newchat' || prevMode === 'mic';
if (wasExpanded || wasNewchat) {
// Collapse pill if expanded, then spin arrow in (same as + spin-in)
if (wasExpanded) sendBtn.classList.remove('newchat-expanded');
const delay = wasExpanded ? 300 : 0;
setTimeout(() => {
if (sendBtn.dataset.mode !== 'send') return;
sendBtn.innerHTML = _sendIcon;
sendBtn.title = 'Send message';
sendBtn.classList.remove('mic-mode', 'newchat-mode', 'anim-spin-swap');
sendBtn.classList.add('anim-spin');
sendBtn.addEventListener('animationend', () => sendBtn.classList.remove('anim-spin'), { once: true });
}, delay);
} else {
sendBtn.innerHTML = _sendIcon;
sendBtn.title = 'Send message';
sendBtn.classList.remove('mic-mode', 'newchat-mode', 'newchat-expanded', 'anim-spin', 'anim-launch', 'anim-land');
}
}
// Animate icon spin — when switching TO newchat or mic (the + or mic
// appearing). The previous `prevMode && ...` guard skipped this after
// streaming ended (dataset.mode is reset to '' there, an empty falsy
// string), which let the lingering anim-land class from the stop icon's
// entry replay on the +, making it look like the + comes from below.
// Never animate into send mode (arrow) — it should just appear instantly.
if (newMode !== prevMode && (newMode === 'newchat' || newMode === 'mic')) {
if (!sendBtn.classList.contains('anim-spin')) {
sendBtn.classList.remove('anim-launch', 'anim-land');
sendBtn.classList.add('anim-spin');
sendBtn.addEventListener('animationend', () => sendBtn.classList.remove('anim-spin'), { once: true });
}
}
sendBtn.dataset.mode = newMode;
}
if (sendBtn) {
sendBtn.addEventListener('click', (e) => {
e.preventDefault();
// If recording, stop recording
if (sendBtn.dataset.mode === 'recording' || voiceRecorderModule.getIsRecording()) {
voiceRecorderModule.stopRecording();
return;
}
const hasText = messageInput && messageInput.value.trim().length > 0;
const hasFiles = _hasAttachments();
// New chat mode — empty input, no attachments, no STT
if (!hasText && !hasFiles && sendBtn.dataset.mode === 'newchat') {
if (sessionModule) {
const sessions = sessionModule.getSessions();
const currentId = sessionModule.getCurrentSessionId();
const current = sessions.find(s => s.id === currentId);
if (current && current.endpoint_url && current.model) {
sessionModule.createDirectChat(current.endpoint_url, current.model, current.endpoint_id);
} else {
// Fallback to rail button
const railNew = el('rail-new-session');
if (railNew) railNew.click();
}
}
return;
}
// If input is empty and STT is enabled, start recording
if (!hasText && !hasFiles && _isSttEnabled()) {
sendBtn.innerHTML = _stopIcon;
sendBtn.title = 'Stop recording';
sendBtn.dataset.mode = 'recording';
sendBtn.classList.add('recording');
voiceRecorderModule.startRecording(
(audioFile) => fileHandlerModule.addFiles([audioFile]),
uiModule.showToast,
uiModule.showError
);
return;
}
// Otherwise, send message
handleSubmit(e);
});
}
// Enter to send (shift+enter for newline), or new chat when empty
if (messageInput) {
messageInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
// Flush the debounced icon update so dataset.mode reflects the current
// text state. Without this, a fast type-and-Enter would still see the
// stale 'newchat' mode and open a new chat instead of sending.
try { _updateSendBtnIcon(); } catch {}
if (sendBtn && sendBtn.dataset.mode === 'newchat') {
const railNew = el('rail-new-session');
if (railNew) railNew.click();
return;
}
handleSubmit(e);
}
});
}
// Toggle mic/send icon on input change + hide model picker after enough text
if (messageInput) {
const _debouncedUpdateIcon = uiModule.debounce(_updateSendBtnIcon, 50);
const _MODEL_PICKER_HIDE_CHARS = 10;
const _syncModelPickerAutohide = () => {
const hidePicker = (messageInput.value || '').replace(/\s/g, '').length >= _MODEL_PICKER_HIDE_CHARS;
if (modelPickerWrap) {
modelPickerWrap.classList.toggle('model-picker-autohide', hidePicker);
}
};
window._syncModelPickerAutohide = _syncModelPickerAutohide;
_syncModelPickerAutohide();
messageInput.addEventListener('input', () => {
_syncModelPickerAutohide();
_debouncedUpdateIcon();
}, { passive: true });
}
// Collapse "New Session" label on scroll
const _chatScroll = document.getElementById('chat-container');
if (_chatScroll && sendBtn) {
_chatScroll.addEventListener('scroll', () => {
if (sendBtn.classList.contains('newchat-expanded')) {
sendBtn.classList.remove('newchat-expanded');
}
}, { passive: true });
}
// Expose globally so voiceRecorder can trigger update after async fetch
window._updateSendBtnIcon = _updateSendBtnIcon;
// Initial icon state
_updateSendBtnIcon();
// Auto-focus input on load
if (messageInput) {
setTimeout(() => messageInput.focus(), 100);
}
// Add drag and drop handlers for the chat container
const chatContainer = el('chat-container');
// Prevent default to allow drop
const chatInputBar = chatContainer.querySelector('.chat-input-bar');
function _showDropHighlight() {
chatContainer.style.backgroundColor = 'rgba(0, 170, 255, 0.1)';
chatContainer.style.transition = 'background-color 0.2s ease';
if (chatInputBar) {
chatInputBar.style.outline = '2px dashed color-mix(in srgb, var(--accent, #0af) 50%, transparent)';
chatInputBar.style.outlineOffset = '-2px';
chatInputBar.style.background = 'color-mix(in srgb, var(--accent, #0af) 8%, var(--bg))';
chatInputBar.style.transition = 'outline 0.2s ease, background 0.2s ease';
}
}
function _hideDropHighlight() {
chatContainer.style.backgroundColor = '';
if (chatInputBar) {
chatInputBar.style.outline = '';
chatInputBar.style.outlineOffset = '';
chatInputBar.style.background = '';
}
}
chatContainer.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
_showDropHighlight();
});
chatContainer.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
_hideDropHighlight();
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
fileHandlerModule.addFiles(files);
fileHandlerModule.renderAttachStrip();
uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to chat`);
});
chatContainer.addEventListener('dragleave', (e) => {
e.preventDefault();
_hideDropHighlight();
});
// Make the attachment strip also a drop target
const attachStrip = el('attach-strip');
attachStrip.addEventListener('dragover', (e) => {
e.preventDefault();
attachStrip.style.backgroundColor = 'rgba(0, 170, 255, 0.1)';
attachStrip.style.borderRadius = '4px';
});
attachStrip.addEventListener('drop', (e) => {
e.preventDefault();
attachStrip.style.backgroundColor = '';
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to chat`);
});
attachStrip.addEventListener('dragleave', (e) => {
e.preventDefault();
attachStrip.style.backgroundColor = '';
});
// ── Compare-mode file drop shield ──────────────────────────────────────────
// Compare reuses #chat-container, but each pane renders into a sandboxed
// <iframe>. Iframes swallow drag-and-drop events: a file dropped on a pane is
// handled by the iframe, not the parent, so the browser loads the file *inside
// the pane* ("behind" the app) instead of attaching it. The chatContainer drop
// handler above never sees it because the event doesn't bubble out of the frame.
//
// Fix: while a file drag is active in Compare, raise a single full-window shield
// that sits above every pane/iframe and becomes the drop target. The drop then
// lands on the parent document and we route the files into the shared composer
// (the same pending-files pipeline the picker and paste use). Scoped to Compare
// via the .compare-active class, so normal chat and the tool dropzones (gallery,
// RAG, document editor, …) are unaffected.
let _cmpDropShield = null;
const _isFileDrag = (e) => {
const types = e.dataTransfer && e.dataTransfer.types;
return !!types && Array.prototype.indexOf.call(types, 'Files') !== -1;
};
const _compareActive = () => {
const c = el('chat-container');
return !!c && c.classList.contains('compare-active');
};
const _showCmpShield = () => {
if (!_cmpDropShield) {
_cmpDropShield = document.createElement('div');
_cmpDropShield.id = 'compare-drop-shield';
_cmpDropShield.setAttribute('aria-hidden', 'true');
_cmpDropShield.style.cssText = 'position:fixed;inset:0;z-index:2147483646;' +
'display:none;align-items:center;justify-content:center;' +
'background:color-mix(in srgb, var(--accent, #0af) 16%, rgba(0,0,0,0.5));' +
'backdrop-filter:blur(2px);';
const _box = document.createElement('div');
_box.style.cssText = 'pointer-events:none;border:2px dashed rgba(255,255,255,0.9);' +
'border-radius:14px;padding:20px 28px;background:rgba(0,0,0,0.4);' +
'font:600 16px/1.4 system-ui,sans-serif;color:#fff;';
_box.textContent = 'Drop files to attach';
_cmpDropShield.appendChild(_box);
document.body.appendChild(_cmpDropShield);
}
_cmpDropShield.style.display = 'flex';
};
const _hideCmpShield = () => { if (_cmpDropShield) _cmpDropShield.style.display = 'none'; };
// Capture phase so we raise the shield before the pointer reaches an iframe.
window.addEventListener('dragenter', (e) => {
if (_isFileDrag(e) && _compareActive()) _showCmpShield();
}, true);
window.addEventListener('dragover', (e) => {
if (!_isFileDrag(e) || !_compareActive()) return;
e.preventDefault(); // mark as a valid drop target
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
_showCmpShield();
}, true);
window.addEventListener('dragleave', (e) => {
// Hide only when the drag actually leaves the window (no relatedTarget).
if (_compareActive() && !e.relatedTarget) _hideCmpShield();
}, true);
window.addEventListener('dragend', _hideCmpShield, true);
window.addEventListener('drop', (e) => {
if (!_isFileDrag(e) || !_compareActive()) return;
e.preventDefault();
_hideCmpShield();
const files = Array.from(e.dataTransfer.files || []);
if (!files.length) return;
fileHandlerModule.addFiles(files);
fileHandlerModule.renderAttachStrip();
uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to attach`);
}, true);
// Load initial data
presetsModule.loadPresets(uiModule.showError);
if (sessionModule) {
sessionModule.initDependencies({
API_BASE: API_BASE,
el: el,
showToast: uiModule.showToast,
showError: uiModule.showError,
addMessage: chatModule.addMessage,
renderContent: markdownModule.renderContent,
scrollHistory: uiModule.scrollHistoryInstant
});
// Load sessions first (critical path) — remove loader when done
sessionModule.loadSessions()
.catch(e => console.warn('loadSessions error:', e))
.finally(() => {
const loader = document.getElementById('app-loader');
if (loader) { loader.style.opacity = '0'; setTimeout(() => loader.remove(), 300); }
// Fire any URL route opener now that sessions + module wiring are
// ready. Deferred from up top of init for exactly this reason.
if (window._odysseusRouteOpener) {
try { window._odysseusRouteOpener(); } catch (_) {}
window._odysseusRouteOpener = null;
}
});
} else {
console.error('Session module not loaded!');
}
// Non-critical: load in parallel, resolve silently
modelsModule.refreshModels(true).then(() => {
const modelsBox = document.getElementById('models');
const hasModels = modelsBox && modelsBox.querySelector('.models-row');
if (!hasModels) {
const tip = document.getElementById('welcome-tip');
if (tip) tip.textContent = 'Add an AI endpoint from Settings in the sidebar, or paste an endpoint/API key into the chat.';
}
}).catch(() => {});
modelsModule.refreshProviders();
ragModule.loadPersonalDocs();
memoryModule.loadMemories(); // Ensure memories are loaded on page load
// Ensure the memory list is rendered after loading
setTimeout(async () => {
await memoryModule.loadMemories();
}, 1000);
// Ensure proper initial state
voiceRecorderModule.init();
if (censorModule) censorModule.init();
// Auto-focus message input on load
const msgEl = document.getElementById('message');
if (msgEl) msgEl.focus();
// Initialize mouse-based drag for sidebar sections
const sidebar = document.getElementById('sidebar');
const sidebarInner = sidebar ? sidebar.querySelector('.sidebar-inner') : sidebar;
// ── Subtle elastic overscroll for sidebar ──
if (sidebarInner) {
const MAX_PULL = 8;
let _overscroll = 0;
let _resetTimer = null;
sidebarInner.addEventListener('wheel', (e) => {
const el = sidebarInner;
const atTop = el.scrollTop <= 0 && e.deltaY < 0;
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1 && e.deltaY > 0;
if (!atTop && !atBottom) { _overscroll = 0; return; }
// Accumulate overscroll with diminishing returns
_overscroll += Math.abs(e.deltaY) * 0.15;
const pull = Math.min(_overscroll, MAX_PULL);
const dir = atTop ? 1 : -1;
el.style.transition = 'none';
el.style.transform = `translateY(${dir * pull}px)`;
// Reset after scrolling stops
clearTimeout(_resetTimer);
_resetTimer = setTimeout(() => {
el.style.transition = 'transform 0.3s cubic-bezier(0.25, 1, 0.5, 1)';
el.style.transform = '';
_overscroll = 0;
}, 120);
}, { passive: true });
}
// ── Global touch-scroll guard for sidebar ──
// Suppress click events when the user was scrolling (finger moved).
// This prevents accidental session/model/setting selection while swiping.
if (sidebarInner && 'ontouchstart' in window) {
let _sidebarTouchMoved = false;
let _sidebarTouchStartY = 0;
sidebarInner.addEventListener('touchstart', (e) => {
_sidebarTouchMoved = false;
_sidebarTouchStartY = e.touches[0].clientY;
}, { passive: true });
sidebarInner.addEventListener('touchmove', (e) => {
// Only flag as scroll if finger moved more than 8px vertically
if (Math.abs(e.touches[0].clientY - _sidebarTouchStartY) > 8) {
_sidebarTouchMoved = true;
}
}, { passive: true });
sidebarInner.addEventListener('click', (e) => {
if (_sidebarTouchMoved) {
e.stopPropagation();
e.preventDefault();
_sidebarTouchMoved = false;
}
}, true); // capture phase — intercepts before any child handlers
}
// Section collapse/expand + drag reorder (extracted to js/section-management.js)
initSectionCollapse(Storage);
initSectionDrag(Storage, loadUIVis);
// Handle drag over and out for individual sections
const sections = document.querySelectorAll('.section[draggable="true"]');
sections.forEach(section => {
section.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
// Only show visual feedback if we're not dragging over the active element
const activeId = e.dataTransfer.getData('text/plain');
if (activeId && activeId !== section.id) {
section.setAttribute('dnd-over', 'true');
}
});
section.addEventListener('dragleave', (e) => {
// Check if we're actually leaving the element
const rect = section.getBoundingClientRect();
if (e.clientY < rect.top || e.clientY > rect.bottom ||
e.clientX < rect.left || e.clientX > rect.right) {
section.setAttribute('dnd-over', 'false');
}
});
});
// Restore saved order on load
const savedOrder = Storage.get(Storage.KEYS.SECTION_ORDER);
if (savedOrder) {
try {
const order = JSON.parse(savedOrder);
const innerContainer = sidebarInner || document.getElementById('sidebar');
// Create a document fragment to minimize reflows
const fragment = document.createDocumentFragment();
// First, collect all sections in the desired order
for (const id of order) {
const section = document.getElementById(id);
if (section) {
fragment.appendChild(section);
}
}
// Append any remaining sections (in case new ones were added)
sections.forEach(section => {
if (!order.includes(section.id)) {
fragment.appendChild(section);
}
});
// Finally, add all sections back to the container
innerContainer.appendChild(fragment);
} catch (e) {
console.error('Failed to restore sidebar order:', e);
}
}
if (window.hljs) {
console.log('Highlighting all code blocks on page load');
document.querySelectorAll('pre code:not(.hljs)').forEach(block => {
window.hljs.highlightElement(block);
});
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startOdysseusApp, { once: true });
} else {
startOdysseusApp();
}