mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-16 09:45:24 -04:00
Odysseus v1.0
This commit is contained in:
@@ -0,0 +1,573 @@
|
||||
// ============================================
|
||||
// Sidebar Layout — icon rail, hamburger cycling, mobile backdrop & swipe
|
||||
// ============================================
|
||||
|
||||
let _syncRailSideFn = null;
|
||||
|
||||
/**
|
||||
* Get the current syncRailSide function reference.
|
||||
* Needed because it gets patched after initial setup.
|
||||
*/
|
||||
export function syncRailSide() {
|
||||
if (_syncRailSideFn) _syncRailSideFn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize sidebar layout: icon rail, hamburger cycling, mobile backdrop, swipe gestures.
|
||||
* @param {Object} Storage - Storage module
|
||||
* @param {Object} opts
|
||||
* @param {Object} opts.documentModule - Document module (for swapSide)
|
||||
* @param {Function} opts._closeCompareIfActive
|
||||
* @param {Function} opts._deactivateIncognito
|
||||
* @param {Object} opts.presetsModule
|
||||
* @param {Object} opts.sessionModule
|
||||
* @param {Function} opts.el - Element lookup helper
|
||||
* @param {*} opts._defaultChat - Default chat config
|
||||
* @param {Function} opts._syncResearchIndicator
|
||||
*/
|
||||
export function initSidebarLayout(Storage, opts) {
|
||||
const {
|
||||
documentModule, _closeCompareIfActive, _deactivateIncognito,
|
||||
presetsModule, sessionModule, el, _defaultChat, _syncResearchIndicator
|
||||
} = opts;
|
||||
|
||||
// ── Icon rail + sidebar toggle ──
|
||||
const iconRail = document.getElementById('icon-rail');
|
||||
const hamburgerBtn = document.getElementById('hamburger-btn');
|
||||
|
||||
function _syncRailSideCore() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (!iconRail) return;
|
||||
const isRight = sidebar.classList.contains('right-side');
|
||||
const sidebarHidden = sidebar.classList.contains('hidden');
|
||||
const railHidden = iconRail.classList.contains('rail-hidden');
|
||||
const isMobileMini = iconRail.classList.contains('mobile-mini');
|
||||
iconRail.classList.toggle('right-side', isRight);
|
||||
// On mobile mini mode, JS already set inline styles — don't touch
|
||||
if (isMobileMini) {
|
||||
// Just update side positioning
|
||||
if (isRight) {
|
||||
iconRail.style.left = 'auto';
|
||||
iconRail.style.right = '0';
|
||||
} else {
|
||||
iconRail.style.left = '0';
|
||||
iconRail.style.right = 'auto';
|
||||
}
|
||||
} else {
|
||||
iconRail.style.display = (sidebarHidden && !railHidden) ? '' : 'none';
|
||||
}
|
||||
// Hamburger is always visible — just update body classes for CSS layout adjustments
|
||||
if (hamburgerBtn) {
|
||||
document.body.classList.toggle('hamburger-right', isRight);
|
||||
document.body.classList.toggle('hamburger-left', !isRight);
|
||||
document.body.classList.toggle('hamburger-only', sidebarHidden && railHidden);
|
||||
document.body.classList.toggle('sidebar-collapsed', sidebarHidden);
|
||||
}
|
||||
// Keep incognito button clear of hamburger
|
||||
const incogBtn = document.getElementById('incognito-btn');
|
||||
if (incogBtn) {
|
||||
if (isRight && sidebarHidden) {
|
||||
incogBtn.style.right = '48px';
|
||||
} else {
|
||||
incogBtn.style.right = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set initial reference and expose globally
|
||||
_syncRailSideFn = _syncRailSideCore;
|
||||
window.syncRailSide = syncRailSide;
|
||||
|
||||
// Restore sidebar side preference
|
||||
if (Storage.get(Storage.KEYS.SIDEBAR_SIDE) === 'right') {
|
||||
document.getElementById('sidebar').classList.add('right-side');
|
||||
}
|
||||
syncRailSide();
|
||||
|
||||
// In-sidebar toggle button — same behavior as hamburger
|
||||
const sidebarToggleBtn = document.getElementById('sidebar-toggle-btn');
|
||||
if (sidebarToggleBtn) {
|
||||
sidebarToggleBtn.addEventListener('click', (e) => {
|
||||
if (hamburgerBtn) hamburgerBtn.click();
|
||||
});
|
||||
}
|
||||
|
||||
// New chat buttons — same as clicking brand
|
||||
const chatNewBtn = document.getElementById('chat-new-btn');
|
||||
const sidebarNewChat = document.getElementById('sidebar-new-chat-btn');
|
||||
[chatNewBtn, sidebarNewChat].forEach(btn => {
|
||||
if (btn) btn.addEventListener('click', () => {
|
||||
const brandBtn = document.getElementById('sidebar-brand-btn');
|
||||
if (brandBtn) brandBtn.click();
|
||||
});
|
||||
});
|
||||
|
||||
// Hamburger cycles: full sidebar → mini → off → full
|
||||
// Shift-click swaps sidebar side
|
||||
let _userToggledSidebar = false;
|
||||
let _wasAutoCollapsed = false;
|
||||
|
||||
// Deliberate "open the sidebar" used by the mobile swipe gesture (wired at
|
||||
// module scope). It MUST set _userToggledSidebar so the auto-collapse
|
||||
// MutationObserver doesn't immediately re-hide it (the swipe was opening it,
|
||||
// then checkSidebarAutoCollapse re-added .hidden because this flag was unset
|
||||
// — looked like nothing happened). Mirrors the hamburger's mobile-open path.
|
||||
window._odyOpenSidebar = function(side) {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (!sidebar) return;
|
||||
// On mobile, never open the sidebar while Compare is running — the panes
|
||||
// own the screen and stray gestures (swipe, dragging a dock chip to the X)
|
||||
// were popping it open. Blocking the open helper covers every path.
|
||||
const cc = document.getElementById('chat-container');
|
||||
if (window.innerWidth < 768 && cc && cc.classList.contains('compare-active')) return;
|
||||
_userToggledSidebar = true;
|
||||
// Optionally place the sidebar on a specific edge (the swipe gesture passes
|
||||
// the direction). Persist it + re-anchor the doc panel, same as a
|
||||
// shift-click on the hamburger.
|
||||
if (side === 'left' || side === 'right') {
|
||||
const wantRight = side === 'right';
|
||||
if (sidebar.classList.contains('right-side') !== wantRight) {
|
||||
sidebar.classList.toggle('right-side', wantRight);
|
||||
try { Storage.set(Storage.KEYS.SIDEBAR_SIDE, side); } catch (_) {}
|
||||
if (documentModule && documentModule.swapSide) { try { documentModule.swapSide(); } catch (_) {} }
|
||||
}
|
||||
}
|
||||
const backdrop = document.getElementById('sidebar-backdrop');
|
||||
if (window.innerWidth < 768 && iconRail) { iconRail.classList.remove('mobile-mini'); iconRail.style.cssText = ''; }
|
||||
sidebar.classList.remove('hidden');
|
||||
if (backdrop && window.innerWidth < 768) backdrop.classList.add('visible');
|
||||
syncRailSide();
|
||||
};
|
||||
|
||||
if (hamburgerBtn) {
|
||||
hamburgerBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (e.shiftKey) {
|
||||
sidebar.classList.toggle('right-side');
|
||||
Storage.set(Storage.KEYS.SIDEBAR_SIDE, sidebar.classList.contains('right-side') ? 'right' : 'left');
|
||||
syncRailSide();
|
||||
if (documentModule && documentModule.swapSide) documentModule.swapSide();
|
||||
return;
|
||||
}
|
||||
|
||||
_userToggledSidebar = true;
|
||||
const isSidebarVisible = !sidebar.classList.contains('hidden');
|
||||
|
||||
if (window.innerWidth < 768) {
|
||||
// Mobile: full sidebar ↔ hidden — simple toggle, no mini rail
|
||||
const backdrop = document.getElementById('sidebar-backdrop');
|
||||
if (iconRail) { iconRail.classList.remove('mobile-mini'); iconRail.style.cssText = ''; }
|
||||
|
||||
if (isSidebarVisible) {
|
||||
// Closing sidebar
|
||||
sidebar.classList.add('hidden');
|
||||
if (backdrop) backdrop.classList.remove('visible');
|
||||
} else {
|
||||
// Mobile: the hamburger always opens the sidebar from the RIGHT.
|
||||
// (Not persisted — keeps the desktop side preference untouched.)
|
||||
if (!sidebar.classList.contains('right-side')) {
|
||||
sidebar.classList.add('right-side');
|
||||
if (documentModule && documentModule.swapSide) { try { documentModule.swapSide(); } catch (_) {} }
|
||||
}
|
||||
// Opening sidebar — blur keyboard first, then open after layout settles
|
||||
if (document.activeElement && document.activeElement !== document.body
|
||||
&& (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) {
|
||||
document.activeElement.blur();
|
||||
// Wait for keyboard dismiss to settle, then open
|
||||
setTimeout(() => {
|
||||
sidebar.classList.remove('hidden');
|
||||
if (backdrop) backdrop.classList.add('visible');
|
||||
syncRailSide();
|
||||
}, 250);
|
||||
} else {
|
||||
sidebar.classList.remove('hidden');
|
||||
if (backdrop) backdrop.classList.add('visible');
|
||||
}
|
||||
}
|
||||
syncRailSide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Desktop: full sidebar ↔ mini (icon rail) — simple toggle
|
||||
if (isSidebarVisible) {
|
||||
sidebar.classList.add('hidden');
|
||||
} else {
|
||||
_wasAutoCollapsed = false;
|
||||
iconRail.classList.remove('rail-hidden');
|
||||
sidebar.classList.remove('hidden');
|
||||
}
|
||||
syncRailSide();
|
||||
});
|
||||
}
|
||||
|
||||
// Icon rail section clicks — open sidebar and scroll to section
|
||||
if (iconRail) {
|
||||
iconRail.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.icon-rail-btn');
|
||||
if (!btn || btn.id === 'rail-new-session' || btn.id === 'rail-delete-session' || btn.id === 'rail-search-btn' || btn.id === 'rail-settings' || btn.id === 'rail-admin') return;
|
||||
const sectionId = btn.dataset.section;
|
||||
if (!sectionId) return;
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
sidebar.classList.remove('hidden');
|
||||
syncRailSide();
|
||||
const section = document.getElementById(sectionId);
|
||||
if (section) {
|
||||
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
section.classList.remove('collapsed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-collapse sidebar when window gets small or chat area is squeezed
|
||||
const AUTO_COLLAPSE_WIDTH = 700;
|
||||
const MIN_CHAT_WIDTH = 380; // collapse sidebar if chat gets narrower than this
|
||||
|
||||
function checkSidebarAutoCollapse() {
|
||||
if (_userToggledSidebar) return;
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (!sidebar) return;
|
||||
const isHidden = sidebar.classList.contains('hidden');
|
||||
|
||||
// Check if chat area is too narrow (e.g. sidebar + doc panel both open).
|
||||
// BUT — if a tile-snapped modal exists, IT is what's making chat narrow,
|
||||
// and that's the user's explicit choice. Don't auto-collapse the sidebar
|
||||
// in response, or we get a reactive loop: snap → narrow chat → hide
|
||||
// sidebar → safe-rect changes → reclamp modal → new chat width → ...
|
||||
const chatContainer = document.querySelector('.chat-container');
|
||||
const hasTileSnapped = document.querySelector('.modal-content[data-_tile-zone], .research-pane[data-_tile-zone]');
|
||||
const chatTooNarrow = chatContainer && chatContainer.offsetWidth < MIN_CHAT_WIDTH && !isHidden && !hasTileSnapped;
|
||||
|
||||
if ((window.innerWidth < AUTO_COLLAPSE_WIDTH || chatTooNarrow) && !isHidden) {
|
||||
sidebar.classList.add('hidden');
|
||||
_wasAutoCollapsed = true;
|
||||
syncRailSide();
|
||||
} else if (window.innerWidth >= AUTO_COLLAPSE_WIDTH && isHidden && _wasAutoCollapsed) {
|
||||
// Only restore if chat won't be too narrow
|
||||
sidebar.classList.remove('hidden');
|
||||
void document.body.offsetWidth; // reflow
|
||||
if (chatContainer && chatContainer.offsetWidth < MIN_CHAT_WIDTH) {
|
||||
sidebar.classList.add('hidden');
|
||||
} else {
|
||||
_wasAutoCollapsed = false;
|
||||
}
|
||||
syncRailSide();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
_userToggledSidebar = false; // allow auto-collapse on actual resize
|
||||
requestAnimationFrame(checkSidebarAutoCollapse);
|
||||
});
|
||||
// Also re-check when doc panel toggles
|
||||
new MutationObserver(() => requestAnimationFrame(checkSidebarAutoCollapse))
|
||||
.observe(document.body, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
// Auto-collapse on initial load if window is small
|
||||
if (window.innerWidth < AUTO_COLLAPSE_WIDTH) {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (sidebar && !sidebar.classList.contains('hidden')) {
|
||||
sidebar.classList.add('hidden');
|
||||
_wasAutoCollapsed = true;
|
||||
syncRailSide();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mobile sidebar backdrop + swipe-to-close ──
|
||||
// Backdrop overlay: tapping it closes the sidebar
|
||||
const mobileBackdrop = document.createElement('div');
|
||||
mobileBackdrop.id = 'sidebar-backdrop';
|
||||
document.body.appendChild(mobileBackdrop);
|
||||
|
||||
function updateMobileBackdrop() {
|
||||
if (window.innerWidth >= 768) { mobileBackdrop.classList.remove('visible'); return; }
|
||||
const sb = document.getElementById('sidebar');
|
||||
const rail = document.getElementById('icon-rail');
|
||||
const sidebarOpen = sb && !sb.classList.contains('hidden');
|
||||
const miniOpen = rail && rail.classList.contains('mobile-mini');
|
||||
mobileBackdrop.classList.toggle('visible', sidebarOpen || miniOpen);
|
||||
}
|
||||
|
||||
// Suppress sidebar close briefly after dropdown actions
|
||||
window._suppressSidebarClose = false;
|
||||
mobileBackdrop.addEventListener('click', (e) => {
|
||||
if (window._suppressSidebarClose) return;
|
||||
// Don't close while a session is being renamed inline — the rename input
|
||||
// lives inside the sidebar, and a backdrop tap (e.g. to dismiss the
|
||||
// keyboard) would otherwise kick the user out mid-rename.
|
||||
if (document.querySelector('.session-rename-input')) return;
|
||||
// Don't close if a dropdown or submenu is visible
|
||||
const openDD = document.querySelector('.session-dropdown-menu[style*="display: block"], .session-dropdown-menu[style*="display:block"]');
|
||||
const openSub = document.querySelector('.session-folder-submenu[style*="display: block"], .session-folder-submenu[style*="display:block"]');
|
||||
if (openDD || openSub) {
|
||||
if (openSub) openSub.style.display = 'none';
|
||||
if (openDD) openDD.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const sb = document.getElementById('sidebar');
|
||||
if (sb && !sb.classList.contains('hidden')) {
|
||||
sb.classList.add('hidden');
|
||||
}
|
||||
mobileBackdrop.classList.remove('visible');
|
||||
syncRailSide();
|
||||
});
|
||||
|
||||
// Patch syncRailSide to also update backdrop
|
||||
const _origSyncRailSideCore = _syncRailSideCore;
|
||||
_syncRailSideFn = function() { _origSyncRailSideCore(); updateMobileBackdrop(); };
|
||||
window.syncRailSide = syncRailSide;
|
||||
|
||||
// Swipe sidebar toward edge to close
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (sidebar && 'ontouchstart' in window) {
|
||||
let _swStartX = 0, _swStartY = 0, _swSwiping = false;
|
||||
sidebar.addEventListener('touchstart', (e) => {
|
||||
if (e.target.closest('.list-item')) { _swSwiping = false; return; }
|
||||
_swStartX = e.touches[0].clientX;
|
||||
_swStartY = e.touches[0].clientY;
|
||||
_swSwiping = true;
|
||||
}, { passive: true });
|
||||
sidebar.addEventListener('touchmove', (e) => {
|
||||
if (!_swSwiping) return;
|
||||
const dx = e.touches[0].clientX - _swStartX;
|
||||
const dy = Math.abs(e.touches[0].clientY - _swStartY);
|
||||
if (dy > 40) { _swSwiping = false; return; }
|
||||
const isRight = sidebar.classList.contains('right-side');
|
||||
if ((!isRight && dx < -60) || (isRight && dx > 60)) {
|
||||
_swSwiping = false;
|
||||
const _backdrop = document.getElementById('sidebar-backdrop');
|
||||
if (_backdrop) _backdrop.classList.remove('visible');
|
||||
sidebar.classList.add('hidden');
|
||||
syncRailSide();
|
||||
}
|
||||
}, { passive: true });
|
||||
sidebar.addEventListener('touchend', () => { _swSwiping = false; }, { passive: true });
|
||||
}
|
||||
|
||||
// ── Click outside sidebar / icon rail to close (mobile only) ──
|
||||
document.addEventListener('click', (e) => {
|
||||
if (window.innerWidth >= 700) return; // desktop keeps sidebar open
|
||||
const sb = document.getElementById('sidebar');
|
||||
const rail = document.getElementById('icon-rail');
|
||||
// Ignore clicks on elements removed from DOM (e.g. session list re-render during folder toggle)
|
||||
if (!e.target.isConnected) return;
|
||||
// Ignore clicks on the sidebar, icon rail, or hamburger button itself
|
||||
if (e.target.closest('#sidebar') || e.target.closest('#icon-rail') || e.target.closest('#hamburger-btn')) return;
|
||||
// Ignore clicks inside modals or the chat input area
|
||||
if (e.target.closest('.modal') || e.target.closest('.input-bar') || e.target.closest('#message')) return;
|
||||
// Ignore clicks on session/folder dropdowns and the styled prompt
|
||||
// overlay — they're body-level elements logically tied to a sidebar
|
||||
// action (e.g. "Move to folder → New Folder…"), so closing the
|
||||
// sidebar when the user clicks one yanks the action mid-flight.
|
||||
if (e.target.closest('.session-dropdown, .folder-submenu, #styled-prompt-overlay, #styled-confirm-overlay')) return;
|
||||
// Close full sidebar if open (with animation)
|
||||
if (sb && !sb.classList.contains('hidden')) {
|
||||
const backdrop = document.getElementById('sidebar-backdrop');
|
||||
if (backdrop) backdrop.classList.remove('visible');
|
||||
sb.classList.add('hidden');
|
||||
syncRailSide();
|
||||
return;
|
||||
}
|
||||
// Close mobile-mini icon rail overlay if open
|
||||
if (rail && rail.classList.contains('mobile-mini')) {
|
||||
rail.classList.remove('mobile-mini');
|
||||
rail.style.cssText = '';
|
||||
const backdrop = document.getElementById('sidebar-backdrop');
|
||||
if (backdrop) backdrop.classList.remove('visible');
|
||||
syncRailSide();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mobile: close sidebar/rail when a tool button is tapped ──
|
||||
// The user expects the sidebar to get out of the way the moment a tool
|
||||
// window opens — otherwise the modal lands behind the sidebar on phones.
|
||||
// We remember whether the sidebar was open at the moment the tool was
|
||||
// tapped so we can re-open it when the tool's modal is dismissed; that
|
||||
// way clicking around the app doesn't leave the sidebar permanently
|
||||
// shut.
|
||||
let _sidebarWasOpenBeforeTool = false;
|
||||
let _railWasOpenBeforeTool = false;
|
||||
document.addEventListener('click', (e) => {
|
||||
if (window.innerWidth >= 700) return;
|
||||
const btn = e.target.closest('[id^="tool-"], [id^="rail-"]');
|
||||
if (!btn) return;
|
||||
setTimeout(() => {
|
||||
const sb = document.getElementById('sidebar');
|
||||
const rail = document.getElementById('icon-rail');
|
||||
const backdrop = document.getElementById('sidebar-backdrop');
|
||||
let changed = false;
|
||||
if (sb && !sb.classList.contains('hidden')) {
|
||||
_sidebarWasOpenBeforeTool = true;
|
||||
sb.classList.add('hidden');
|
||||
changed = true;
|
||||
}
|
||||
if (rail && rail.classList.contains('mobile-mini')) {
|
||||
_railWasOpenBeforeTool = true;
|
||||
rail.classList.remove('mobile-mini');
|
||||
rail.style.cssText = '';
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
if (backdrop) backdrop.classList.remove('visible');
|
||||
syncRailSide();
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// When a tool is dismissed by swiping it down (ui.js fires `modal-dismissed`),
|
||||
// don't bounce the sidebar back open — the swipe should just dismiss the tool.
|
||||
// Button-close still restores the prior sidebar state (no event fired there).
|
||||
window.addEventListener('modal-dismissed', () => {
|
||||
_sidebarWasOpenBeforeTool = false;
|
||||
_railWasOpenBeforeTool = false;
|
||||
});
|
||||
|
||||
// ── Mobile: when a tool modal closes, restore the sidebar/rail to
|
||||
// whatever state it was in before the tool was opened. ──
|
||||
// We watch every .modal for the .hidden class going on, and if our
|
||||
// remembered "sidebar-was-open" flag is set, undo the auto-close.
|
||||
if (window.innerWidth < 700) {
|
||||
const _restoreSidebar = () => {
|
||||
const sb = document.getElementById('sidebar');
|
||||
const rail = document.getElementById('icon-rail');
|
||||
const backdrop = document.getElementById('sidebar-backdrop');
|
||||
// Skip if any modal is still visible (.modal without .hidden) — we only
|
||||
// restore once the user is back to bare chat. A tool swiped DOWN to a
|
||||
// dock chip is minimized (display:none via .modal-minimized), not closed
|
||||
// — it's still "around", so don't bounce the sidebar open behind it. Only
|
||||
// a full close (no minimized modal, no dock chips) should restore.
|
||||
const anyOpen = [...document.querySelectorAll('.modal')]
|
||||
.some(m => (!m.classList.contains('hidden') && getComputedStyle(m).display !== 'none')
|
||||
|| m.classList.contains('modal-minimized'));
|
||||
const anyDocked = document.querySelectorAll('.minimized-dock-chip').length > 0;
|
||||
if (anyOpen || anyDocked) {
|
||||
// A tool is still minimized/docked. The user has left the "launched
|
||||
// from the sidebar" context — drop the restore intent so that later
|
||||
// FULLY closing the tool (e.g. dragging its chip to the trash) doesn't
|
||||
// bounce the sidebar open. (The modal-dismissed listener that normally
|
||||
// clears these gets blocked by modalManager's stopImmediatePropagation.)
|
||||
_sidebarWasOpenBeforeTool = false;
|
||||
_railWasOpenBeforeTool = false;
|
||||
return;
|
||||
}
|
||||
if (_sidebarWasOpenBeforeTool && sb && sb.classList.contains('hidden')) {
|
||||
sb.classList.remove('hidden');
|
||||
if (backdrop) backdrop.classList.add('visible');
|
||||
}
|
||||
if (_railWasOpenBeforeTool && rail && !rail.classList.contains('mobile-mini')) {
|
||||
rail.classList.add('mobile-mini');
|
||||
}
|
||||
_sidebarWasOpenBeforeTool = false;
|
||||
_railWasOpenBeforeTool = false;
|
||||
if (_sidebarWasOpenBeforeTool || _railWasOpenBeforeTool) syncRailSide();
|
||||
};
|
||||
const _modalObs = new MutationObserver((muts) => {
|
||||
let triggered = false;
|
||||
for (const m of muts) {
|
||||
if (m.type !== 'attributes' || m.attributeName !== 'class') continue;
|
||||
const t = m.target;
|
||||
if (!(t instanceof HTMLElement) || !t.classList) continue;
|
||||
if (t.classList.contains('modal')) { triggered = true; break; }
|
||||
}
|
||||
if (triggered) setTimeout(_restoreSidebar, 50);
|
||||
});
|
||||
_modalObs.observe(document.body, { subtree: true, attributes: true, attributeFilter: ['class'] });
|
||||
}
|
||||
|
||||
// (Mobile swipe-to-open-sidebar is wired at MODULE scope — see
|
||||
// _initChatSwipeToOpenSidebar() at the bottom of this file — so it attaches
|
||||
// independently of this init function completing.)
|
||||
}
|
||||
|
||||
// ── Mobile: swipe horizontally on the splash/chat to open the sidebar ──
|
||||
// Wired at MODULE scope (not inside initSidebarLayout) so a throw anywhere in
|
||||
// that init can't drop this listener. Bound on `document` so it catches the
|
||||
// touch regardless of which child element is under the finger. touchmove is
|
||||
// NON-passive and calls preventDefault() once the gesture is locked
|
||||
// horizontal — without that, Firefox (and others) treat the horizontal swipe
|
||||
// as their own scroll/navigation gesture and our handler never gets to act.
|
||||
function _initChatSwipeToOpenSidebar() {
|
||||
if (window.__odySwipeWired) return;
|
||||
window.__odySwipeWired = true;
|
||||
|
||||
// Areas where a horizontal drag means something else (their own scroll/drag).
|
||||
const EXCLUDE = [
|
||||
'#sidebar', '#icon-rail', '.modal', '.input-bar', '#message',
|
||||
'#minimized-dock', '.minimized-dock-chip', '#dock-trash-zone',
|
||||
'pre', 'table', '.agent-tool-output', '.agent-thread-cmd',
|
||||
'input', 'textarea', 'select',
|
||||
].join(', ');
|
||||
|
||||
let sx = 0, sy = 0, track = false, decided = false;
|
||||
|
||||
const reset = () => { track = false; decided = false; };
|
||||
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
reset();
|
||||
if (window.innerWidth >= 768) return;
|
||||
if (!e.touches || e.touches.length !== 1) return;
|
||||
if (window._chipDragging) return;
|
||||
const sb = document.getElementById('sidebar');
|
||||
if (sb && !sb.classList.contains('hidden')) return; // already open
|
||||
// Only in the chat / empty-chat view. Not when a document or PDF is open
|
||||
// (body.doc-view), notes is open (body.notes-view), or a tool modal is up.
|
||||
if (document.body.classList.contains('doc-view') ||
|
||||
document.body.classList.contains('notes-view')) return;
|
||||
// Not while Compare is running — it takes over #chat-container with its own
|
||||
// panes/scroll, and the swipe-to-open-sidebar gesture gets in the way there.
|
||||
const cc = document.getElementById('chat-container');
|
||||
if (cc && cc.classList.contains('compare-active')) return;
|
||||
const anyModalOpen = [...document.querySelectorAll('.modal')].some(
|
||||
m => !m.classList.contains('hidden') && getComputedStyle(m).display !== 'none');
|
||||
if (anyModalOpen) return;
|
||||
const t = e.target;
|
||||
if (t && t.closest && t.closest(EXCLUDE)) return;
|
||||
// The gesture must start within the chat area itself.
|
||||
if (!(t && t.closest && t.closest('#chat-container'))) return;
|
||||
sx = e.touches[0].clientX;
|
||||
sy = e.touches[0].clientY;
|
||||
track = true;
|
||||
}, { passive: true, capture: true });
|
||||
|
||||
document.addEventListener('touchmove', (e) => {
|
||||
if (!track) return;
|
||||
if (window._chipDragging) { track = false; return; }
|
||||
if (!e.touches || !e.touches.length) return;
|
||||
const dx = e.touches[0].clientX - sx;
|
||||
const dy = e.touches[0].clientY - sy;
|
||||
const adx = Math.abs(dx), ady = Math.abs(dy);
|
||||
if (!decided) {
|
||||
if (adx < 10 && ady < 10) return; // not enough travel to judge
|
||||
if (ady > adx) { track = false; return; } // vertical-dominant → let it scroll
|
||||
decided = true; // locked into a horizontal swipe
|
||||
}
|
||||
// Claim the gesture from the browser so it doesn't scroll/navigate instead.
|
||||
if (e.cancelable) e.preventDefault();
|
||||
if (adx >= 40) {
|
||||
track = false;
|
||||
// Direction picks the side (per user preference): swipe LEFT → sidebar
|
||||
// on the left, swipe RIGHT → sidebar on the right. dx<0 is a leftward
|
||||
// finger motion; mapping it to 'right' (and dx>0 to 'left') is what makes
|
||||
// it feel correct in practice.
|
||||
const side = dx < 0 ? 'right' : 'left';
|
||||
// Use the deliberate-open helper (sets _userToggledSidebar so the
|
||||
// auto-collapse observer doesn't instantly re-hide it). Fall back to a
|
||||
// plain unhide if the helper isn't wired yet.
|
||||
if (typeof window._odyOpenSidebar === 'function') {
|
||||
window._odyOpenSidebar(side);
|
||||
} else {
|
||||
const sb = document.getElementById('sidebar');
|
||||
if (sb) { sb.classList.remove('hidden'); try { syncRailSide(); } catch (_) {} }
|
||||
}
|
||||
}
|
||||
}, { passive: false, capture: true });
|
||||
|
||||
document.addEventListener('touchend', reset, { passive: true, capture: true });
|
||||
document.addEventListener('touchcancel', reset, { passive: true, capture: true });
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', _initChatSwipeToOpenSidebar);
|
||||
} else {
|
||||
_initChatSwipeToOpenSidebar();
|
||||
}
|
||||
Reference in New Issue
Block a user