574 lines
26 KiB
JavaScript
574 lines
26 KiB
JavaScript
// ============================================
|
|
// 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();
|
|
}
|