2026-05-31 23:58:26 +09:00
// ============================================
// 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 ( ) ;
2026-06-02 15:00:50 +03:00
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 ;
}
2026-05-31 23:58:26 +09:00
// ============================================
// 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' ) ;
2026-06-03 10:42:28 +05:30
// Prefer dataset.raw (original markdown) over innerText (rendered HTML as text)
// to avoid extra newlines and formatting artifacts.
const text = body ? ( body . dataset . raw || body . innerText || body . textContent || '' ) . trim ( ) : '' ;
2026-05-31 23:58:26 +09:00
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 ;
}
2026-06-01 21:08:57 +07:00
// 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 ;
}
2026-06-03 04:44:27 +05:30
// Model picker popup — close before opening any modals
const modelPickerMenu = document . getElementById ( 'model-picker-menu' ) ;
if ( modelPickerMenu && modelPickerMenu . classList . contains ( 'open' ) ) {
modelPickerMenu . classList . remove ( 'open' ) ;
return ;
}
2026-05-31 23:58:26 +09:00
// 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)
2026-06-03 04:44:27 +05:30
const dynamicModals = [ 'library-modal' , 'archive-modal' , 'doclib-modal' , 'gallery-modal' , 'tasks-modal' , 'email-lib-modal' ] ;
2026-05-31 23:58:26 +09:00
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' ) ;
}
} ) ;
}
2026-06-01 03:52:10 +01:00
// Manage Chats — opens Full Library modal (decoupled from Chats accordion toggle)
const chatsLibraryBtn = el ( 'chats-library-btn' ) ;
if ( chatsLibraryBtn ) {
chatsLibraryBtn . addEventListener ( 'click' , ( e ) => {
2026-05-31 23:58:26 +09:00
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' ) ;
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
agentBtn . setAttribute ( 'aria-pressed' , String ( mode === 'agent' ) ) ;
chatBtn . setAttribute ( 'aria-pressed' , String ( mode === 'chat' ) ) ;
2026-05-31 23:58:26 +09:00
// 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 ) ;
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
btn . setAttribute ( 'aria-pressed' , String ( saved ) ) ;
2026-05-31 23:58:26 +09:00
btn . addEventListener ( 'click' , ( ) => {
const curMode = ( loadToggleState ( ) . mode ) || 'chat' ;
const chk = el ( checkboxId ) ;
chk . checked = ! chk . checked ;
btn . classList . toggle ( 'active' , chk . checked ) ;
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
btn . setAttribute ( 'aria-pressed' , String ( chk . checked ) ) ;
2026-05-31 23:58:26 +09:00
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:' ;
} ) ;
}
2026-06-01 13:41:27 +07:00
const _DEOJ _SKIP = '.sources-section, .thinking-toggle, .memory-used-pill' ;
2026-05-31 23:58:26 +09:00
/** Walk all text nodes inside an element and replace emojis with text descriptions */
function deEmojify ( root ) {
2026-06-01 13:41:27 +07:00
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 ) ) ) ;
} ) ;
2026-05-31 23:58:26 +09:00
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
2026-06-01 13:41:27 +07:00
if ( node . parentElement && node . parentElement . closest ( _DEOJ _SKIP ) ) continue ;
2026-05-31 23:58:26 +09:00
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 ) ;
2026-06-02 15:00:50 +03:00
if ( await _createDirectChatFromPreferredModel ( ) ) return ;
2026-05-31 23:58:26 +09:00
// 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 ) ;
2026-06-02 15:00:50 +03:00
if ( await _createDirectChatFromPreferredModel ( ) ) return ;
2026-05-31 23:58:26 +09:00
// 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 ) ;
2026-06-02 09:52:22 -05:00
const res = await fetch ( ` ${ API _BASE } /api/session/ ${ currentId } ` , { method : 'DELETE' } ) ;
2026-05-31 23:58:26 +09:00
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 ) => {
2026-06-02 21:32:50 +08:00
if ( e . key === 'Enter' && ! e . shiftKey && ! e . isComposing ) {
2026-05-31 23:58:26 +09:00
// 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 ) => {
2026-06-02 21:32:50 +08:00
if ( e . key === 'Enter' && ! e . shiftKey && ! e . isComposing ) {
2026-05-31 23:58:26 +09:00
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 = '' ;
} ) ;
2026-06-02 04:14:59 +02:00
// ── 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 ) ;
2026-05-31 23:58:26 +09:00
// 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 ( ) ;
}