// Session Management Functions // This module handles all session-related operations import Storage from './storage.js'; import uiModule, { styledPrompt } from './ui.js'; import markdownModule from './markdown.js'; import chatRenderer from './chatRenderer.js'; import { providerLogo } from './providers.js'; import { initModelPicker, updateModelPicker } from './modelPicker.js'; import themeModule from './theme.js'; import spinnerModule from './spinner.js'; const API_BASE = window.location.origin; let sessions = []; let currentSessionId = null; let _sessionNavToken = 0; let _skipAutoSelect = false; const SIDEBAR_MAX_VISIBLE = 10; const FOLDER_MAX_VISIBLE = 5; let _showAllSessions = false; let _expandedFolders = {}; // folderName -> true if "show more" clicked let _sortMode = Storage.get('odysseus-session-sort') || 'active'; // default to last active let _autoCreateInProgress = false; // guard against recursive auto-create const _INCOGNITO_SESSIONS_KEY = 'ody-incognito-sessions'; // sessionStorage key for incognito session IDs const _isMac = /Mac|iPhone|iPad/.test(navigator.platform); const _mod = _isMac ? '⌘' : 'Ctrl'; function _getIncognitoIds() { try { return JSON.parse(sessionStorage.getItem(_INCOGNITO_SESSIONS_KEY) || '[]'); } catch { return []; } } function _markIncognito(sid) { const ids = _getIncognitoIds(); if (!ids.includes(sid)) { ids.push(sid); sessionStorage.setItem(_INCOGNITO_SESSIONS_KEY, JSON.stringify(ids)); } } function _isIncognitoSession(sid) { return _getIncognitoIds().includes(sid); } async function _cleanupIncognitoSessions() { const ids = _getIncognitoIds(); if (ids.length === 0) return; // Keep the current active incognito session alive, delete the rest const toDelete = ids.filter(sid => sid !== currentSessionId); if (toDelete.length === 0) return; const keep = ids.filter(sid => sid === currentSessionId); sessionStorage.setItem(_INCOGNITO_SESSIONS_KEY, JSON.stringify(keep)); await Promise.all(toDelete.map(sid => fetch(`${API_BASE}/api/session/${sid}`, { method: 'DELETE' }).catch(() => {}) )); } // Research indicator tracking const _researchingSessions = new Set(); const _streamingSessions = new Set(); // Background chat streams (not polled against research API) const _completedSessions = new Set(); // Sessions with completed background streams let _researchPollTimer = null; // Session list keyboard navigation state let _sessionListFocused = false; /** Clear current session from UI (after delete/archive). */ function _deselectCurrentSession(sid) { if (currentSessionId !== sid) return; currentSessionId = null; uiModule.el('chat-history').innerHTML = ''; uiModule.el('current-meta').textContent = 'Odysseus Chat'; Storage.remove('lastSessionId'); history.replaceState(null, '', window.location.pathname); if (window.chatModule && window.chatModule.showWelcomeScreen) { window.chatModule.showWelcomeScreen(); } // Reset send button to idle state const submitBtn = document.querySelector('.send-btn'); if (submitBtn) { submitBtn.dataset.mode = ''; delete submitBtn.dataset.phase; submitBtn.classList.remove('recording'); } if (window._updateSendBtnIcon) window._updateSendBtnIcon(); } function _removeSessionFromLocalState(sid) { if (!sid) return; const id = String(sid); sessions = sessions.filter(s => String(s.id) !== id); _selectedIds.delete(id); try { const savedOrder = Storage.get('session-order'); if (savedOrder) { const orderIds = JSON.parse(savedOrder); if (Array.isArray(orderIds) && orderIds.some(x => String(x) === id)) { Storage.set('session-order', JSON.stringify(orderIds.filter(x => String(x) !== id))); } } } catch (e) { console.warn('Failed to prune deleted session order:', e); } document.querySelectorAll('.list-item[data-session-id]').forEach(el => { if (String(el.dataset.sessionId) === id) el.remove(); }); _deselectCurrentSession(id); } function _normalizeSessionsList(fetched) { if (!Array.isArray(fetched)) return []; const seen = new Set(); const unique = []; for (const session of fetched) { if (!session || session.id == null) continue; const id = String(session.id); if (seen.has(id)) continue; seen.add(id); unique.push(session); } return unique; } // Initialize dependencies from app.js (no-op: dependencies now imported directly) export function initDependencies() {} // ── Folder state persistence ── const FOLDER_STATE_KEY = 'odysseus-folder-state'; const FOLDER_ORDER_KEY = 'odysseus-folder-order'; function loadFolderState() { return Storage.getJSON(FOLDER_STATE_KEY, {}); } function saveFolderState(state) { Storage.setJSON(FOLDER_STATE_KEY, state); } function loadFolderOrder() { return Storage.getJSON(FOLDER_ORDER_KEY, []); } function saveFolderOrder(order) { Storage.setJSON(FOLDER_ORDER_KEY, order); } /** Get all unique folder names from current sessions. */ function getFolderNames() { const names = new Set(); sessions.forEach(s => { if (s.folder) names.add(s.folder); }); return Array.from(names).sort(); } /** Move a session to a folder via the API. */ async function moveToFolder(sessionId, folderName) { const fd = new FormData(); fd.append('folder', folderName || ''); await fetch(`${API_BASE}/api/session/${sessionId}`, { method: 'PATCH', body: fd }); // Update local data const s = sessions.find(x => x.id === sessionId); if (s) s.folder = folderName || null; renderSessionList(); } /** Build the "Move to folder" submenu for a session dropdown. */ function buildFolderSubmenu(sessionId, currentFolder, dropdown) { const folders = getFolderNames(); const moveItem = document.createElement('div'); moveItem.className = 'dropdown-item-compact'; moveItem.style.position = 'relative'; const _folderIcon = ''; moveItem.innerHTML = 'Move to folder'; const sub = document.createElement('div'); sub.className = 'dropdown session-folder-submenu'; // "No folder" option const noneOpt = document.createElement('div'); noneOpt.className = 'dropdown-item-compact'; if (!currentFolder) noneOpt.style.opacity = '0.5'; noneOpt.textContent = '(No folder)'; noneOpt.addEventListener('click', async (e) => { e.stopPropagation(); await moveToFolder(sessionId, ''); dropdown.style.display = 'none'; sub.style.display = 'none'; }); sub.appendChild(noneOpt); // Existing folders folders.forEach(f => { const opt = document.createElement('div'); opt.className = 'dropdown-item-compact'; if (f === currentFolder) opt.style.opacity = '0.5'; opt.textContent = f; opt.addEventListener('click', async (e) => { e.stopPropagation(); await moveToFolder(sessionId, f); // Auto-flip to By Folder view so the user can see where the // chat went, same as when creating a new folder. setSortMode('group'); dropdown.style.display = 'none'; sub.style.display = 'none'; }); sub.appendChild(opt); }); // "New folder" option const newOpt = document.createElement('div'); newOpt.className = 'dropdown-item-compact'; newOpt.style.color = 'var(--accent-primary)'; newOpt.textContent = '+ New Folder'; newOpt.addEventListener('click', async (e) => { e.stopPropagation(); const name = await styledPrompt('Name this folder:', { title: 'New folder', placeholder: 'e.g. Work, Research, Drafts', confirmText: 'Create', }); if (!name || !name.trim()) return; await moveToFolder(sessionId, name.trim()); // Auto-flip to By Folder view so the user immediately sees the // folder they just created — otherwise the new folder disappears // into the flat list and looks like the action did nothing. setSortMode('group'); dropdown.style.display = 'none'; sub.style.display = 'none'; }); sub.appendChild(newOpt); moveItem.addEventListener('click', (e) => { e.stopPropagation(); if (sub.style.display === 'block') { sub.style.display = 'none'; } else { const rect = moveItem.getBoundingClientRect(); const isMobile = window.innerWidth <= 768; sub.style.top = '-9999px'; sub.style.display = 'block'; const subRect = sub.getBoundingClientRect(); if (isMobile) { // On mobile: position below the dropdown, centered const ddRect = dropdown.getBoundingClientRect(); sub.style.left = Math.max(8, ddRect.left) + 'px'; sub.style.width = Math.min(ddRect.width, window.innerWidth - 16) + 'px'; const topBelow = ddRect.bottom + 4; if (topBelow + subRect.height > window.innerHeight) { sub.style.top = Math.max(8, ddRect.top - subRect.height - 4) + 'px'; } else { sub.style.top = topBelow + 'px'; } } else { // Desktop: to the right sub.style.left = rect.right + 2 + 'px'; sub.style.width = ''; if (rect.top + subRect.height > window.innerHeight) { sub.style.top = Math.max(2, window.innerHeight - subRect.height - 4) + 'px'; } else { sub.style.top = rect.top + 'px'; } // Clamp right edge if (rect.right + 2 + subRect.width > window.innerWidth - 8) { sub.style.left = Math.max(8, rect.left - subRect.width - 2) + 'px'; } } } }); sub.addEventListener('click', (e) => e.stopPropagation()); document.addEventListener('click', () => { sub.style.display = 'none'; }); document.body.appendChild(sub); return moveItem; } /** Create a single session list-item element. */ function createSessionItem(s) { const div = document.createElement('div'); div.className = 'list-item session-item'; div.setAttribute('role', 'option'); div.setAttribute('tabindex', '-1'); div.setAttribute('data-session-id', s.id); // Special-session sentinel — true for the legacy OpenClaw row, which // skips the normal provider dot / name / action chrome. Was // previously detected here but the declaration got removed while // leaving the references in place, causing ReferenceError on every // session list re-render. const isOpenClaw = s.is_openclaw || s.id === 'openclaw'; // Drag handle const handle = document.createElement('span'); handle.className = 'item-drag-handle'; handle.textContent = '\u22EE\u22EE'; handle.title = 'Drag to reorder'; div.appendChild(handle); // Provider dot indicator if (!isOpenClaw) { const star = document.createElement('span'); const _logo = providerLogo(s.model); if (_logo) { star.className = 'session-star provider-logo'; star.innerHTML = _logo; star.style.opacity = '0.4'; } else { star.className = 'session-star'; } div.appendChild(star); } // Session type icon const icon = document.createElement('span'); const _isFork = s.name && (s.name.startsWith('Fork:') || s.name.startsWith('\u2ADD')); const _isGroup = s.name && s.name.startsWith('[GRP]'); icon.className = 'session-icon' + (s.has_documents ? ' has-docs' : ''); if (_isGroup) { icon.innerHTML = ''; } else if (_isFork) { icon.textContent = '\u2ADD'; icon.style.fontSize = '14px'; } else if (s.has_documents) { icon.innerHTML = ''; } else if (s.has_images) { icon.innerHTML = ''; } else if (s.mode === 'agent') { icon.innerHTML = ''; } else if (s.mode === 'research') { icon.innerHTML = ''; } else { icon.innerHTML = ''; } // Favorite bookmark replaces session-icon when important if (s.is_important && !isOpenClaw) { icon.className = 'session-icon session-fav'; icon.title = 'Unfavorite'; icon.innerHTML = ''; icon.addEventListener('click', async (e) => { e.stopPropagation(); const fd = new FormData(); fd.append('important', false); await fetch(`${API_BASE}/api/session/${s.id}/important`, { method: 'POST', body: fd }); s.is_important = false; uiModule.showToast('Unfavorited'); renderSessionList(); }); } div.appendChild(icon); const span = document.createElement('span'); span.className = 'grow'; let chatTitle = s.name || ''; if (_isFork) chatTitle = chatTitle.replace(/^Fork:\s*/, '').replace(/^\u2ADD\s*/, ''); if (_isGroup) chatTitle = chatTitle.replace(/^\[GRP\]\s*/, ''); let label = chatTitle; if (s.model) label += ' · ' + s.model.split('/').pop(); if (s.archived) label += ' [archived]'; span.textContent = label; span.title = (s.model ? s.model.split('/').pop() + ' · ' : '') + chatTitle; span.classList.add('text-ellipsis'); // Double-click to rename (only when session is already selected) if (!isOpenClaw) { span.addEventListener('dblclick', (e) => { if (currentSessionId !== s.id) return; // must be selected first e.stopPropagation(); const input = document.createElement('input'); input.type = 'text'; input.value = s.name || ''; input.className = 'session-rename-input'; span.replaceWith(input); input.focus(); input.select(); const _stopGuard = _guardSidebarDuringRename(); const commit = async () => { const newName = input.value.trim(); if (newName && newName !== s.name) { const fd = new FormData(); fd.append('name', newName); await fetch(`${API_BASE}/api/session/${s.id}`, { method: 'PATCH', body: fd }); s.name = newName; uiModule.showToast('Renamed'); } _forceSidebarOpen(); renderSessionList(); _stopGuard(); }; input.addEventListener('blur', commit); input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); } if (ev.key === 'Escape') { input.removeEventListener('blur', commit); _forceSidebarOpen(); renderSessionList(); _stopGuard(); } }); }); } // Clicking anywhere on the row selects the session (except drag handle and menu) // On mobile, suppress click if user was scrolling (touchmove detected) // Long press on mobile shows context menu let _touchMoved = false; let _longPressTimer = null; let _longPressed = false; div.addEventListener('touchstart', (e) => { _touchMoved = false; _longPressed = false; if (window.innerWidth > 768) return; _longPressTimer = setTimeout(() => { _longPressed = true; // Haptic feedback if available if (navigator.vibrate) navigator.vibrate(30); // Show the session dropdown directly (menu button is hidden on mobile) const dd = div._sessionDropdown; if (dd) { // Close any other open dropdowns document.querySelectorAll('.dropdown').forEach(d => { if (d !== dd) d.style.display = 'none'; }); const rect = div.getBoundingClientRect(); dd.style.position = 'fixed'; dd.style.left = rect.left + 'px'; dd.style.top = (rect.bottom + 4) + 'px'; dd.style.right = 'auto'; dd.style.display = 'block'; dd.style.zIndex = '1000'; // Clamp to viewport requestAnimationFrame(() => { const mr = dd.getBoundingClientRect(); if (mr.bottom > window.innerHeight - 8) dd.style.top = (rect.top - mr.height - 4) + 'px'; if (mr.right > window.innerWidth - 8) { dd.style.left = 'auto'; dd.style.right = '8px'; } }); // Close on tap outside const close = (ev) => { if (!dd.contains(ev.target)) { dd.style.display = 'none'; document.removeEventListener('click', close, true); } }; setTimeout(() => document.addEventListener('click', close, true), 100); } }, 500); }, { passive: true }); div.addEventListener('touchmove', () => { _touchMoved = true; if (_longPressTimer) { clearTimeout(_longPressTimer); _longPressTimer = null; } }, { passive: true }); div.addEventListener('touchend', () => { if (_longPressTimer) { clearTimeout(_longPressTimer); _longPressTimer = null; } }, { passive: true }); div.addEventListener('click', (e) => { if (e.target.closest('.item-drag-handle') || e.target.closest('.session-fav') || e.target.closest('.hamburger') || e.target.closest('.session-dropdown') || e.target.closest('.session-rename-input') || e.target.closest('.session-select-cb')) return; if (_touchMoved || _longPressed) { _touchMoved = false; _longPressed = false; return; } // In select mode, toggle dot instead of navigating if (_selectMode) { const dot = div.querySelector('.session-select-cb'); if (dot) dot.click(); return; } selectSession(s.id); }); // Create a dropdown menu button const menuBtn = document.createElement('button'); menuBtn.innerHTML = ''; menuBtn.title = 'Session actions'; menuBtn.className = 'hamburger session-menu-btn'; // Create dropdown menu const dropdown = document.createElement('div'); dropdown.className = 'dropdown session-dropdown session-dropdown-menu'; // Create menu items const _icon = (svg) => ``; const _renameIcon = ''; const _archiveIcon = ''; const _deleteIcon = ''; const _copyIcon = ''; const renameItem = document.createElement('div'); renameItem.className = 'dropdown-item-compact'; renameItem.innerHTML = _icon(_renameIcon) + 'Rename'; const archiveItem = document.createElement('div'); archiveItem.className = 'dropdown-item-compact'; archiveItem.innerHTML = _icon(_archiveIcon) + 'Archive'; const deleteItem = document.createElement('div'); deleteItem.className = 'dropdown-item-compact dropdown-item-danger'; deleteItem.innerHTML = _icon(_deleteIcon) + 'Delete' + _mod + '+Alt+D'; dropdown.appendChild(renameItem); // Star/Unstar item if (!isOpenClaw) { const _favIcon = s.is_important ? '' : ''; const starItem = document.createElement('div'); starItem.className = 'dropdown-item-compact'; starItem.innerHTML = _icon(_favIcon) + '' + (s.is_important ? 'Unfavorite' : 'Favorite') + '' + _mod + '+Alt+F'; starItem.addEventListener('click', async (e) => { e.stopPropagation(); const newVal = !s.is_important; const fd = new FormData(); fd.append('important', newVal); await fetch(`${API_BASE}/api/session/${s.id}/important`, { method: 'POST', body: fd }); s.is_important = newVal; dropdown.style.display = 'none'; renderSessionList(); }); dropdown.appendChild(starItem); } const copyItem = document.createElement('div'); copyItem.className = 'dropdown-item-compact'; copyItem.innerHTML = _icon(_copyIcon) + 'Copy Chat'; copyItem.addEventListener('click', async (e) => { e.stopPropagation(); dropdown.style.display = 'none'; try { const res = await fetch(`${API_BASE}/api/history/${s.id}`); const data = await res.json(); const msgs = data.history || []; if (!msgs.length) { uiModule.showToast('No messages to copy'); return; } const lines = msgs .filter(m => m.role === 'user' || m.role === 'assistant') .map(m => { const label = m.role === 'user' ? 'You' : 'AI'; const text = typeof m.content === 'string' ? m.content.trim() : JSON.stringify(m.content); return `${label}: ${text}`; }); const text = lines.join('\n\n'); try { await navigator.clipboard.writeText(text); } catch (_clipErr) { // Fallback for non-secure contexts const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;left:-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } uiModule.showToast('Chat copied to clipboard'); } catch (e) { console.error('Copy chat failed:', e); uiModule.showError('Failed to copy chat'); } }); // Rename is already appended above (line 393) // "Select" — enter bulk select mode with this session pre-selected if (!isOpenClaw) { const selectMoreItem = document.createElement('div'); selectMoreItem.className = 'dropdown-item-compact'; selectMoreItem.innerHTML = _icon('●') + 'Select'; selectMoreItem.addEventListener('click', (e) => { e.stopPropagation(); dropdown.style.display = 'none'; _enterSelectMode(); const dot = div.querySelector('.session-select-cb'); if (dot) { dot._checked = true; dot.innerHTML = '●'; dot.style.opacity = '1'; dot.style.color = 'var(--accent, var(--red))'; _selectedIds.add(s.id); _updateBulkCount(); } }); // On mobile, "Select" is the primary multi-pick action — put it at the top // of the menu. On desktop keep its original position. if (window.innerWidth <= 768) { dropdown.insertBefore(selectMoreItem, dropdown.firstChild); } else { dropdown.appendChild(selectMoreItem); } } // Copy & Move to folder const folderItem = buildFolderSubmenu(s.id, s.folder, dropdown); dropdown.appendChild(copyItem); dropdown.appendChild(folderItem); // Separator before destructive actions const _sep = document.createElement('div'); _sep.style.cssText = 'height:1px;margin:3px 0;background:color-mix(in srgb,var(--border) 40%,transparent)'; dropdown.appendChild(_sep); dropdown.appendChild(archiveItem); dropdown.appendChild(deleteItem); // Mobile-only Cancel — explicit close for touch users. CSS hides it on // desktop (outside-click already dismisses cleanly there). const _cancelIcon = ''; const cancelItem = document.createElement('div'); cancelItem.className = 'dropdown-item-compact dropdown-cancel-mobile'; cancelItem.innerHTML = _icon(_cancelIcon) + 'Cancel'; cancelItem.addEventListener('click', (e) => { e.stopPropagation(); dropdown.style.display = 'none'; }); dropdown.appendChild(cancelItem); // Add event listeners menuBtn.addEventListener('click', (e) => { e.stopPropagation(); // Close any other open dropdowns document.querySelectorAll('.dropdown').forEach(d => { if (d !== dropdown) d.style.display = 'none'; }); // Toggle this dropdown if (dropdown.style.display === 'block') { dropdown.style.display = 'none'; } else { // Position the dropdown using viewport coords const rect = menuBtn.getBoundingClientRect(); dropdown.style.left = ''; dropdown.style.right = (window.innerWidth - rect.right) + 'px'; // Show off-screen first to measure height dropdown.style.top = '-9999px'; dropdown.style.display = 'block'; const ddRect = dropdown.getBoundingClientRect(); // Flip above if not enough room below if (rect.bottom + 2 + ddRect.height > window.innerHeight) { dropdown.style.top = Math.max(2, rect.top - ddRect.height - 2) + 'px'; } else { dropdown.style.top = rect.bottom + 2 + 'px'; } } }); renameItem.addEventListener('click', () => { dropdown.style.display = 'none'; _forceSidebarOpen(); // Find the session row's name span and start inline editing const sessionEl = document.querySelector(`.list-item[data-session-id="${s.id}"]`); if (!sessionEl) return; const span = sessionEl.querySelector('.grow'); if (!span || sessionEl.querySelector('.session-rename-input')) return; const input = document.createElement('input'); input.type = 'text'; input.value = s.name || ''; input.className = 'session-rename-input'; span.replaceWith(input); input.focus(); input.select(); const _stopGuard = _guardSidebarDuringRename(); const commit = async () => { const newName = input.value.trim(); if (newName && newName !== s.name) { const fd = new FormData(); fd.append('name', newName); await fetch(`${API_BASE}/api/session/${s.id}`, { method: 'PATCH', body: fd }); s.name = newName; uiModule.showToast('Renamed'); } _forceSidebarOpen(); renderSessionList(); _stopGuard(); }; input.addEventListener('blur', commit); input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); } if (ev.key === 'Escape') { input.removeEventListener('blur', commit); _forceSidebarOpen(); renderSessionList(); _stopGuard(); } }); }); deleteItem.addEventListener('click', async () => { if (s.is_important) { uiModule.showToast('Unfavorite before deleting'); dropdown.style.display = 'none'; return; } dropdown.style.display = 'none'; if (!await uiModule.styledConfirm('Delete this session?', { confirmText: 'Delete', danger: true })) { _forceSidebarOpen(); return; } const wasCurrentSession = currentSessionId === s.id; // If streaming, abort it before deleting if (wasCurrentSession && window.chatModule && window.chatModule.abortCurrentRequest) { window.chatModule.abortCurrentRequest(); } _deselectCurrentSession(s.id); _removeSessionFromLocalState(s.id); _skipAutoSelect = true; // Clean up persistent chat mapping try { const pm = await import('./presets.js'); if (pm.removePersistentChat) pm.removePersistentChat(s.id); } catch (e) {} // On mobile, close sidebar if we deleted the active session so user sees welcome screen if (wasCurrentSession && window.innerWidth <= 768) { const sidebar = document.getElementById('sidebar'); if (sidebar) sidebar.classList.add('hidden'); const backdrop = document.getElementById('sidebar-backdrop'); if (backdrop) backdrop.classList.remove('visible'); } else { _forceSidebarOpen(); } // Await API deletion, then reload the authoritative list from the server try { await fetch(`${API_BASE}/api/session/${s.id}`, { method: 'DELETE' }); } catch (e) { /* network error — session may still exist server-side */ } await loadSessions(); }); archiveItem.addEventListener('click', async () => { dropdown.style.display = 'none'; _forceSidebarOpen(); try { const response = await fetch(`${API_BASE}/api/session/${s.id}/archive`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); if (response.ok) { _forceSidebarOpen(); await loadSessions(); dropdown.style.display = 'none'; uiModule.showToast('Session archived'); } else { throw new Error('Failed to archive session'); } } catch (error) { console.error('Error archiving session:', error); uiModule.showError('Failed to archive session'); } }); // Dropdowns are closed by the shared global listener (_initDropdownDismiss) // Prevent dropdown from closing when clicking inside it dropdown.addEventListener('click', (e) => { e.stopPropagation(); }); div.appendChild(span); // Apply processing/completed state to the star dot var _isProcessing = _researchingSessions.has(s.id) || _streamingSessions.has(s.id); var _isDone = _completedSessions.has(s.id) && !_isProcessing; if (!isOpenClaw) { var _starEl = div.querySelector('.session-star'); if (_starEl) { _starEl.dataset.sessionId = s.id; if (_isProcessing) { _starEl.classList.add('processing'); _starEl.style.opacity = '1'; } else if (_isDone) { _starEl.classList.add('notify'); _starEl.style.opacity = '1'; div.classList.add('stream-complete'); } } } div.appendChild(menuBtn); dropdown.addEventListener('click', (e) => e.stopPropagation()); document.body.appendChild(dropdown); div._sessionDropdown = dropdown; return div; } let _renderRAF = null; export function renderSessionList() { // Debounce rapid re-renders within the same frame if (_renderRAF) cancelAnimationFrame(_renderRAF); _renderRAF = requestAnimationFrame(_renderSessionListImpl); } function _renderSessionListImpl() { _renderRAF = null; const list = uiModule.el('session-list'); if (!list) return; // Get saved order from localStorage const savedOrder = Storage.get('session-order'); let orderedSessions = sessions.filter(s => !s.archived && s.folder !== 'Assistant' && !_isIncognitoSession(s.id) && (s.name || '').trim() !== 'Nobody' && (s.name || '').trim() !== 'Incognito'); if (savedOrder) { try { const orderIds = JSON.parse(savedOrder); const sessionMap = new Map(orderedSessions.map(s => [s.id, s])); const ordered = []; orderIds.forEach(id => { if (sessionMap.has(id)) { ordered.push(sessionMap.get(id)); sessionMap.delete(id); } }); // Append any new sessions not in saved order sessionMap.forEach(s => ordered.push(s)); orderedSessions = ordered; } catch (e) { console.warn('Failed to restore session order:', e); } } // Clean up any previous session dropdowns and folder submenus from body document.querySelectorAll('.session-dropdown, .folder-submenu').forEach(d => d.remove()); const _frag = document.createDocumentFragment(); // ── Flat sort modes: ignore folders, show one ordered list. ── // Folders are only shown when _sortMode === 'group' (or null/empty // for manual mode). This keeps the picker simple: a folder-grouped // view is one of the sort choices, alongside Last Active / Newest. if (_sortMode && _sortMode !== 'group') { orderedSessions.sort((a, b) => { if (_sortMode === 'newest') return (b.created_at || '').localeCompare(a.created_at || ''); // "Last active" sorts by the last actual MESSAGE, not updated_at — // updated_at is bumped by renames / model swaps / folder moves, which // made the order feel random. Fall back to updated_at/created_at for // older rows that predate the last_message_at backfill. if (_sortMode === 'active') { const av = a.last_message_at || a.updated_at || a.created_at || ''; const bv = b.last_message_at || b.updated_at || b.created_at || ''; return bv.localeCompare(av); } return 0; }); // Starred still float to top const starred = orderedSessions.filter(s => s.is_important); const rest = orderedSessions.filter(s => !s.is_important); const allFlat = [...starred, ...rest]; const limit = _showAllSessions ? allFlat.length : SIDEBAR_MAX_VISIBLE; const visible = allFlat.slice(0, limit); const activeIdx = allFlat.findIndex(s => s.id === currentSessionId); if (!_showAllSessions && activeIdx >= limit) visible.push(allFlat[activeIdx]); visible.forEach(s => _frag.appendChild(createSessionItem(s))); if (allFlat.length > SIDEBAR_MAX_VISIBLE) { const remaining = allFlat.length - SIDEBAR_MAX_VISIBLE; const toggleBtn = document.createElement('button'); toggleBtn.className = 'session-show-more-btn'; toggleBtn.textContent = _showAllSessions ? 'Show less' : `Show ${remaining} more`; toggleBtn.addEventListener('click', (e) => { e.stopPropagation(); _showAllSessions = !_showAllSessions; renderSessionList(); }); _frag.appendChild(toggleBtn); } list.innerHTML = ''; list.appendChild(_frag); _postRenderSessionList(list); return; } // ── Group / manual mode: render folders, then unfiled sessions. ── const folderState = loadFolderState(); const folders = {}; // folderName -> [sessions] const unfiled = []; orderedSessions.forEach(s => { if (s.folder) { if (!folders[s.folder]) folders[s.folder] = []; folders[s.folder].push(s); } else { unfiled.push(s); } }); // Move starred sessions to top of each group, preserving relative order const starPartition = (arr) => { const starred = arr.filter(s => s.is_important); const rest = arr.filter(s => !s.is_important); arr.length = 0; arr.push(...starred, ...rest); }; starPartition(unfiled); Object.values(folders).forEach(arr => starPartition(arr)); // Render folders first (above unfiled sessions) const savedFolderOrder = loadFolderOrder(); const allFolderNames = Object.keys(folders); const orderedFolderNames = []; savedFolderOrder.forEach(name => { if (allFolderNames.includes(name)) orderedFolderNames.push(name); }); allFolderNames.forEach(name => { if (!orderedFolderNames.includes(name)) orderedFolderNames.push(name); }); orderedFolderNames.forEach(folderName => { const folderDiv = document.createElement('div'); folderDiv.className = 'session-folder'; folderDiv.dataset.folderName = folderName; const header = document.createElement('div'); header.className = 'session-folder-header'; header.dataset.folderName = folderName; const collapsed = folderState[folderName] === false; // Drag handle for folder reordering const dragHandle = document.createElement('span'); dragHandle.className = 'folder-drag-handle'; dragHandle.textContent = '\u2630'; dragHandle.title = 'Drag to reorder folder'; header.appendChild(dragHandle); const toggle = document.createElement('span'); toggle.className = 'folder-toggle'; toggle.textContent = collapsed ? '\u25B6' : '\u25BC'; header.appendChild(toggle); const nameSpan = document.createElement('span'); nameSpan.className = 'folder-name'; nameSpan.textContent = folderName; header.appendChild(nameSpan); const countSpan = document.createElement('span'); countSpan.className = 'folder-count'; countSpan.textContent = `(${folders[folderName].length})`; header.appendChild(countSpan); // Delete folder button const deleteBtn = document.createElement('button'); deleteBtn.className = 'folder-delete-btn'; deleteBtn.textContent = '\u00d7'; deleteBtn.title = 'Delete folder and all sessions'; deleteBtn.addEventListener('click', async (e) => { e.stopPropagation(); const count = folders[folderName].length; if (!await uiModule.styledConfirm(`Delete folder "${folderName}" and all ${count} session(s) inside it?`, { confirmText: 'Delete', danger: true })) return; for (const s of folders[folderName]) { try { await fetch(`${API_BASE}/api/session/${s.id}`, { method: 'DELETE' }); _deselectCurrentSession(s.id); } catch (err) { console.error('Failed to delete session:', s.id, err); } } await loadSessions(); }); header.appendChild(deleteBtn); let _folderTouchMoved = false; header.addEventListener('touchstart', () => { _folderTouchMoved = false; }, { passive: true }); header.addEventListener('touchmove', () => { _folderTouchMoved = true; }, { passive: true }); header.addEventListener('click', (e) => { e.stopPropagation(); if (e.target.closest('.folder-drag-handle') || e.target.closest('.folder-delete-btn')) return; if (_folderTouchMoved) { _folderTouchMoved = false; return; } const state = loadFolderState(); const isCollapsed = state[folderName] === false; state[folderName] = isCollapsed ? true : false; saveFolderState(state); renderSessionList(); }); // Allow renaming folder via double-click header.addEventListener('dblclick', async (e) => { e.stopPropagation(); if (e.target.closest('.folder-delete-btn')) return; const newName = await styledPrompt('Rename folder:', { title: 'Rename folder', defaultValue: folderName, confirmText: 'Rename', }); if (!newName || !newName.trim() || newName.trim() === folderName) return; const promises = folders[folderName].map(s => moveToFolder(s.id, newName.trim())); Promise.all(promises).then(() => loadSessions()); }); folderDiv.appendChild(header); if (!collapsed) { const content = document.createElement('div'); content.className = 'session-folder-content'; const folderSessions = folders[folderName]; const folderExpanded = _expandedFolders[folderName]; const folderLimit = folderExpanded ? folderSessions.length : FOLDER_MAX_VISIBLE; const visibleFolder = folderSessions.slice(0, folderLimit); // Always include active session even if beyond limit const activeInFolder = folderSessions.findIndex(s => s.id === currentSessionId); if (!folderExpanded && activeInFolder >= folderLimit) { visibleFolder.push(folderSessions[activeInFolder]); } visibleFolder.forEach(s => { content.appendChild(createSessionItem(s)); }); if (folderSessions.length > FOLDER_MAX_VISIBLE) { const rem = folderSessions.length - FOLDER_MAX_VISIBLE; const moreBtn = document.createElement('button'); moreBtn.className = 'session-show-more-btn'; moreBtn.textContent = folderExpanded ? 'Show less' : `Show ${rem} more`; moreBtn.addEventListener('click', (e) => { e.stopPropagation(); _expandedFolders[folderName] = !folderExpanded; renderSessionList(); }); content.appendChild(moreBtn); } folderDiv.appendChild(content); } _frag.appendChild(folderDiv); }); // Render unfiled sessions below folders (capped unless expanded) const hasFolders = orderedFolderNames.length > 0; const activeInUnfiled = unfiled.findIndex(s => s.id === currentSessionId); const limit = _showAllSessions ? unfiled.length : SIDEBAR_MAX_VISIBLE; const visibleUnfiled = unfiled.slice(0, limit); // If active session is beyond the limit, include it if (!_showAllSessions && activeInUnfiled >= limit) { visibleUnfiled.push(unfiled[activeInUnfiled]); } // Wrap in "Unsorted" folder if real folders exist let unfiledTarget = _frag; if (hasFolders && unfiled.length > 0) { const unsortedDiv = document.createElement('div'); unsortedDiv.className = 'session-folder unsorted-folder'; const unsortedHeader = document.createElement('div'); unsortedHeader.className = 'session-folder-header'; const unsortedCollapsed = loadFolderState()['__unsorted__'] === false; const dragHandle = document.createElement('span'); dragHandle.className = 'folder-drag-handle'; dragHandle.textContent = '\u2630'; dragHandle.title = 'Drag to reorder folder'; unsortedHeader.appendChild(dragHandle); const toggle = document.createElement('span'); toggle.className = 'folder-toggle'; toggle.textContent = unsortedCollapsed ? '\u25B6' : '\u25BC'; unsortedHeader.appendChild(toggle); const nameSpan = document.createElement('span'); nameSpan.className = 'folder-name'; nameSpan.textContent = 'Unsorted'; unsortedHeader.appendChild(nameSpan); const countSpan = document.createElement('span'); countSpan.className = 'folder-count'; countSpan.textContent = `(${unfiled.length})`; unsortedHeader.appendChild(countSpan); const deleteBtn = document.createElement('button'); deleteBtn.className = 'folder-delete-btn'; deleteBtn.textContent = '\u00d7'; deleteBtn.title = 'Delete all unsorted sessions'; deleteBtn.addEventListener('click', async (e) => { e.stopPropagation(); if (!await uiModule.styledConfirm(`Delete all ${unfiled.length} unsorted session(s)?`, { confirmText: 'Delete', danger: true })) return; for (const s of unfiled) { try { await fetch(`${API_BASE}/api/session/${s.id}`, { method: 'DELETE' }); _deselectCurrentSession(s.id); } catch (err) { console.error('Failed to delete session:', s.id, err); } } await loadSessions(); }); unsortedHeader.appendChild(deleteBtn); unsortedHeader.addEventListener('click', (e) => { e.stopPropagation(); const state = loadFolderState(); state['__unsorted__'] = state['__unsorted__'] === false ? true : false; saveFolderState(state); renderSessionList(); }); unsortedDiv.appendChild(unsortedHeader); if (!unsortedCollapsed) { const content = document.createElement('div'); content.className = 'session-folder-content'; unfiledTarget = content; unsortedDiv.appendChild(content); } _frag.appendChild(unsortedDiv); if (unsortedCollapsed) { unfiledTarget = null; } } if (unfiledTarget) { visibleUnfiled.forEach(s => { unfiledTarget.appendChild(createSessionItem(s)); }); } // "Show more" / "Show less" toggle if (unfiledTarget && unfiled.length > SIDEBAR_MAX_VISIBLE) { const remaining = unfiled.length - SIDEBAR_MAX_VISIBLE; const toggleBtn = document.createElement('button'); toggleBtn.className = 'session-show-more-btn'; toggleBtn.textContent = _showAllSessions ? 'Show less' : `Show ${remaining} more`; toggleBtn.addEventListener('click', (e) => { e.stopPropagation(); _showAllSessions = !_showAllSessions; renderSessionList(); }); unfiledTarget.appendChild(toggleBtn); } // Flush all built elements into the list in one operation list.innerHTML = ''; list.appendChild(_frag); _postRenderSessionList(list); } /** Shared post-render: highlight, keyboard nav, swipe hint, drag sort */ function _postRenderSessionList(list) { if (currentSessionId) { const activeEl = document.querySelector(`.list-item[data-session-id="${currentSessionId}"]`); if (activeEl) { activeEl.classList.add('active-session'); if (_sessionListFocused) activeEl.focus(); } } _initKeyboardNav(list); _initSwipeToDelete(list); initDragSort(); _showSwipeHint(list); } function _initKeyboardNav(list) { if (!list._kbInit) { list._kbInit = true; list.addEventListener('keydown', _onSessionListKeydown); list.addEventListener('focusin', () => { _sessionListFocused = true; }); list.addEventListener('focusout', (e) => { if (!list.contains(e.relatedTarget)) _sessionListFocused = false; }); } } function _initSwipeToDelete(list) { // handled by existing swipe code — placeholder for consistency } function _showSwipeHint(list) { if ('ontouchstart' in window && !localStorage.getItem('ody-swipe-hint-shown')) { const firstItem = list.querySelector('.session-item'); if (firstItem) { localStorage.setItem('ody-swipe-hint-shown', '1'); const hint = document.createElement('div'); hint.className = 'swipe-hint'; hint.innerHTML = '\u2190 swipe to delete'; firstItem.style.position = 'relative'; firstItem.appendChild(hint); setTimeout(() => { hint.style.opacity = '0'; }, 3000); setTimeout(() => { hint.remove(); }, 3500); } } } // ── Force sidebar open on mobile (after dropdown actions) ── function _forceSidebarOpen() { if (window.innerWidth > 768) return; // Suppress backdrop close if (window._suppressSidebarClose !== undefined) { window._suppressSidebarClose = true; setTimeout(() => { window._suppressSidebarClose = false; }, 2000); } // Force sidebar visible requestAnimationFrame(() => { const sb = document.getElementById('sidebar'); if (sb && sb.classList.contains('hidden')) { sb.classList.remove('hidden'); if (window.syncRailSide) window.syncRailSide(); } }); } // While an inline rename is in progress on mobile, several paths can hide the // sidebar (backdrop tap, soft-keyboard viewport resize, dropdown dismiss). Watch // the sidebar directly and re-open it if anything hides it — bulletproof against // whichever path fires. Returns a stopper to call once the rename is committed. function _guardSidebarDuringRename() { if (window.innerWidth > 768 || !window.MutationObserver) return () => {}; const sb = document.getElementById('sidebar'); if (!sb) return () => {}; const obs = new MutationObserver(() => { if (sb.classList.contains('hidden')) { sb.classList.remove('hidden'); const bd = document.getElementById('sidebar-backdrop'); if (bd) bd.classList.add('visible'); } }); obs.observe(sb, { attributes: true, attributeFilter: ['class'] }); // Keep guarding briefly after the caller stops, to catch the keyboard-dismiss // resize that fires just after blur/commit. return () => setTimeout(() => obs.disconnect(), 400); } // ── Bulk select mode ── let _selectMode = false; let _selectedIds = new Set(); function _enterSelectMode() { _selectMode = true; _selectedIds.clear(); const bulkBar = document.getElementById('session-bulk-bar'); if (bulkBar) bulkBar.classList.remove('hidden'); const selectBtn = document.getElementById('session-select-btn'); if (selectBtn) selectBtn.style.opacity = '1'; // Add select dots to all session items document.querySelectorAll('.list-item[data-session-id]').forEach(item => { if (item.querySelector('.session-select-cb')) return; const dot = document.createElement('span'); dot.className = 'session-select-cb'; dot.innerHTML = '○'; dot.style.cssText = 'cursor:pointer;font-size:16px;flex-shrink:0;opacity:0.4;transition:opacity 0.1s;user-select:none;'; dot._checked = false; dot.addEventListener('click', (e) => { e.stopPropagation(); dot._checked = !dot._checked; dot.innerHTML = dot._checked ? '●' : '○'; dot.style.opacity = dot._checked ? '1' : '0.4'; dot.style.color = dot._checked ? 'var(--accent, var(--red))' : ''; const sid = item.dataset.sessionId; if (dot._checked) _selectedIds.add(sid); else _selectedIds.delete(sid); _updateBulkCount(); }); item.insertBefore(dot, item.firstChild); }); _updateBulkCount(); } function _exitSelectMode() { _selectMode = false; _selectedIds.clear(); const bulkBar = document.getElementById('session-bulk-bar'); if (bulkBar) bulkBar.classList.add('hidden'); const selectBtn = document.getElementById('session-select-btn'); if (selectBtn) selectBtn.style.opacity = '0.5'; const selectAll = document.getElementById('session-select-all'); if (selectAll) selectAll.checked = false; // Remove checkboxes document.querySelectorAll('.session-select-cb').forEach(cb => cb.remove()); } function _updateBulkCount() { const count = _selectedIds.size; const archiveBtn = document.getElementById('session-bulk-archive'); const deleteBtn = document.getElementById('session-bulk-delete'); if (archiveBtn) { archiveBtn.disabled = count === 0; archiveBtn.style.opacity = count === 0 ? '0.2' : ''; } if (deleteBtn) { deleteBtn.disabled = count === 0; deleteBtn.style.opacity = count === 0 ? '0.2' : ''; } } function _initBulkSelect() { const selectBtn = document.getElementById('session-select-btn'); if (selectBtn) { selectBtn.addEventListener('click', (e) => { e.stopPropagation(); if (_selectMode) _exitSelectMode(); else _enterSelectMode(); }); } const cancelBtn = document.getElementById('session-bulk-cancel'); if (cancelBtn) cancelBtn.addEventListener('click', () => _exitSelectMode()); // Select from funnel dropdown const selectFromDropdown = document.getElementById('session-select-from-dropdown'); if (selectFromDropdown) { selectFromDropdown.addEventListener('click', () => { const dd = document.getElementById('session-sort-dropdown'); if (dd) dd.style.display = 'none'; _enterSelectMode(); }); } // Escape exits select mode document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && _selectMode) { _exitSelectMode(); } }); const selectAll = document.getElementById('session-select-all'); const selectAllDot = document.getElementById('session-select-all-dot'); const selectAllLabel = document.getElementById('session-select-all-label'); if (selectAll && selectAllDot) { const _toggleAll = () => { selectAll.checked = !selectAll.checked; selectAllDot.innerHTML = selectAll.checked ? '●' : '○'; selectAllDot.style.opacity = selectAll.checked ? '1' : '0.4'; selectAllDot.style.color = selectAll.checked ? 'var(--accent, var(--red))' : ''; document.querySelectorAll('.session-select-cb').forEach(dot => { dot._checked = selectAll.checked; dot.innerHTML = selectAll.checked ? '●' : '○'; dot.style.opacity = selectAll.checked ? '1' : '0.4'; dot.style.color = selectAll.checked ? 'var(--accent, var(--red))' : ''; const sid = dot.closest('[data-session-id]')?.dataset.sessionId; if (sid) { if (selectAll.checked) _selectedIds.add(sid); else _selectedIds.delete(sid); } }); _updateBulkCount(); }; selectAllDot.addEventListener('click', _toggleAll); if (selectAllLabel) selectAllLabel.addEventListener('click', _toggleAll); } const archiveBtn = document.getElementById('session-bulk-archive'); if (archiveBtn) { archiveBtn.addEventListener('click', async () => { if (_selectedIds.size === 0) return; const count = _selectedIds.size; if (!await uiModule.styledConfirm(`Archive ${count} session(s)?`, { confirmText: 'Archive' })) return; for (const sid of _selectedIds) { try { await fetch(`${API_BASE}/api/session/${sid}/archive`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); } catch (_) {} } _exitSelectMode(); if (window._suppressSidebarClose !== undefined) { window._suppressSidebarClose = true; setTimeout(() => { window._suppressSidebarClose = false; }, 1500); } await loadSessions(); uiModule.showToast(`${count} session(s) archived`); }); } const deleteBtn = document.getElementById('session-bulk-delete'); if (deleteBtn) { deleteBtn.addEventListener('click', async () => { if (_selectedIds.size === 0) return; const count = _selectedIds.size; if (!await uiModule.styledConfirm(`Delete ${count} session(s)? This cannot be undone.`, { confirmText: 'Delete', danger: true })) return; const deletedIds = []; for (const sid of _selectedIds) { try { const res = await fetch(`${API_BASE}/api/session/${sid}`, { method: 'DELETE' }); if (res.ok) deletedIds.push(sid); } catch (_) {} } await _animateSessionRowsRemoving(deletedIds, '#session-list .list-item[data-session-id]'); _exitSelectMode(); if (window._suppressSidebarClose !== undefined) { window._suppressSidebarClose = true; setTimeout(() => { window._suppressSidebarClose = false; }, 1500); } await loadSessions(); uiModule.showToast(`${deletedIds.length} session(s) deleted`); }); } } function _animateSessionRowsRemoving(ids, selector) { const idSet = new Set((ids || []).map(id => String(id))); if (!idSet.size) return Promise.resolve(); const rows = Array.from(document.querySelectorAll(selector || '.list-item[data-session-id]')) .filter(row => idSet.has(String(row.dataset.sessionId || row.dataset.sid))); if (!rows.length) return Promise.resolve(); for (const row of rows) { row.style.maxHeight = `${Math.max(row.getBoundingClientRect().height, row.scrollHeight)}px`; row.classList.add('memory-tidy-removing'); } return new Promise(resolve => setTimeout(resolve, 520)); } export async function loadSessions() { try { // Delete incognito sessions left over from a previous page load await _cleanupIncognitoSessions(); // Use prefetched data from login page if available (first load only) const prefetched = sessionStorage.getItem('ody-prefetch-sessions'); let fetched; if (prefetched) { sessionStorage.removeItem('ody-prefetch-sessions'); fetched = JSON.parse(prefetched); } else { const res = await fetch(`${API_BASE}/api/sessions`); fetched = await res.json(); } sessions = _normalizeSessionsList(fetched); renderSessionList(); const sessionsSection = uiModule.el('sessions-section'); if (sessions.length === 0) { sessionsSection.classList.add('hidden'); } else { sessionsSection.classList.remove('hidden'); } const activeSessions = sessions.filter(s => !s.archived); // "Transient" sessions = the singleton Assistant chat + any task-output // session. Treat them as not-restorable so coming back to the app lands // on the user's last actual conversation, not whichever check-in task // most recently appended a message. const _isTransient = (s) => !!s && (s.folder === 'Assistant' || s.folder === 'Tasks'); const _realSessions = activeSessions.filter(s => !_isTransient(s)); const hashId = window.location.hash.replace('#', ''); let savedId = Storage.get('lastSessionId'); // If the persisted lastSessionId points to a transient session (legacy // state from before the persistence-guard was added), drop it. if (savedId) { const _saved = activeSessions.find(s => s.id === savedId); if (_saved && _isTransient(_saved)) { Storage.remove('lastSessionId'); savedId = null; } } const hasPendingChat = !!_pendingChat; let targetId = null; if (hasPendingChat) { // A model was picked and the UI is showing a fresh New Chat, but the // session is not created until the first message. Background stream // completions call loadSessions() later; without this guard that reload // sees no current session and auto-selects the previous chat. targetId = null; } else if (hashId && activeSessions.some(s => s.id === hashId)) { targetId = hashId; } else if (currentSessionId && activeSessions.some(s => s.id === currentSessionId)) { targetId = currentSessionId; } else if (currentSessionId) { // Session was just created but may not be in the list yet — keep it targetId = currentSessionId; } else if (savedId && activeSessions.some(s => s.id === savedId)) { targetId = savedId; } else if (!_skipAutoSelect && _realSessions.length > 0) { // Most-recent NON-transient session — skip Assistant / Tasks so the // auto-firing assistant doesn't become the apparent default chat. targetId = _realSessions[0].id; } else if (!_skipAutoSelect && activeSessions.length > 0) { // Only transient sessions exist (brand-new account) — fall through to // the original behaviour so we don't leave the user with nothing. targetId = activeSessions[0].id; } _skipAutoSelect = false; // Fresh login: prefer a default-model session so a brand-new user lands // ready to chat. CRITICAL: only do this when there's NO session to return // to (no hash / lastSessionId / existing chat resolved into targetId). // Otherwise a fresh page load — which a server restart triggers — would // spin up a new empty default-model chat and shadow the user's last // conversation, making it look like the chat "lost its context" (and the // picker would still show the old model's name from cached state). See // the targetId resolution above (hash → currentSession → lastSessionId → // most-recent). const _isFirstLoad = !sessionStorage.getItem('ody-session-active'); if (_isFirstLoad) { sessionStorage.setItem('ody-session-active', '1'); if (!targetId) { try { const dcRes = await fetch(`${API_BASE}/api/default-chat`); const dc = await dcRes.json(); if (dc.endpoint_url && dc.model) { // Check if there's already an empty session with this model we can reuse const emptyDefault = activeSessions.find(s => s.model === dc.model && s.message_count === 0 ); if (emptyDefault) { targetId = emptyDefault.id; } else { await createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id); // On mobile, hide sidebar so user lands directly in chat if (window.innerWidth < 768) { const sb = document.getElementById('sidebar'); if (sb) sb.classList.add('hidden'); } return; // createDirectChat handles selectSession internally } } } catch (_) { /* no default model configured */ } } } if (targetId && targetId !== currentSessionId) { await selectSession(targetId, { keepSidebar: true }); } else if (targetId && targetId === currentSessionId) { // Same session — just refresh the header name in case it was auto-generated const s = sessions.find(x => x.id === targetId); const metaEl = document.getElementById('current-meta'); if (metaEl && s) metaEl.textContent = s.name; } // No session selected — still enable input so slash commands (e.g. /setup) work if (!targetId && !hasPendingChat) { const msgInput = document.getElementById('message'); if (msgInput) { msgInput.disabled = false; if (window.innerWidth > 768) msgInput.focus(); } if (window.chatModule && window.chatModule.showWelcomeScreen) { window.chatModule.showWelcomeScreen(); } updateModelPicker(); // Only auto-create if there are truly zero sessions (not just unselected) if (activeSessions.length === 0 && !_autoCreateInProgress) { _autoCreateInProgress = true; try { const dcRes = await fetch(`${API_BASE}/api/default-chat`); const dc = await dcRes.json(); if (dc.endpoint_url && dc.model) { await createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id); } } catch (_) { /* no default model — that's fine, user can /setup */ } _autoCreateInProgress = false; } } } catch (error) { console.error('Error in loadSessions:', error); uiModule.showError('Failed to load sessions: ' + error.message); } } export async function selectSession(id, { keepSidebar = false } = {}) { // Exit compare mode cleanly if active if (window.compareModule && window.compareModule.isActive()) { window.compareModule.deactivate(true); return; // deactivate does a page reload } try { const navToken = ++_sessionNavToken; const prevSessionId = currentSessionId; // Re-archive peeked session when navigating away _checkPeekCleanup(id); // Clear any leftover document text selection so it doesn't bleed into the new chat if (prevSessionId !== id && window.documentModule?.clearSelection) { try { window.documentModule.clearSelection(); } catch {} } currentSessionId = id; // Identify Assistant / task-output sessions so we don't "trap" the user // there on return. Skipped from both `lastSessionId` persistence and the // URL hash — the user complained that coming back to Odysseus kept // landing them on the auto-firing task-log chat instead of their last // real conversation. const _meta = sessions.find(s => s.id === id); const _isTransientChat = !!_meta && (_meta.folder === 'Assistant' || _meta.folder === 'Tasks'); if (!_isTransientChat) { Storage.set('lastSessionId', id); // Update URL hash without triggering hashchange handler if (window.location.hash !== '#' + id) { history.replaceState(null, '', '#' + id); } } // Restore character preset for persistent chats try { const presetsModule = window.presetsModule || (await import('./presets.js')).default; if (presetsModule && presetsModule.onSessionSwitch) presetsModule.onSessionSwitch(id); } catch (e) {} const meta = sessions.find(s => s.id === id); // Detach any in-flight stream to background instead of aborting try { if (window.chatModule) { if (window.chatModule.detachCurrentStream) { window.chatModule.detachCurrentStream(prevSessionId); } else if (window.chatModule.abortCurrentRequest) { window.chatModule.abortCurrentRequest(); } } } catch (e) { console.warn('detachCurrentStream error:', e); if (window.chatModule && window.chatModule.abortCurrentRequest) { window.chatModule.abortCurrentRequest(); } } // Reset send button to idle state if (window._updateSendBtnIcon) window._updateSendBtnIcon(); const sendBtn = document.querySelector('.send-btn'); if (sendBtn && sendBtn.dataset.mode === 'streaming') { sendBtn.dataset.mode = ''; sendBtn.innerHTML = ''; sendBtn.title = 'Send message'; } // Deactivate compare mode on session switch if (window.compareModule) { if (window.compareModule.isActive()) window.compareModule.deactivate(true); else if (window.compareModule.hasVisibleResults()) window.compareModule.cleanupResults(); } const msgInput = document.getElementById('message'); if (msgInput) { msgInput.disabled = false; msgInput.value = ''; } const sendBtn2 = document.querySelector('.send-btn'); if (sendBtn2) { sendBtn2.style.color = ''; if (window._updateSendBtnIcon) window._updateSendBtnIcon(); } // On mobile, keep sidebar open — user dismisses it by tapping chat area or swiping // Highlight active session in sidebar document.querySelectorAll('.list-item.active-session').forEach(el => el.classList.remove('active-session')); const activeEl = document.querySelector(`.list-item[data-session-id="${id}"]`); if (activeEl) activeEl.classList.add('active-session'); const currentMetaEl = uiModule.el('current-meta'); if (currentMetaEl) { currentMetaEl.textContent = meta ? meta.name : 'Odysseus Chat'; } // Update model picker visibility updateModelPicker(); // Refresh session cost badge for the newly selected session if (chatRenderer.updateSessionCostUI) chatRenderer.updateSessionCostUI(); const chatHistory = uiModule.el('chat-history'); // Prefetch history before fading so we can swap instantly. `isOC` // is the OpenClaw special-session sentinel — used by the wouldWipe // guard below and the welcome-screen branch further down. (Its // declaration had been removed while leaving the references in // place, producing a ReferenceError every selectSession.) const isOC = meta && (meta.is_openclaw || id === 'openclaw'); let msgHistory = [], modelName = null; if (!isOC) { const res = await fetch(`${API_BASE}/api/history/${id}`); const data = await res.json(); if (navToken !== _sessionNavToken || currentSessionId !== id) return; msgHistory = data.history || []; modelName = data.model || null; // The model returned by /api/history is the authoritative one the // backend will use for this session. Write it back into the cached // session meta and refresh the picker so the displayed model can // never diverge from what's actually sent (the "picker says Minimax // but it used the default" bug after a restart / stale cache). if (modelName) { const sMeta = sessions.find(s => s.id === id); if (sMeta && sMeta.model !== modelName) { sMeta.model = modelName; updateModelPicker(); } } } // Guard: if the fetched history is empty but the DOM already has message // bubbles for the same session (incognito doesn't persist, so /api/history // returns []), preserve the DOM instead of wiping it. This fixes the // "reply flashes for 0.1s then empty" bug when selectSession is called // after a streaming completion in an incognito chat. const isSameSession = (prevSessionId === id); const hasExistingBubbles = chatHistory && chatHistory.querySelectorAll('.msg').length > 0; const wouldWipe = !isOC && !msgHistory.length && isSameSession && hasExistingBubbles; if (wouldWipe) { // Skip the fade/reload; we're already showing the right content. if (chatHistory) chatHistory.classList.remove('no-animate'); return; } // Fade out old content, swap, fade in if (chatHistory) { chatHistory.style.transition = 'opacity 0.12s ease-out'; chatHistory.style.opacity = '0'; await new Promise(r => setTimeout(r, 120)); if (navToken !== _sessionNavToken || currentSessionId !== id) return; chatHistory.innerHTML = ''; } // Suppress per-message entrance animations during bulk history render if (chatHistory) chatHistory.classList.add('no-animate'); // Populate new content while invisible if (isOC) { if (window.chatModule && window.chatModule.showWelcomeScreen) window.chatModule.showWelcomeScreen(); window.chatModule.addMessage('assistant', `
\uD83E\uDD9E OpenClaw Agent Connected
Messages will be routed through your OpenClaw agent. The agent has access to tools, memory, and skills configured in your OpenClaw workspace.
`, 'OpenClaw'); } else if (msgHistory.length) { for (const msg of msgHistory) { const meta = msg.metadata ? { ...msg.metadata, _fromHistory: true } : null; let displayContent; if (typeof msg.content === 'string') { displayContent = msg.content; } else if (Array.isArray(msg.content)) { // Multimodal (image/audio attachments): extract text parts, skip binary displayContent = msg.content.filter(p => p.type === 'text').map(p => p.text).join('\n').trim(); } else { displayContent = ''; } // Clean up doc selection context for display if (msg.role === 'user') { // Hide "Continue where you left off" bubbles if (displayContent.trim() === 'Continue where you left off' || displayContent.trim().startsWith('Your message was cut off.') || displayContent.trim().startsWith('Your previous response was interrupted.') || displayContent.includes('[Instruction: Rewrite') || displayContent.includes('[Instruction: Explain')) continue; const docEditMatch = displayContent.match(/^In the document, edit this specific text \((lines? [\d-]+)\):\n```\n([\s\S]*?)\n```\n\nInstruction: ([\s\S]*)$/); if (docEditMatch) { displayContent = `[Doc edit: ${docEditMatch[1]}] ${docEditMatch[3]}`; } } window.chatModule.addMessage(msg.role, markdownModule.renderContent(displayContent), modelName, meta); } } else { if (window.chatModule && window.chatModule.showWelcomeScreen) window.chatModule.showWelcomeScreen(); // Don't highlight empty sessions — feels like nothing is selected document.querySelectorAll('.list-item.active-session').forEach(el => el.classList.remove('active-session')); } uiModule.scrollHistoryInstant(); // Fade in and re-enable message animations if (chatHistory) { chatHistory.style.transition = 'opacity 0.15s ease-in'; chatHistory.style.opacity = '1'; chatHistory.classList.remove('no-animate'); } if (window.hljs) { document.querySelectorAll('pre code:not(.hljs)').forEach(block => { window.hljs.highlightElement(block); }); } // Hide research button on session switch — it's only for the session that started it var _rBtn = document.getElementById('research-toggle-btn'); var _rChk = document.getElementById('research-toggle'); if (_rBtn) _rBtn.style.display = 'none'; if (_rChk) _rChk.checked = false; // Check for pending/completed research that survived a page refresh if (window.chatModule && window.chatModule.checkPendingResearch) { window.chatModule.checkPendingResearch(id); } // Restore group chat state if this is a group session if (window.groupModule && window.groupModule.restoreState && window.groupModule.restoreState(id)) { if (window._syncGroupIndicator) window._syncGroupIndicator(true); // Hide model picker for group sessions const _mpw = document.getElementById('model-picker-wrap'); if (_mpw) _mpw.style.display = 'none'; } else if (window.groupModule && window.groupModule.isActive()) { // Switching away from group session — deactivate window.groupModule.stopGroup(); if (window._syncGroupIndicator) window._syncGroupIndicator(false); } // Stop pulsing notification — user is now viewing this session clearStreamComplete(id); // Re-attach any background stream try { if (window.chatModule && window.chatModule.checkBackgroundStream) { window.chatModule.checkBackgroundStream(id); } } catch (e) { console.warn('checkBackgroundStream error:', e); } // Check server for active stream (survives page refresh) _checkServerStream(id); // Document panel: keep open if next session also wants it, otherwise close if (window.documentModule) { const docBtn = document.getElementById('overflow-doc-btn'); const meta = sessions.find(s => s.id === id); const shouldOpen = localStorage.getItem('odysseus-doc-open-' + id) === '1'; const hasDocs = !!(meta && meta.has_documents); if (docBtn) { docBtn.classList.remove('active'); docBtn.classList.toggle('has-docs', hasDocs); } const docInd = document.getElementById('doc-indicator-btn'); if (docInd) docInd.classList.toggle('visible', hasDocs); if (hasDocs) { // Wait for session UI to settle, then slide in documents setTimeout(() => window.documentModule.loadSessionDocs(id, { restoreMode: true }), 300); } else if (!shouldOpen) { window.documentModule.closePanel(); } } } catch (error) { console.error('Error in selectSession:', error); uiModule.showError('Failed to load session: ' + error.message); } finally { // Ensure memories are loaded after session selection if (window.memoryModule && window.memoryModule.loadMemories) { await window.memoryModule.loadMemories(); } // Auto-focus message input (unless session list has keyboard focus). // Skip on mobile — focusing the textarea pops up the on-screen keyboard, // which is intrusive when the user is just navigating between chats // (e.g. picking a chat from the Library). They can tap the input to // bring up the keyboard when they actually want to type. if (!_sessionListFocused && window.innerWidth > 768) { const msgInput = document.getElementById('message'); if (msgInput) msgInput.focus(); } } } // Pending session — stored locally until the first message is sent let _pendingChat = null; // { url, modelId, endpointId } export function createDirectChat(url, modelId, endpointId) { _sessionNavToken++; // Detach any active stream so it doesn't interfere with the new chat if (window.chatModule && window.chatModule.detachCurrentStream) { window.chatModule.detachCurrentStream(currentSessionId); } // Stop an active GROUP chat too — otherwise its in-flight parallel/round-robin // streams keep rendering into the brand-new chat (abort the group's fetches). if (window.groupModule && window.groupModule.isActive && window.groupModule.isActive()) { try { window.groupModule.stopGroup(); } catch {} if (window._syncGroupIndicator) window._syncGroupIndicator(false); } // Don't hit the API — just store the model info and prepare the UI _pendingChat = { url, modelId, endpointId }; _skipAutoSelect = true; currentSessionId = null; Storage.remove('lastSessionId'); history.replaceState(null, '', window.location.pathname); document.querySelectorAll('.list-item.active-session, .session-item.active').forEach(el => { el.classList.remove('active-session', 'active'); }); // Close document panel — new chat has no docs if (window.documentModule && window.documentModule.isPanelOpen()) { window.documentModule.closePanel(); } const docBtn = document.getElementById('overflow-doc-btn'); if (docBtn) { docBtn.classList.remove('active', 'has-docs'); docBtn.style.display = ''; // show in overflow menu again } const docInd = document.getElementById('doc-indicator-btn'); if (docInd) docInd.classList.remove('visible', 'active'); // Clear chat area and show welcome const box = document.getElementById('chat-history'); if (box) box.innerHTML = ''; if (window.chatModule && window.chatModule.showWelcomeScreen) { window.chatModule.showWelcomeScreen(); } // Update model picker to show the pending model updateModelPicker(); // Update current-meta header const metaEl = document.getElementById('current-meta'); if (metaEl) { metaEl.textContent = 'New Chat'; } // Enable input const msgInput = document.getElementById('message'); if (msgInput) { msgInput.disabled = false; msgInput.value = ''; msgInput.focus(); } } /** Actually create the session in the DB. Called on first message send. */ export async function materializePendingSession() { const pending = _pendingChat; if (!pending) return false; _pendingChat = null; const incognitoChk = document.getElementById('incognito-toggle'); const isIncognito = incognitoChk && incognitoChk.checked; const base = (pending.modelId || 'model').split('/').pop(); const name = isIncognito ? 'Nobody' : `${base} ${new Date().toLocaleTimeString()}`; const fd = new FormData(); fd.append('name', name); fd.append('endpoint_url', pending.url || ''); fd.append('model', pending.modelId || ''); if (pending.url && pending.modelId) { fd.append('skip_validation', 'true'); } if (pending.endpointId) { fd.append('endpoint_id', pending.endpointId); } let res; try { res = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: fd }); } catch (e) { uiModule.showError('Failed to reach backend: ' + e); return false; } let payload; try { payload = await res.json(); } catch { payload = { detail: await res.text() }; } if (!res.ok) { uiModule.showError(`Session create failed (${res.status}) ${payload.detail || JSON.stringify(payload)}`); return false; } if (isIncognito && payload.id) { _markIncognito(payload.id); } // Clear any leftover document text selection from the previous session if (window.documentModule?.clearSelection) { try { window.documentModule.clearSelection(); } catch {} } currentSessionId = payload.id; Storage.set('lastSessionId', payload.id); history.replaceState(null, '', '#' + payload.id); // Reload sidebar to show the new session — await it so the session // is fully registered before the caller proceeds (prevents race conditions) await loadSessions().catch(() => {}); return true; } export function hasPendingChat() { return !!_pendingChat; } export function getPendingChat() { return _pendingChat; } // Getters for external access export function getCurrentSessionId() { return currentSessionId; } export function getSessions() { return sessions; } export function getCurrentModel() { const sess = sessions.find(x => x.id === currentSessionId); if (sess && sess.model) return sess.model; // Pending session not yet materialized — read from model picker label const label = document.getElementById('model-picker-label'); return label ? label.textContent.trim() : null; } /** Endpoint URL serving the current (or pending) session's model. Used to * decide whether a model is local (free) vs a billable cloud provider. */ export function getCurrentEndpointUrl() { const sess = sessions.find(x => x.id === currentSessionId); if (sess && sess.endpoint_url) return sess.endpoint_url; if (_pendingChat && _pendingChat.url) return _pendingChat.url; return null; } export function setCurrentSessionId(id) { _sessionNavToken++; currentSessionId = id; if (!id) { Storage.remove('lastSessionId'); history.replaceState(null, '', window.location.pathname); document.querySelectorAll('.list-item.active-session, .session-item.active').forEach(el => { el.classList.remove('active-session', 'active'); }); } } // Session list keyboard navigation: arrows to move, Delete to delete async function _onSessionListKeydown(e) { const item = e.target.closest('.list-item[data-session-id]'); if (!item) return; if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); // Get all visible session items across all containers const allItems = Array.from(document.querySelectorAll('#session-list .list-item[data-session-id]')); const idx = allItems.indexOf(item); if (idx < 0) return; const next = e.key === 'ArrowDown' ? allItems[idx + 1] : allItems[idx - 1]; if (next) { next.focus(); const sid = next.dataset.sessionId; if (sid) selectSession(sid); } return; } if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); const sid = item.dataset.sessionId; const s = sessions.find(x => x.id === sid); if (!s) return; if (s.is_important) { uiModule.showToast('Unfavorite before deleting'); return; } const ok = await uiModule.styledConfirm('Delete this session?', { confirmText: 'Delete', danger: true }); if (!ok) return; _sessionListFocused = true; (async () => { await fetch(`${API_BASE}/api/session/${s.id}`, { method: 'DELETE' }); _deselectCurrentSession(s.id); await loadSessions(); })(); return; } if (e.key === 'Enter') { e.preventDefault(); const sid = item.dataset.sessionId; if (sid) selectSession(sid); return; } } // Initialize drag sorting for sessions — uses the same dragSortModule as models export function initDragSort() { if (!window.dragSortModule) return; const list = uiModule.el('session-list'); if (!list) return; // Unfiled sessions (exclude items nested inside folders) window.dragSortModule.enable('session-list', '.list-item', { instanceKey: 'session-items', handleSelector: '.item-drag-handle', excludeSelector: '.session-folder-content .list-item', storageKey: 'session-order', }); // Folder reordering window.dragSortModule.enable('session-list', '.session-folder', { instanceKey: 'session-folders', handleSelector: '.folder-drag-handle', onReorder: (items) => { const order = items.map(f => f.dataset.folderName).filter(Boolean); saveFolderOrder(order); }, }); // Sessions within each folder list.querySelectorAll('.session-folder-content').forEach((content, i) => { const id = 'session-folder-content-' + i; content.id = id; window.dragSortModule.enable(id, '.list-item', { handleSelector: '.item-drag-handle', }); }); } // Hash-based routing: navigate between sessions with browser back/forward window.addEventListener('hashchange', () => { const hashId = window.location.hash.replace('#', ''); if (hashId && hashId !== currentSessionId) { const target = sessions.find(s => s.id === hashId && !s.archived); if (target) selectSession(hashId); } }); // ── Research indicator management ── function _updateResearchDots() { document.querySelectorAll('.session-star[data-session-id]').forEach(function(star) { var sid = star.dataset.sessionId; var isRunning = _researchingSessions.has(sid) || _streamingSessions.has(sid); var isCompleted = _completedSessions.has(sid) && !isRunning; var listItem = star.closest('.list-item'); star.classList.toggle('processing', isRunning); star.classList.toggle('notify', isCompleted); if (listItem) listItem.classList.toggle('stream-complete', isCompleted); if (isRunning || isCompleted) { star.style.opacity = '1'; } else { star.style.opacity = ''; } }); } function _startResearchPolling() { if (_researchPollTimer) return; _researchPollTimer = setInterval(async function() { if (_researchingSessions.size === 0) { clearInterval(_researchPollTimer); _researchPollTimer = null; return; } for (var sid of _researchingSessions) { try { var res = await fetch(`${API_BASE}/api/research/status/${sid}`); if (!res.ok) { _researchingSessions.delete(sid); continue; } var data = await res.json(); if (data.status !== 'running') { _researchingSessions.delete(sid); } } catch (e) { _researchingSessions.delete(sid); } } _updateResearchDots(); if (_researchingSessions.size === 0 && _researchPollTimer) { clearInterval(_researchPollTimer); _researchPollTimer = null; } }, 5000); } export function markResearching(sessionId) { _researchingSessions.add(sessionId); _updateResearchDots(); _updateRailNotifs(); _startResearchPolling(); } export function clearResearching(sessionId) { _researchingSessions.delete(sessionId); _updateResearchDots(); _updateRailNotifs(); } export function markStreaming(sessionId) { _streamingSessions.add(sessionId); _updateResearchDots(); _updateRailNotifs(); } export function clearStreaming(sessionId) { _streamingSessions.delete(sessionId); _updateResearchDots(); _updateRailNotifs(); } export function markStreamComplete(sessionId) { _researchingSessions.delete(sessionId); _streamingSessions.delete(sessionId); // Don't pulse if user is already viewing this session — they can see the response if (currentSessionId === sessionId) { _updateResearchDots(); _updateRailNotifs(); return; } _completedSessions.add(sessionId); _updateResearchDots(); _updateRailNotifs(); // Show notification dot on Chats section if collapsed const sessSection = document.getElementById('sessions-section'); if (sessSection && sessSection.classList.contains('collapsed')) { const dot = document.getElementById('chats-notif-dot'); if (dot) dot.style.display = 'inline-block'; } // Safety net: re-apply after a tick in case a concurrent renderSessionList overwrites the DOM setTimeout(function() { if (_completedSessions.has(sessionId)) { _updateResearchDots(); } }, 300); } // ── Rail notification dots ── // Keep rail buttons lit when background work is happening / finished function _updateRailNotifs() { // Research rail — pulsing while any session is researching const railResearch = document.getElementById('rail-research'); if (railResearch) { // OR in the Deep Research panel's job state (set by panel.js) // so inline-research and panel-research both keep the rail lit. const researching = _researchingSessions.size > 0 || !!window._researchJobsActive; railResearch.classList.toggle('rail-notify', researching); } // Chats rail — show when a background stream completed const railChats = document.getElementById('rail-chats'); if (railChats) { const sidebar = document.getElementById('sidebar'); const sidebarHidden = sidebar && sidebar.classList.contains('hidden'); const hasCompleted = _completedSessions.size > 0; railChats.classList.toggle('rail-notify', hasCompleted && sidebarHidden); railChats.classList.toggle('rail-notify-success', hasCompleted && sidebarHidden); // Store first completed session for click-to-open if (hasCompleted) { railChats.dataset.targetSession = [..._completedSessions][0]; } else { delete railChats.dataset.targetSession; } } // Trigger rail sync so buttons become visible if (window._syncRailDynamic) window._syncRailDynamic(); } /** * Check server for an active stream (survives page refresh). * If the server is still streaming for this session, show a spinner * and poll until done, then reload the session. */ async function _checkServerStream(sessionId) { try { // Skip if research is running — it has its own progress UI if (_researchingSessions.has(sessionId)) return; // Skip if the SSE reader is still actively connected — it handles rendering if (window.chatModule && window.chatModule.hasActiveStream && window.chatModule.hasActiveStream(sessionId)) return; const res = await fetch(`${API_BASE}/api/chat/stream_status/${sessionId}`); if (!res.ok) return; // 404 = no active stream const info = await res.json(); if (info.status !== 'streaming') return; // Skip if this is a research stream — research has its own progress UI if (info.mode === 'research' || info.is_research) return; // Server is still streaming — show spinner and poll const box = document.getElementById('chat-history'); if (!box) return; const holder = document.createElement('div'); holder.className = 'msg msg-ai'; holder.innerHTML = ''; const bodyDiv = holder.querySelector('.body'); const spinnerMod = await import('./spinner.js'); const spinner = spinnerMod.default.create('Generating response...', 'right'); bodyDiv.appendChild(spinner.createElement()); spinner.start(); box.appendChild(holder); uiModule.scrollHistory(); const pollId = setInterval(async () => { if (getCurrentSessionId() !== sessionId) { clearInterval(pollId); spinner.destroy(); if (holder.parentNode) holder.remove(); return; } try { const r = await fetch(`${API_BASE}/api/chat/stream_status/${sessionId}`); if (!r.ok || (await r.json()).status !== 'streaming') { clearInterval(pollId); spinner.destroy(); if (holder.parentNode) holder.remove(); // Reload session to show the completed response + docs selectSession(sessionId); } } catch (_) { clearInterval(pollId); spinner.destroy(); if (holder.parentNode) holder.remove(); selectSession(sessionId); } }, 1500); } catch (_) { // No stream active — nothing to do } } export function clearStreamComplete(sessionId) { _completedSessions.delete(sessionId); // Direct DOM cleanup in case _updateResearchDots misses it var item = document.querySelector(`.list-item[data-session-id="${sessionId}"]`); if (item) item.classList.remove('stream-complete'); var star = document.querySelector(`.session-star[data-session-id="${sessionId}"]`); if (star) { star.classList.remove('notify', 'processing'); star.style.opacity = ''; } _updateResearchDots(); _updateRailNotifs(); } // Initialize dropdowns once DOM is ready function _initAllDropdowns() { initModelPicker({ getCurrentSessionId: () => currentSessionId, getSessions: () => sessions, getPendingChat: () => _pendingChat, setPendingChat: (v) => { _pendingChat = v; }, createDirectChat, }); _initDropdownDismiss(); _initBulkSelect(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', _initAllDropdowns); } else { _initAllDropdowns(); } // Shared global listener to close all session dropdowns on click-away or Escape function _initDropdownDismiss() { document.addEventListener('click', (e) => { if (e.target.closest('.session-dropdown-menu')) return; document.querySelectorAll('.session-dropdown-menu').forEach(d => d.style.display = 'none'); }); // Watch the sidebar — when it's hidden (any path: hamburger, swipe, mobile // collapse), close any open session dropdowns so they don't orphan over // the page. const _sb = document.getElementById('sidebar'); if (_sb) { new MutationObserver(() => { if (_sb.classList.contains('hidden')) { document.querySelectorAll('.session-dropdown-menu, .folder-submenu').forEach(d => d.style.display = 'none'); } }).observe(_sb, { attributes: true, attributeFilter: ['class'] }); } document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { document.querySelectorAll('.session-dropdown-menu').forEach(d => d.style.display = 'none'); } }); } // ────────────────────────────────────────────── // Shared: positioned dropdown menu // ────────────────────────────────────────────── /** * Show a dropdown menu anchored to a button, using the existing * .dropdown / .dropdown-item-compact / .session-dropdown-menu CSS. * Items: [{ label, action, danger? }] * Returns a close() function. */ function _showDropdown(anchorEl, items) { // Close any open archive dropdown document.querySelectorAll('.session-dropdown-menu.archive-dd').forEach(d => d.remove()); const dd = document.createElement('div'); dd.className = 'dropdown session-dropdown-menu archive-dd'; for (const item of items) { const row = document.createElement('div'); row.className = 'dropdown-item-compact' + (item.danger ? ' dropdown-item-danger' : ''); row.innerHTML = '' + item.label + ''; row.addEventListener('click', (e) => { e.stopPropagation(); close(); item.action(); }); dd.appendChild(row); } document.body.appendChild(dd); // Position using viewport coords (same pattern as session menus) const rect = anchorEl.getBoundingClientRect(); dd.style.right = (window.innerWidth - rect.right) + 'px'; dd.style.top = '-9999px'; dd.style.display = 'block'; const ddRect = dd.getBoundingClientRect(); if (rect.bottom + 2 + ddRect.height > window.innerHeight) { dd.style.top = Math.max(2, rect.top - ddRect.height - 2) + 'px'; } else { dd.style.top = (rect.bottom + 2) + 'px'; } function close() { dd.remove(); } // Existing _initDropdownDismiss handles click-away + Escape for .session-dropdown-menu return close; } // ────────────────────────────────────────────── // Archive Browser // ────────────────────────────────────────────── // All mutable archive state lives here; reset on each openArchive(). const _arc = { data: [], total: 0, search: '', offset: 0, sort: 'recent', model: '', debounce: null, selectMode: false, selected: new Set(), allModelCounts: null }; function _arcRelativeTime(iso) { if (!iso) return ''; const diff = Date.now() - new Date(iso).getTime(); const mins = Math.floor(diff / 60000); if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; const days = Math.floor(hrs / 24); if (days < 30) return `${days}d ago`; return new Date(iso).toLocaleDateString(); } // ── Actions (pure side-effects, no DOM creation) ── // Peek at an archived session — load its history without unarchiving let _peekingSessionId = null; async function _arcPeekOpen(sid) { try { _peekingSessionId = sid; closeArchive(); // Load history directly without unarchiving const res = await fetch(`${API_BASE}/api/history/${sid}`); const data = await res.json(); const history = data.history || []; // Set as current session so chat renders currentSessionId = sid; // Find the archived session metadata const meta = _arc.data.find(s => s.id === sid); const metaEl = document.getElementById('current-meta'); if (metaEl) metaEl.textContent = (meta?.name || 'Archived') + ' (archived)'; // Render the chat history const chatBox = document.getElementById('chat-history'); if (chatBox) chatBox.innerHTML = ''; if (window.chatModule && window.chatModule.hideWelcomeScreen) window.chatModule.hideWelcomeScreen(); const addMsg = window.chatModule && window.chatModule.addMessage; if (addMsg) { for (const msg of history) { if (msg.role === 'user' || msg.role === 'assistant') { const model = String((msg.metadata && msg.metadata.model) || ''); const content = typeof msg.content === 'string' ? msg.content : (Array.isArray(msg.content) ? msg.content : String(msg.content || '')); try { addMsg(msg.role, content, model, msg.metadata || null); } catch (e) { console.warn('Failed to render message:', e); } } } } if (window.uiModule) window.uiModule.scrollHistory(); } catch (e) { console.error('Peek open failed:', e); uiModule.showError('Failed to open archived session'); } } // When navigating away from a peeked session, just clear the state function _checkPeekCleanup(newSessionId) { if (_peekingSessionId && _peekingSessionId !== newSessionId) { _peekingSessionId = null; } } async function _arcRestore(sid) { try { const res = await fetch(`${API_BASE}/api/session/${sid}/unarchive`, { method: 'POST' }); if (!res.ok) throw new Error('Failed'); _arcRemove(sid); _arcRefreshUI(); uiModule.showToast('Session restored'); loadSessions(); } catch { uiModule.showError('Failed to restore session'); } } async function _arcDelete(sid) { if (!await window.styledConfirm('Delete this session permanently?', { confirmText: 'Delete', danger: true })) return; try { const res = await fetch(`${API_BASE}/api/session/${sid}`, { method: 'DELETE' }); if (!res.ok) throw new Error('Failed'); await _animateSessionRowsRemoving([sid], '#archive-grid .archive-row[data-session-id]'); _arcRemove(sid); _arcRefreshUI(); uiModule.showToast('Session deleted'); } catch { uiModule.showError('Failed to delete session'); } } function _arcRemove(sid) { _arc.data = _arc.data.filter(x => x.id !== sid); _arc.total--; _arc.selected.delete(sid); } async function _arcBulkRestore() { const ids = [..._arc.selected]; if (!ids.length) return; for (const sid of ids) { try { await fetch(`${API_BASE}/api/session/${sid}/unarchive`, { method: 'POST' }); _arcRemove(sid); } catch {} } _arc.selected.clear(); _arcRefreshUI(); uiModule.showToast(`${ids.length} session${ids.length > 1 ? 's' : ''} restored`); loadSessions(); } async function _arcBulkDelete() { const ids = [..._arc.selected]; if (!ids.length) return; const ok = await uiModule.styledConfirm(`Delete ${ids.length} session${ids.length > 1 ? 's' : ''} permanently?`, { confirmText: 'Delete', danger: true }); if (!ok) return; const deletedIds = []; for (const sid of ids) { try { const res = await fetch(`${API_BASE}/api/session/${sid}`, { method: 'DELETE' }); if (res.ok) { deletedIds.push(sid); _arcRemove(sid); } } catch {} } await _animateSessionRowsRemoving(deletedIds, '#archive-grid .archive-row[data-session-id]'); _arc.selected.clear(); _arcRefreshUI(); uiModule.showToast(`${deletedIds.length} session${deletedIds.length > 1 ? 's' : ''} deleted`); } function _arcToggleSelectMode() { _arc.selectMode = !_arc.selectMode; _arc.selected.clear(); _arcRefreshUI(); } function _arcUpdateBulkBar() { const bar = document.getElementById('archive-bulk-bar'); const count = document.getElementById('archive-selected-count'); const selectBtn = document.getElementById('archive-select-btn'); if (bar) bar.classList.toggle('hidden', !_arc.selectMode); if (count) count.textContent = `${_arc.selected.size} selected`; if (selectBtn) { selectBtn.textContent = _arc.selectMode ? 'Cancel' : 'Select'; selectBtn.classList.toggle('active', _arc.selectMode); } } // ── Data fetching ── async function _arcFetch(append) { if (!append) _arc.offset = 0; const params = new URLSearchParams({ offset: String(_arc.offset), limit: '20', sort: _arc.sort }); if (_arc.search) params.set('search', _arc.search); if (_arc.model) params.set('model', _arc.model); try { const res = await fetch(`${API_BASE}/api/sessions/archived?${params}`); if (!res.ok) throw new Error(res.statusText); const data = await res.json(); _arc.data = append ? _arc.data.concat(data.sessions) : data.sessions; _arc.total = data.total; // Cache model counts from unfiltered first fetch if (!_arc.allModelCounts && !_arc.model && !_arc.search) { const counts = {}; _arc.data.forEach(s => { const m = (s.model || '').split('/').pop(); if (m) counts[m] = (counts[m] || 0) + 1; }); _arc.allModelCounts = { counts, total: _arc.total }; } _arcRefreshUI(); } catch (e) { console.error('Archive fetch failed:', e); } } // ── Rendering (dumb — reads _arc, writes DOM) ── function _arcRefreshUI() { _arcRenderStats(); _arcRenderChips(); _arcRenderGrid(); _arcRenderLoadMore(); _arcUpdateBulkBar(); } function _arcRenderStats() { const el = document.getElementById('archive-stats'); if (el) el.textContent = _arc.total ? `${_arc.total}` : ''; } function _arcRenderChips() { const el = document.getElementById('archive-chips'); if (!el) return; // Use cached counts so chips don't disappear when filtering const cached = _arc.allModelCounts; if (!cached) return; const modelCounts = cached.counts; const models = Object.keys(modelCounts).sort(); if (models.length < 2) { el.innerHTML = ''; return; } el.innerHTML = ''; const mkChip = (label, value, count) => { const chip = document.createElement('button'); chip.className = 'doclib-chip' + (_arc.model === value ? ' active' : ''); chip.textContent = `${label} (${count})`; chip.addEventListener('click', () => { _arc.model = (_arc.model === value ? '' : value); _arcFetch(false); }); el.appendChild(chip); }; mkChip('All', '', cached.total); models.forEach(m => mkChip(m, m, modelCounts[m])); } function _arcRenderCard(s) { const card = document.createElement('div'); card.className = 'memory-item archive-row' + (_arc.selected.has(s.id) ? ' selected' : ''); card.dataset.sessionId = s.id; const modelShort = uiModule.esc((s.model || '').split('/').pop()); const msgCount = s.message_count || 0; const checkboxHtml = _arc.selectMode ? `` : ''; card.innerHTML = ` ${checkboxHtml}