Squash Odysseus development history

This commit is contained in:
pewdiepie-archdaemon
2026-09-11 06:04:19 +00:00
parent c9dd68d890
commit 84aa9a91de
871 changed files with 265870 additions and 27854 deletions
+344 -56
View File
@@ -2,12 +2,14 @@
// This module handles all session-related operations
import Storage from './storage.js';
import uiModule, { autoResize, styledPrompt } from './ui.js';
import chatRenderer from './chatRenderer.js?v=20260815toolapproval4';
import uiModule, { autoResize, styledPrompt } from './ui.js?v=20260908weekhoverfix1';
import chatRenderer from './chatRenderer.js?v=20260910streamlinks2';
import { providerLogo } from './providers.js';
import { initModelPicker, updateModelPicker } from './modelPicker.js?v=20260722ctxheader1';
import themeModule from './theme.js';
import { initModelPicker, updateModelPicker } from './modelPicker.js?v=20260909routeidentity1';
import themeModule from './theme.js?v=20260909effectspeed1';
import spinnerModule from './spinner.js';
import { actionMenuRank, orderActionMenuItems, SELECT_MENU_ICON } from './actionMenuOrder.js';
import { registerEscapeLayer, bindMenuDismiss } from './escMenuStack.js';
const API_BASE = window.location.origin;
@@ -33,6 +35,9 @@ const _INCOGNITO_SESSIONS_KEY = 'ody-incognito-sessions'; // sessionStorage key
const _isMac = /Mac|iPhone|iPad/.test(navigator.platform);
const _mod = _isMac ? '⌘' : 'Ctrl';
let _historyPager = null;
const _serverStreamChecksInFlight = new Set();
const _serverStreamAbsentUntil = new Map();
const SERVER_STREAM_ABSENT_TTL_MS = 30000;
function _shouldPreserveStartupComposer(msgInput) {
if (!msgInput || !msgInput.value) return false;
@@ -50,6 +55,57 @@ function _clearComposerUnlessStartupTyped(msgInput) {
msgInput.value = '';
}
function _forceExitIncognitoUi() {
const chk = document.getElementById('incognito-toggle');
const wasIncognito = !!(chk && chk.checked);
if (chk) chk.checked = false;
const indicator = document.getElementById('incognito-indicator');
if (indicator) indicator.style.display = 'none';
const btn = document.getElementById('incognito-btn');
if (btn) {
btn.classList.remove('active');
btn.title = 'Enable Nobody mode — no memory or skill injection, no history saved';
btn.innerHTML = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg><span class="incognito-label">Nobody</span>';
}
const welcomeName = document.querySelector('.welcome-name');
if (welcomeName && welcomeName.dataset.originalHtml) {
welcomeName.innerHTML = welcomeName.dataset.originalHtml;
delete welcomeName.dataset.originalHtml;
}
const welcomeSub = document.getElementById('welcome-sub');
if (welcomeSub && welcomeSub.dataset.originalText) {
welcomeSub.textContent = welcomeSub.dataset.originalText;
delete welcomeSub.dataset.originalText;
welcomeSub.style.display = '';
}
const tip = document.getElementById('welcome-tip');
if (tip && Object.prototype.hasOwnProperty.call(tip.dataset, 'originalTip')) {
tip.textContent = tip.dataset.originalTip;
delete tip.dataset.originalTip;
tip.classList.remove('nobody-session-note');
tip.style.opacity = '';
tip.style.marginTop = '';
}
if (wasIncognito) {
const toggleState = Storage.getJSON(Storage.KEYS.TOGGLES, {}) || {};
const restoreMode = toggleState.nobody_prev_mode || toggleState.mode || 'agent';
delete toggleState.nobody_prev_mode;
['web_agent', 'bash_agent', 'web_chat', 'bash_chat'].forEach(k => {
if (toggleState[k] === false) delete toggleState[k];
});
Storage.setJSON(Storage.KEYS.TOGGLES, toggleState);
if (typeof window.__odysseusSetChatMode === 'function') {
window.__odysseusSetChatMode(restoreMode === 'chat' ? 'chat' : 'agent');
}
}
}
function _paintSessionLoading(chatHistory, label = 'Loading chat') {
if (!chatHistory) return;
if (chatRenderer.hideWelcomeScreen) chatRenderer.hideWelcomeScreen();
@@ -275,9 +331,42 @@ async function _cleanupIncognitoSessions() {
// 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
const _COMPLETED_SESSIONS_KEY = 'odysseus-completed-chat-sessions-v1';
const _completedSessions = new Set((() => {
try {
const ids = JSON.parse(localStorage.getItem(_COMPLETED_SESSIONS_KEY) || '[]');
return Array.isArray(ids) ? ids.map(String) : [];
} catch (_) { return []; }
})());
let _researchPollTimer = null;
function _persistCompletedSessions() {
try { localStorage.setItem(_COMPLETED_SESSIONS_KEY, JSON.stringify([..._completedSessions])); } catch (_) {}
}
function _renderSessionRunState(state, isRunning) {
if (!state) return;
if (state._whirlpool) {
state._whirlpool.destroy();
state._whirlpool = null;
}
state.replaceChildren();
state.classList.toggle('is-working', isRunning);
state.classList.toggle('is-done', !isRunning);
if (isRunning) {
const whirlpool = spinnerModule.createWhirlpool(12);
whirlpool.element.classList.add('session-run-whirlpool');
state.appendChild(whirlpool.element);
state._whirlpool = whirlpool;
state.title = 'Agent is working';
state.setAttribute('aria-label', 'Agent is working');
} else {
state.textContent = 'Done';
state.title = 'Agent finished while you were away';
state.setAttribute('aria-label', 'Agent finished while you were away');
}
}
// Session list keyboard navigation state
let _sessionListFocused = false;
@@ -376,6 +465,32 @@ async function moveToFolder(sessionId, folderName) {
renderSessionList();
}
// Let a chat be dropped directly on a folder/tag. The existing menu action is
// still available, but drag-and-drop is faster when organizing several chats.
function wireFolderDropTarget(header, folderName) {
header.addEventListener('dragover', (e) => {
const types = Array.from(e.dataTransfer?.types || []);
if (!types.includes('application/x-odysseus-session')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
header.classList.add('drag-over');
});
header.addEventListener('dragleave', (e) => {
if (!header.contains(e.relatedTarget)) header.classList.remove('drag-over');
});
header.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
header.classList.remove('drag-over');
const sessionId = e.dataTransfer.getData('application/x-odysseus-session');
if (!sessionId) return;
const session = sessions.find(s => String(s.id) === String(sessionId));
if (!session || (session.folder || '') === (folderName || '')) return;
await moveToFolder(sessionId, folderName);
uiModule.showToast(folderName ? `Moved to ${folderName}` : 'Removed from folder');
});
}
/** Build the "Move to folder" submenu for a session dropdown. */
function buildFolderSubmenu(sessionId, currentFolder, dropdown) {
const folders = getFolderNames();
@@ -448,6 +563,11 @@ function buildFolderSubmenu(sessionId, currentFolder, dropdown) {
if (sub.style.display === 'block') {
sub.style.display = 'none';
} else {
sub._escapeUnregister?.();
sub._escapeUnregister = registerEscapeLayer(
() => { sub.style.display = 'none'; },
() => sub.isConnected && sub.style.display === 'block',
);
const rect = moveItem.getBoundingClientRect();
const isMobile = window.innerWidth <= 768;
sub.style.top = '-9999px';
@@ -493,6 +613,9 @@ function buildFolderSubmenu(sessionId, currentFolder, dropdown) {
function createSessionItem(s) {
const div = document.createElement('div');
div.className = 'list-item session-item';
// Native desktop drag moves a chat onto a folder/tag. The dedicated handle
// continues to control manual ordering in Rearrange mode.
div.draggable = true;
div.setAttribute('role', 'option');
div.setAttribute('tabindex', '-1');
div.setAttribute('data-session-id', s.id);
@@ -562,6 +685,20 @@ function createSessionItem(s) {
}
div.appendChild(icon);
div.addEventListener('dragstart', (e) => {
if (document.body.classList.contains('rearrange-mode')) {
e.preventDefault();
return;
}
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('application/x-odysseus-session', s.id);
div.classList.add('dragging-to-folder');
});
div.addEventListener('dragend', () => {
div.classList.remove('dragging-to-folder');
document.querySelectorAll('.session-folder-header.drag-over').forEach(h => h.classList.remove('drag-over'));
});
const span = document.createElement('span');
span.className = 'grow';
let chatTitle = s.name || '';
@@ -612,11 +749,77 @@ function createSessionItem(s) {
// On mobile, suppress click if user was scrolling (touchmove detected)
// Long press on mobile shows context menu
let _touchMoved = false;
let _touchStartX = 0;
let _touchStartY = 0;
let _longPressTimer = null;
let _longPressed = false;
let _folderDragPointer = null;
let _folderDragMoved = false;
let _folderDragTarget = null;
// Pointer-based fallback for moving chats onto folder/tag headers. Native
// HTML5 drag events are unreliable when the sidebar also handles scrolling
// and row clicks, especially in Firefox.
const _clearFolderDragTarget = () => {
if (_folderDragTarget) _folderDragTarget.classList.remove('drag-over');
_folderDragTarget = null;
};
const _finishFolderPointerDrag = async (e) => {
if (!_folderDragPointer || e.pointerId !== _folderDragPointer) return;
const target = _folderDragTarget;
const moved = _folderDragMoved;
_folderDragPointer = null;
_folderDragMoved = false;
_clearFolderDragTarget();
document.removeEventListener('pointermove', _moveFolderPointer);
document.removeEventListener('pointerup', _finishFolderPointerDrag);
document.removeEventListener('pointercancel', _finishFolderPointerDrag);
if (!moved || !target) return;
const folderName = target.dataset.folderName || '';
if ((s.folder || '') === folderName) return;
await moveToFolder(s.id, folderName);
uiModule.showToast(folderName ? `Moved to ${folderName}` : 'Removed from folder');
};
const _moveFolderPointer = (e) => {
if (!_folderDragPointer || e.pointerId !== _folderDragPointer) return;
const dx = e.clientX - _folderDragStartX;
const dy = e.clientY - _folderDragStartY;
if (!_folderDragMoved && Math.hypot(dx, dy) < 10) return;
_folderDragMoved = true;
e.preventDefault();
const under = document.elementFromPoint(e.clientX, e.clientY);
const target = under?.closest('.session-folder-header');
if (target && document.getElementById('session-list')?.contains(target)) {
if (_folderDragTarget !== target) {
_clearFolderDragTarget();
_folderDragTarget = target;
}
target.classList.add('drag-over');
} else {
_clearFolderDragTarget();
}
};
let _folderDragStartX = 0;
let _folderDragStartY = 0;
div.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'touch' || e.target.closest('button, input, select, a, .item-drag-handle')) return;
if (document.body.classList.contains('rearrange-mode')) return;
_folderDragPointer = e.pointerId;
_folderDragMoved = false;
_folderDragStartX = e.clientX;
_folderDragStartY = e.clientY;
document.addEventListener('pointermove', _moveFolderPointer, { passive: false });
document.addEventListener('pointerup', _finishFolderPointerDrag);
document.addEventListener('pointercancel', _finishFolderPointerDrag);
});
div.addEventListener('touchstart', (e) => {
_touchMoved = false;
_longPressed = false;
const touch = e.touches && e.touches[0];
if (touch) {
_touchStartX = touch.clientX;
_touchStartY = touch.clientY;
}
if (window.innerWidth > 768) return;
_longPressTimer = setTimeout(() => {
_longPressed = true;
@@ -634,6 +837,11 @@ function createSessionItem(s) {
dd.style.right = 'auto';
dd.style.display = 'block';
dd.style.zIndex = '1000';
dd._escapeUnregister?.();
dd._escapeUnregister = registerEscapeLayer(
() => { dd.style.display = 'none'; },
() => dd.isConnected && dd.style.display === 'block',
);
// Clamp to viewport
requestAnimationFrame(() => {
const mr = dd.getBoundingClientRect();
@@ -646,8 +854,13 @@ function createSessionItem(s) {
}
}, 500);
}, { passive: true });
div.addEventListener('touchmove', () => {
_touchMoved = true;
div.addEventListener('touchmove', (e) => {
const touch = e.touches && e.touches[0];
// Browsers can emit a tiny touchmove during an ordinary tap. Only treat
// a meaningful displacement as scrolling so taps still open the chat.
if (touch && (Math.abs(touch.clientX - _touchStartX) > 8 || Math.abs(touch.clientY - _touchStartY) > 8)) {
_touchMoved = true;
}
if (_longPressTimer) { clearTimeout(_longPressTimer); _longPressTimer = null; }
}, { passive: true });
div.addEventListener('touchend', () => {
@@ -763,7 +976,7 @@ function createSessionItem(s) {
if (!isOpenClaw) {
const selectMoreItem = document.createElement('div');
selectMoreItem.className = 'dropdown-item-compact';
selectMoreItem.innerHTML = _icon('<span style="font-size:16px;line-height:1;">●</span>') + '<span>Select</span>';
selectMoreItem.innerHTML = _icon(SELECT_MENU_ICON) + '<span>Select</span>';
selectMoreItem.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.style.display = 'none';
@@ -805,6 +1018,19 @@ function createSessionItem(s) {
});
dropdown.appendChild(cancelItem);
// Normalize common actions after all conditional items have been created.
// Product-specific session actions retain their authored order.
_sep.remove();
const orderedSessionItems = orderActionMenuItems(
Array.from(dropdown.children).map(node => ({
node,
label: node.querySelector(':scope > span:not(.dropdown-icon):not(.dropdown-shortcut)')?.textContent || node.textContent,
})),
);
orderedSessionItems.forEach(item => dropdown.appendChild(item.node));
const archiveBoundary = orderedSessionItems.find(item => actionMenuRank(item) >= 700);
if (archiveBoundary) dropdown.insertBefore(_sep, archiveBoundary.node);
// Add event listeners
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
@@ -816,6 +1042,7 @@ function createSessionItem(s) {
if (dropdown.style.display === 'block') {
dropdown.style.display = 'none';
} else {
registerDropdownEscape();
// Position the dropdown using viewport coords
const rect = menuBtn.getBoundingClientRect();
dropdown.style.left = '';
@@ -825,10 +1052,10 @@ function createSessionItem(s) {
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';
if (rect.bottom + 4 + ddRect.height > window.innerHeight) {
dropdown.style.top = Math.max(4, rect.top - ddRect.height - 4) + 'px';
} else {
dropdown.style.top = rect.bottom + 2 + 'px';
dropdown.style.top = rect.bottom + 4 + 'px';
}
}
});
@@ -890,7 +1117,7 @@ function createSessionItem(s) {
_skipAutoSelect = true;
// Clean up persistent chat mapping
try {
const pm = await import('./presets.js');
const pm = await import('./presets.js?v=20260908personaname1');
if (pm.removePersistentChat) pm.removePersistentChat(s.id);
} catch (e) {}
// On mobile, close sidebar if we deleted the active session so user sees welcome screen
@@ -938,6 +1165,14 @@ function createSessionItem(s) {
e.stopPropagation();
});
const registerDropdownEscape = () => {
dropdown._escapeUnregister?.();
dropdown._escapeUnregister = registerEscapeLayer(
() => { dropdown.style.display = 'none'; },
() => dropdown.isConnected && dropdown.style.display === 'block',
);
};
div.appendChild(span);
// Apply processing/completed state to the star dot
@@ -958,6 +1193,13 @@ function createSessionItem(s) {
}
}
if (_isProcessing || _isDone) {
const state = document.createElement('span');
state.className = 'session-run-state ' + (_isProcessing ? 'is-working' : 'is-done');
_renderSessionRunState(state, _isProcessing);
div.appendChild(state);
}
div.appendChild(menuBtn);
dropdown.addEventListener('click', (e) => e.stopPropagation());
document.body.appendChild(dropdown);
@@ -1066,6 +1308,12 @@ function _appendFavoriteSessionItems(frag, items) {
}
}
function _isUnsavedCompareSession(s) {
const name = String((s && s.name) || '').trim();
const folder = String((s && s.folder) || '').trim();
return name.startsWith('[CMP] ') && !folder.startsWith('Compare:');
}
let _renderRAF = null;
export function renderSessionList() {
// Debounce rapid re-renders within the same frame
@@ -1080,7 +1328,7 @@ function _renderSessionListImpl() {
// 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');
let orderedSessions = sessions.filter(s => !s.archived && s.folder !== 'Assistant' && !_isUnsavedCompareSession(s) && !_isIncognitoSession(s.id) && (s.name || '').trim() !== 'Nobody' && (s.name || '').trim() !== 'Incognito');
if (savedOrder) {
try {
@@ -1133,8 +1381,6 @@ function _renderSessionListImpl() {
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);
@@ -1145,7 +1391,7 @@ function _renderSessionListImpl() {
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.textContent = _showAllSessions ? 'Show less' : 'Show all';
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
_showAllSessions = !_showAllSessions;
@@ -1247,6 +1493,7 @@ function _renderSessionListImpl() {
await loadSessions();
});
header.appendChild(deleteBtn);
wireFolderDropTarget(header, folderName);
let _folderTouchMoved = false;
header.addEventListener('touchstart', () => { _folderTouchMoved = false; }, { passive: true });
@@ -1286,19 +1533,13 @@ function _renderSessionListImpl() {
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]);
}
_appendSessionItemsWithDateHeaders(content, visibleFolder);
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.textContent = folderExpanded ? 'Show less' : 'Show all';
moreBtn.addEventListener('click', (e) => {
e.stopPropagation();
_expandedFolders[folderName] = !folderExpanded;
@@ -1315,15 +1556,9 @@ function _renderSessionListImpl() {
// 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) {
@@ -1370,6 +1605,7 @@ function _renderSessionListImpl() {
await loadSessions();
});
unsortedHeader.appendChild(deleteBtn);
wireFolderDropTarget(unsortedHeader, '');
unsortedHeader.addEventListener('click', (e) => {
e.stopPropagation();
@@ -1400,7 +1636,7 @@ function _renderSessionListImpl() {
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.textContent = _showAllSessions ? 'Show less' : 'Show all';
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
_showAllSessions = !_showAllSessions;
@@ -1702,18 +1938,19 @@ export async function loadSessions() {
renderSessionList();
const sessionsSection = uiModule.el('sessions-section');
if (sessions.length === 0) {
const visibleSessions = sessions.filter(s => !s.archived && !_isUnsavedCompareSession(s));
if (visibleSessions.length === 0) {
sessionsSection.classList.add('hidden');
} else {
sessionsSection.classList.remove('hidden');
}
const activeSessions = sessions.filter(s => !s.archived);
const activeSessions = sessions.filter(s => !s.archived && !_isUnsavedCompareSession(s));
// "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 _isTransient = (s) => !!s && (s.folder === 'Assistant' || s.folder === 'Tasks' || _isUnsavedCompareSession(s));
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&note=/.test(hashId)) {
@@ -1838,8 +2075,10 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
window.compareModule.deactivate(true);
return; // deactivate does a page reload
}
let navToken = 0;
try {
const navToken = ++_sessionNavToken;
navToken = ++_sessionNavToken;
window.__odysseusSessionRenderPendingId = id;
const prevSessionId = currentSessionId;
// Selecting a real persisted chat cancels any deferred "New Chat" model
// pick. Otherwise the next send can materialize that pending chat instead
@@ -1871,9 +2110,10 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
history.replaceState(null, '', '#' + id);
}
}
_forceExitIncognitoUi();
// Restore character preset for persistent chats
try {
const presetsModule = window.presetsModule || (await import('./presets.js')).default;
const presetsModule = window.presetsModule || (await import('./presets.js?v=20260908personaname1')).default;
if (presetsModule && presetsModule.onSessionSwitch) presetsModule.onSessionSwitch(id);
} catch (e) {}
const meta = sessions.find(s => s.id === id);
@@ -1908,8 +2148,11 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
}
const msgInput = document.getElementById('message');
if (msgInput) {
msgInput.disabled = false;
_clearComposerUnlessStartupTyped(msgInput);
// Do not accept a send while the authoritative history fetch can still
// repaint this pane. A fast first send used to append optimistically and
// then disappear when the in-flight session render cleared the DOM.
msgInput.disabled = true;
msgInput.style.height = '';
msgInput.style.overflow = '';
autoResize(msgInput);
@@ -2144,6 +2387,12 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
}
uiModule.showError('Failed to load session: ' + error.message);
} finally {
if (navToken === _sessionNavToken && currentSessionId === id) {
const msgInput = document.getElementById('message');
if (msgInput) msgInput.disabled = false;
window.__odysseusSessionRenderPendingId = '';
window.__odysseusSessionReadyId = id;
}
// Memory warmup must not block chat switching. The memories panel can load
// on demand; this is only a delayed cache refresh when the foreground chat
// is idle.
@@ -2198,6 +2447,7 @@ async function _getPreferredDefaultChat() {
export function createDirectChat(url, modelId, endpointId, opts = {}) {
const incomingSource = opts.source || 'manual';
const keepDocument = !!opts.keepDocument;
if (
_pendingChat &&
_pendingChat.modelId &&
@@ -2232,17 +2482,18 @@ export function createDirectChat(url, modelId, endpointId, opts = {}) {
el.classList.remove('active-session', 'active');
});
// Close document panel — new chat has no docs
if (window.documentModule && window.documentModule.isPanelOpen()) {
// Close document panel unless the New Chat flow explicitly carries the
// current document into the new session.
if (!keepDocument && window.documentModule && window.documentModule.isPanelOpen()) {
window.documentModule.closePanel();
}
const docBtn = document.getElementById('overflow-doc-btn');
if (docBtn) {
docBtn.classList.remove('active', 'has-docs');
if (!keepDocument) 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');
if (docInd && !keepDocument) docInd.classList.remove('visible', 'active');
// Clear chat area and show welcome
const box = document.getElementById('chat-history');
@@ -2437,7 +2688,7 @@ export async function deleteCurrentSessionFromTopMenu() {
_removeSessionFromLocalState(sid);
_skipAutoSelect = true;
try {
const pm = await import('./presets.js');
const pm = await import('./presets.js?v=20260908personaname1');
if (pm.removePersistentChat) pm.removePersistentChat(sid);
} catch (e) {}
try {
@@ -2558,6 +2809,26 @@ function _updateResearchDots() {
star.classList.toggle('notify', isCompleted);
if (listItem) listItem.classList.toggle('stream-complete', isCompleted);
if (listItem) {
let state = listItem.querySelector('.session-run-state');
if (isRunning || isCompleted) {
if (!state) {
state = document.createElement('span');
state.className = 'session-run-state';
const menu = listItem.querySelector('.session-menu-btn');
listItem.insertBefore(state, menu || null);
}
const alreadyRunning = state.classList.contains('is-working') && !!state._whirlpool;
const alreadyDone = state.classList.contains('is-done') && state.textContent === 'Done';
if ((isRunning && !alreadyRunning) || (isCompleted && !alreadyDone)) {
_renderSessionRunState(state, isRunning);
}
} else if (state) {
if (state._whirlpool) state._whirlpool.destroy();
state.remove();
}
}
if (isRunning || isCompleted) {
star.style.opacity = '1';
} else {
@@ -2608,6 +2879,7 @@ export function clearResearching(sessionId) {
}
export function markStreaming(sessionId) {
_serverStreamAbsentUntil.delete(sessionId);
_streamingSessions.add(sessionId);
_updateResearchDots();
_updateRailNotifs();
@@ -2630,16 +2902,18 @@ function _clearRunningState(sessionId) {
}
}
export function markStreamComplete(sessionId) {
export function markStreamComplete(sessionId, { force = false } = {}) {
_researchingSessions.delete(sessionId);
_streamingSessions.delete(sessionId);
// Don't pulse if user is already viewing this session — they can see the response
if (currentSessionId === sessionId) {
// A selected chat can still be away in a hidden tab. In that case completion
// must remain visible in the sidebar when the user returns.
if (!force && currentSessionId === sessionId && document.visibilityState === 'visible') {
_updateResearchDots();
_updateRailNotifs();
return;
}
_completedSessions.add(sessionId);
_persistCompletedSessions();
_updateResearchDots();
_updateRailNotifs();
// Show notification dot on Chats section if collapsed
@@ -2692,6 +2966,9 @@ function _updateRailNotifs() {
* and poll until done, then reload the session.
*/
async function _checkServerStream(sessionId) {
if (!sessionId || _serverStreamChecksInFlight.has(sessionId)) return;
if ((_serverStreamAbsentUntil.get(sessionId) || 0) > Date.now()) return;
_serverStreamChecksInFlight.add(sessionId);
try {
// Skip if research is running — it has its own progress UI
if (_researchingSessions.has(sessionId)) return;
@@ -2701,14 +2978,19 @@ async function _checkServerStream(sessionId) {
const res = await fetch(`${API_BASE}/api/chat/stream_status/${sessionId}`);
if (!res.ok) {
if (res.status === 404) {
_serverStreamAbsentUntil.set(sessionId, Date.now() + SERVER_STREAM_ABSENT_TTL_MS);
}
_clearRunningState(sessionId);
return; // 404 = no active stream
}
const info = await res.json();
if (info.status !== 'streaming') {
_serverStreamAbsentUntil.set(sessionId, Date.now() + SERVER_STREAM_ABSENT_TTL_MS);
_clearRunningState(sessionId);
return;
}
_serverStreamAbsentUntil.delete(sessionId);
// Skip if this is a research stream — research has its own progress UI
if (info.mode === 'research' || info.is_research) return;
@@ -2775,11 +3057,14 @@ async function _checkServerStream(sessionId) {
}, 1500);
} catch (_) {
// No stream active — nothing to do
} finally {
_serverStreamChecksInFlight.delete(sessionId);
}
}
export function clearStreamComplete(sessionId) {
_completedSessions.delete(sessionId);
_persistCompletedSessions();
// 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');
@@ -2824,13 +3109,6 @@ function _initDropdownDismiss() {
}
}).observe(_sb, { attributes: true, attributeFilter: ['class'] });
}
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
// Esc must dismiss both the parent dropdown AND the Move-to-folder
// submenu in one keypress — previously only the dropdown closed and
// the submenu was left orphaned on screen.
document.querySelectorAll('.session-dropdown-menu, .session-folder-submenu').forEach(d => d.style.display = 'none');
});
}
// ──────────────────────────────────────────────
@@ -2868,14 +3146,18 @@ function _showDropdown(anchorEl, items) {
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';
if (rect.bottom + 4 + ddRect.height > window.innerHeight) {
dd.style.top = Math.max(4, rect.top - ddRect.height - 4) + 'px';
} else {
dd.style.top = (rect.bottom + 2) + 'px';
dd.style.top = (rect.bottom + 4) + 'px';
}
function close() { dd.remove(); }
// Existing _initDropdownDismiss handles click-away + Escape for .session-dropdown-menu
const close = () => {
unregister();
unregister = () => {};
dd.remove();
};
let unregister = bindMenuDismiss(dd, close, e => dd.contains(e.target) || e.target === anchorEl);
return close;
}
@@ -3276,6 +3558,8 @@ export function openLibrary(defaultTab) {
document.getElementById('lib-select-btn').addEventListener('click', () => {
_lib.selectMode = !_lib.selectMode;
_lib.selected.clear();
document.getElementById('lib-select-btn').classList.toggle('active', _lib.selectMode);
document.getElementById('lib-select-btn').textContent = _lib.selectMode ? 'Cancel' : 'Select';
document.getElementById('lib-bulk-bar').classList.toggle('hidden', !_lib.selectMode);
_renderLibGrid();
});
@@ -3300,6 +3584,8 @@ export function openLibrary(defaultTab) {
}
_lib.selected.clear();
_lib.selectMode = false;
document.getElementById('lib-select-btn').classList.remove('active');
document.getElementById('lib-select-btn').textContent = 'Select';
document.getElementById('lib-bulk-bar').classList.add('hidden');
await loadSessions();
_renderLibGrid();
@@ -3317,6 +3603,8 @@ export function openLibrary(defaultTab) {
}
_lib.selected.clear();
_lib.selectMode = false;
document.getElementById('lib-select-btn').classList.remove('active');
document.getElementById('lib-select-btn').textContent = 'Select';
document.getElementById('lib-bulk-bar').classList.add('hidden');
await loadSessions();
_renderLibGrid();