// Session Management Functions // This module handles all session-related operations import Storage from './storage.js'; import uiModule, { autoResize, styledPrompt } from './ui.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; let _suppressNextSessionLoading = false; const HISTORY_DISPLAY_CHAR_LIMIT = 160000; const HISTORY_DISPLAY_TAIL_CHARS = 20000; const HISTORY_PAGE_LIMIT_MOBILE = 8; const HISTORY_PAGE_LIMIT_DESKTOP = 24; 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'; let _historyPager = null; function _paintSessionLoading(chatHistory, label = 'Loading chat') { if (!chatHistory) return; if (chatRenderer.hideWelcomeScreen) chatRenderer.hideWelcomeScreen(); chatHistory.style.transition = ''; chatHistory.style.opacity = '1'; chatHistory.classList.add('no-animate'); chatHistory.innerHTML = ''; const wrap = document.createElement('div'); wrap.className = 'session-loading-state session-loading-skeleton'; wrap.setAttribute('role', 'status'); wrap.setAttribute('aria-live', 'polite'); wrap.setAttribute('aria-label', label); const viewportHeight = chatHistory.clientHeight || window.innerHeight || 720; const bubbleCount = Math.max(8, Math.min(16, Math.ceil(viewportHeight / 86))); for (let i = 0; i < bubbleCount; i += 1) { const bubble = document.createElement('div'); bubble.className = `session-skeleton-bubble ${i % 2 ? 'is-user' : 'is-ai'}`; const lines = i % 4 === 1 ? 2 : (i % 4 === 3 ? 3 : 4); for (let j = 0; j < lines; j += 1) { const line = document.createElement('div'); line.className = 'session-skeleton-line'; line.style.width = `${[72, 92, 58, 82][(i + j) % 4]}%`; bubble.appendChild(line); } wrap.appendChild(bubble); } chatHistory.appendChild(wrap); } function _updateSessionLoading(chatHistory, label) { const el = chatHistory?.querySelector('.session-loading-state'); if (el) el.setAttribute('aria-label', label); } function _nextPaint() { return new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); } function _displayHistoryContent(content) { const text = String(content || ''); if (text.length <= HISTORY_DISPLAY_CHAR_LIMIT) return text; const head = text.slice(0, HISTORY_DISPLAY_CHAR_LIMIT - HISTORY_DISPLAY_TAIL_CHARS); const tail = text.slice(-HISTORY_DISPLAY_TAIL_CHARS); const omitted = text.length - head.length - tail.length; return [ `> Large message display clipped (${omitted.toLocaleString()} characters omitted). Full content remains stored in chat history/export.`, '', head, '', '```text', `[... ${omitted.toLocaleString()} characters omitted from on-screen history render ...]`, '```', '', tail, ].join('\n'); } function _stripUserVisionBlocks(text) { return String(text || '').replace( /\n*\[Image: ([^\]]+)\]\n[\s\S]*?(?=\n*\[Image: |\n*\[Image attached: |\n*=== File: |\n*\[PDF content\]:|$)/g, '' ).trim(); } function _historyPageLimit() { return window.innerWidth <= 768 ? HISTORY_PAGE_LIMIT_MOBILE : HISTORY_PAGE_LIMIT_DESKTOP; } function _historyUrl(id, { limit = null, offset = null } = {}) { const url = new URL(`${API_BASE}/api/history/${id}`); if (limit != null) url.searchParams.set('limit', String(limit)); if (offset != null) url.searchParams.set('offset', String(offset)); return url.toString(); } function _addHistoryMessageWithFullRenderer(role, content, modelName, meta) { const box = document.getElementById('chat-history'); if (!box) return []; const marker = document.createComment('history-message'); box.appendChild(marker); let rendered = null; try { rendered = chatRenderer.addMessage(role, content, modelName, meta); } catch (e) { marker.remove(); throw e; } const nodes = []; let node = marker.nextSibling; while (node) { const next = node.nextSibling; nodes.push(node); node = next; } marker.remove(); return nodes.length ? nodes : (rendered ? [rendered] : []); } function _renderHistoryMessage(msg, modelName) { const meta = msg.metadata ? { ...msg.metadata, _fromHistory: true } : null; let displayContent; if (typeof msg.content === 'string') { displayContent = _displayHistoryContent(msg.content); } else if (Array.isArray(msg.content)) { displayContent = _displayHistoryContent(msg.content.filter(p => p.type === 'text').map(p => p.text).join('\n').trim()); } else { displayContent = ''; } if (msg.role === 'user') { displayContent = _stripUserVisionBlocks(displayContent); const trimmed = displayContent.trim(); if ( trimmed === 'Continue where you left off' || trimmed.startsWith('Your message was cut off.') || trimmed.startsWith('Your previous response was interrupted.') || displayContent.includes('[Instruction: Rewrite') || displayContent.includes('[Instruction: Explain') ) { return null; } 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]}`; } } return _addHistoryMessageWithFullRenderer(msg.role, displayContent, modelName, meta); } function _clearHistoryPager() { const box = document.getElementById('chat-history'); if (_historyPager?.handler && box) { box.removeEventListener('scroll', _historyPager.handler); } _historyPager = null; } function _installHistoryPager(id, pageInfo, modelName) { const box = document.getElementById('chat-history'); _clearHistoryPager(); if (!box || !pageInfo || !pageInfo.has_more_before) return; _historyPager = { sessionId: id, offset: Number(pageInfo.offset || 0), limit: Number(pageInfo.limit || _historyPageLimit()), loading: false, done: false, modelName, handler: null, }; const loadOlder = async () => { if (!_historyPager || _historyPager.loading || _historyPager.done) return; if (_historyPager.sessionId !== currentSessionId) return; if (box.scrollTop > 90) return; const nextOffset = Math.max(0, _historyPager.offset - _historyPager.limit); const nextLimit = _historyPager.offset - nextOffset; if (nextLimit <= 0) { _historyPager.done = true; return; } _historyPager.loading = true; const anchor = box.querySelector('.msg, .agent-thread, .gallery-bubble'); const beforeHeight = box.scrollHeight; try { const res = await fetch(_historyUrl(_historyPager.sessionId, { limit: nextLimit, offset: nextOffset })); const data = await res.json(); if (!_historyPager || _historyPager.sessionId !== currentSessionId) return; const newEls = []; for (const msg of data.history || []) { if (msg.role !== 'user' && msg.role !== 'assistant') continue; const els = _renderHistoryMessage(msg, _historyPager.modelName); if (Array.isArray(els)) newEls.push(...els); } for (const el of newEls) { box.insertBefore(el, anchor || box.firstChild); } _historyPager.offset = Number(data.offset || nextOffset); _historyPager.done = !data.has_more_before; if (window.hljs) { newEls.forEach(el => el.querySelectorAll('pre code:not(.hljs)').forEach(block => window.hljs.highlightElement(block))); } const heightDelta = box.scrollHeight - beforeHeight; box.scrollTop += heightDelta; } catch (e) { console.warn('Failed to load older chat history:', e); } finally { if (_historyPager) _historyPager.loading = false; } }; _historyPager.handler = () => { if (box.scrollTop <= 90) loadOlder(); }; box.addEventListener('scroll', _historyPager.handler, { passive: true }); } 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; } function _dateBucketLabel(value) { if (!value) return 'Older'; const d = new Date(value); if (Number.isNaN(d.getTime())) return 'Older'; const dayStart = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime(); const today = dayStart(new Date()); const day = dayStart(d); const diff = Math.round((today - day) / 86400000); if (diff === 0) return 'Today'; if (diff === 1) return 'Yesterday'; if (diff > 1 && diff < 7) return d.toLocaleDateString([], { weekday: 'long' }); if (diff >= 365) { const years = Math.floor(diff / 365); return `${years} ${years === 1 ? 'year' : 'years'} ago`; } if (diff >= 180) return '6 months ago'; if (diff >= 30) return `${Math.floor(diff / 30) * 30} days ago`; const sameYear = d.getFullYear() === new Date().getFullYear(); return d.toLocaleDateString([], sameYear ? { month: 'long', day: 'numeric' } : { month: 'long', day: 'numeric', year: 'numeric' }); } function _sessionBucketDate(s) { return s.last_message_at || s.updated_at || s.created_at || ''; } function _createDateSectionHeader(label, kind = 'session') { const el = document.createElement('div'); el.className = `date-section-header ${kind}-date-section-header`; el.textContent = label; return el; } function _appendSessionItemsWithDateHeaders(frag, items) { let lastLabel = null; for (const s of items) { const label = _dateBucketLabel(_sessionBucketDate(s)); if (label !== lastLabel) { frag.appendChild(_createDateSectionHeader(label, 'session')); lastLabel = label; } frag.appendChild(createSessionItem(s)); } } function _appendFavoriteSessionItems(frag, items) { if (!items.length) return; frag.appendChild(_createDateSectionHeader('Favorites', 'session')); for (const s of items) { frag.appendChild(createSessionItem(s)); } } 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; }); // Favorites are a global pinned block above date buckets, not just // promoted within the day they belong to. const allFlat = [ ...orderedSessions.filter(s => s.is_important), ...orderedSessions.filter(s => !s.is_important), ]; 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]); const visibleFavorites = visible.filter(s => s.is_important); const visibleRegular = visible.filter(s => !s.is_important); _appendFavoriteSessionItems(_frag, visibleFavorites); _appendSessionItemsWithDateHeaders(_frag, visibleRegular); 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)); let hashId = window.location.hash.replace('#', ''); if (/^(document|note|image|email|event|task|skill|research)-/.test(hashId) || /^open=notes¬e=/.test(hashId)) { hashId = ''; } 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 */ } } } const suppressSessionLoading = _suppressNextSessionLoading; _suppressNextSessionLoading = false; if (targetId && targetId !== currentSessionId) { const showLoading = !suppressSessionLoading && !(_isFirstLoad && !hashId); await selectSession(targetId, { keepSidebar: true, showLoading }); } 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, showLoading = true, immediateLoading = 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; _clearHistoryPager(); // 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 = ''; msgInput.style.height = ''; msgInput.style.overflow = ''; autoResize(msgInput); } const sendBtn2 = document.querySelector('.send-btn'); if (sendBtn2) { sendBtn2.style.color = ''; if (window._updateSendBtnIcon) window._updateSendBtnIcon(); } // On mobile manual chat switches, move the drawer away before showing the // loader so the status sits over the chat pane instead of being hidden by // the sidebar. Startup auto-restore passes keepSidebar + showLoading=false. if (showLoading && !keepSidebar && window.innerWidth <= 768) { const sidebar = document.getElementById('sidebar'); const backdrop = document.getElementById('sidebar-backdrop'); if (sidebar) sidebar.classList.add('hidden'); if (backdrop) backdrop.classList.remove('visible'); } // 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, pageInfo = null; let paintedLoading = false; let loadingTimer = null; let loadingPaintReady = Promise.resolve(); if (!isOC) { if (showLoading && chatHistory && prevSessionId !== id) { const loadingDelayMs = immediateLoading ? 0 : (window.innerWidth <= 768 ? 900 : 500); loadingTimer = setTimeout(() => { if (navToken !== _sessionNavToken || currentSessionId !== id) return; _paintSessionLoading(chatHistory, 'Loading chat'); paintedLoading = true; loadingPaintReady = _nextPaint(); }, loadingDelayMs); } const res = await fetch(_historyUrl(id, { limit: _historyPageLimit() })); const data = await res.json(); if (loadingTimer) { clearTimeout(loadingTimer); loadingTimer = null; } if (paintedLoading) { await loadingPaintReady; } if (navToken !== _sessionNavToken || currentSessionId !== id) return; msgHistory = data.history || []; modelName = data.model || null; pageInfo = { offset: data.offset, limit: data.limit, total: data.total, has_more_before: !!data.has_more_before, }; // 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; } if (paintedLoading && chatHistory) { _updateSessionLoading(chatHistory, msgHistory.length ? 'Rendering chat' : 'Opening chat'); await _nextPaint(); if (navToken !== _sessionNavToken || currentSessionId !== id) return; chatHistory.innerHTML = ''; } // Fade out old content, swap, fade in. When we already painted a loading // state, keep it visible until render starts instead of fading to a blank // pane during slow history fetches. if (chatHistory && !paintedLoading) { 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) { try { _renderHistoryMessage(msg, modelName); } catch (e) { console.warn('Failed to render history message:', e, msg); } } } else { if (window.chatModule && window.chatModule.showWelcomeScreen) window.chatModule.showWelcomeScreen(); // Don't highlight ordinary empty sessions — feels like nothing is // selected. Keep document/email-scoped sessions highlighted though: a // new email/reply chat starts empty but immediately owns an email doc. const isDocScopedEmptySession = !!(meta && (meta.has_documents || /^Email:|^New Email$/i.test(meta.name || ''))); if (!isDocScopedEmptySession) { document.querySelectorAll('.list-item.active-session').forEach(el => el.classList.remove('active-session')); } } uiModule.scrollHistoryInstant(); if (!isOC && msgHistory.length) { _installHistoryPager(id, pageInfo, modelName); } // 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); const chatHistory = uiModule.el('chat-history'); if (chatHistory?.querySelector('.session-loading-state')) { chatHistory.innerHTML = ''; chatHistory.style.opacity = '1'; chatHistory.classList.remove('no-animate'); const msg = document.createElement('div'); msg.className = 'msg msg-ai'; msg.innerHTML = `