1// ============================================ 2// Sidebar Layout â icon rail, hamburger cycling, mobile backdrop & swipe 3// ============================================ 4 5let _syncRailSideFn = null; 6 7/** 8 * Get the current syncRailSide function reference. 9 * Needed because it gets patched after initial setup. 10 */ 11export function syncRailSide() { 12 if (_syncRailSideFn) _syncRailSideFn(); 13} 14 15/** 16 * Initialize sidebar layout: icon rail, hamburger cycling, mobile backdrop, swipe gestures. 17 * @param {Object} Storage - Storage module 18 * @param {Object} opts 19 * @param {Object} opts.documentModule - Document module (for swapSide) 20 * @param {Function} opts._closeCompareIfActive 21 * @param {Function} opts._deactivateIncognito 22 * @param {Object} opts.presetsModule 23 * @param {Object} opts.sessionModule 24 * @param {Function} opts.el - Element lookup helper 25 * @param {*} opts._defaultChat - Default chat config 26 * @param {Function} opts._syncResearchIndicator 27 */ 28export function initSidebarLayout(Storage, opts) { 29 const { 30 documentModule, _closeCompareIfActive, _deactivateIncognito, 31 presetsModule, sessionModule, el, _defaultChat, _syncResearchIndicator 32 } = opts; 33 34 // ââ Icon rail + sidebar toggle ââ 35 const iconRail = document.getElementById('icon-rail'); 36 const hamburgerBtn = document.getElementById('hamburger-btn'); 37 38 function _syncRailSideCore() { 39 const sidebar = document.getElementById('sidebar'); 40 if (!iconRail) return; 41 const isRight = sidebar.classList.contains('right-side'); 42 const sidebarHidden = sidebar.classList.contains('hidden'); 43 const railHidden = iconRail.classList.contains('rail-hidden'); 44 const isMobileMini = iconRail.classList.contains('mobile-mini'); 45 iconRail.classList.toggle('right-side', isRight); 46 // On mobile mini mode, JS already set inline styles â don't touch 47 if (isMobileMini) { 48 // Just update side positioning 49 if (isRight) { 50 iconRail.style.left = 'auto'; 51 iconRail.style.right = '0'; 52 } else { 53 iconRail.style.left = '0'; 54 iconRail.style.right = 'auto'; 55 } 56 } else { 57 iconRail.style.display = (sidebarHidden && !railHidden) ? '' : 'none'; 58 } 59 // Hamburger is always visible â just update body classes for CSS layout adjustments 60 if (hamburgerBtn) { 61 document.body.classList.toggle('hamburger-right', isRight); 62 document.body.classList.toggle('hamburger-left', !isRight); 63 document.body.classList.toggle('hamburger-only', sidebarHidden && railHidden); 64 document.body.classList.toggle('sidebar-collapsed', sidebarHidden); 65 } 66 // Keep incognito button clear of hamburger 67 const incogBtn = document.getElementById('incognito-btn'); 68 if (incogBtn) { 69 if (isRight && sidebarHidden) { 70 incogBtn.style.right = '48px'; 71 incogBtn.style.left = ''; 72 } else if (!isRight && sidebarHidden) { 73 incogBtn.style.left = '48px'; 74 incogBtn.style.right = ''; 75 } else { 76 incogBtn.style.left = ''; 77 incogBtn.style.right = ''; 78 } 79 } 80 } 81 82 // Set initial reference and expose globally 83 _syncRailSideFn = _syncRailSideCore; 84 window.syncRailSide = syncRailSide; 85 86 // Restore sidebar side preference â default to LEFT side 87 // Clear any stale 'right' preference that was set by the old default 88 const savedSide = Storage.get(Storage.KEYS.SIDEBAR_SIDE); 89 if (savedSide === 'right') { 90 document.getElementById('sidebar').classList.add('right-side'); 91 } else { 92 // Ensure no right-side class and persist 'left' as the default 93 document.getElementById('sidebar').classList.remove('right-side'); 94 try { Storage.set(Storage.KEYS.SIDEBAR_SIDE, 'left'); } catch (_) {} 95 } 96 syncRailSide(); 97 98 // In-sidebar toggle button â same behavior as hamburger 99 const sidebarToggleBtn = document.getElementById('sidebar-toggle-btn'); 100 if (sidebarToggleBtn) { 101 sidebarToggleBtn.addEventListener('click', (e) => { 102 if (hamburgerBtn) hamburgerBtn.click(); 103 }); 104 } 105 106 // New chat buttons â same as clicking brand 107 const chatNewBtn = document.getElementById('chat-new-btn'); 108 const sidebarNewChat = document.getElementById('sidebar-new-chat-btn'); 109 [chatNewBtn, sidebarNewChat].forEach(btn => { 110 if (btn) btn.addEventListener('click', () => { 111 const brandBtn = document.getElementById('sidebar-brand-btn'); 112 if (brandBtn) brandBtn.click(); 113 }); 114 }); 115 116 // Hamburger cycles: full sidebar â mini â off â full 117 // Shift-click swaps sidebar side 118 let _userToggledSidebar = false;
119 let _wasAutoCollapsed = false; 120 121 // Deliberate "open the sidebar" used by the mobile swipe gesture (wired at 122 // module scope). It MUST set _userToggledSidebar so the auto-collapse 123 // MutationObserver doesn't immediately re-hide it (the swipe was opening it, 124 // then checkSidebarAutoCollapse re-added .hidden because this flag was unset 125 // â looked like nothing happened). Mirrors the hamburger's mobile-open path. 126 window._odyOpenSidebar = function(side) { 127 const sidebar = document.getElementById('sidebar'); 128 if (!sidebar) return; 129 // On mobile, never open the sidebar while Compare is running â the panes 130 // own the screen and stray gestures (swipe, dragging a dock chip to the X) 131 // were popping it open. Blocking the open helper covers every path. 132 const cc = document.getElementById('chat-container'); 133 if (window.innerWidth < 768 && cc && cc.classList.contains('compare-active')) return; 134 _userToggledSidebar = true; 135 // Optionally place the sidebar on a specific edge (the swipe gesture passes 136 // the direction). Persist it + re-anchor the doc panel, same as a 137 // shift-click on the hamburger. 138 if (side === 'left' || side === 'right') { 139 const wantRight = side === 'right'; 140 if (sidebar.classList.contains('right-side') !== wantRight) { 141 sidebar.classList.toggle('right-side', wantRight); 142 try { Storage.set(Storage.KEYS.SIDEBAR_SIDE, side); } catch (_) {} 143 if (documentModule && documentModule.swapSide) { try { documentModule.swapSide(); } catch (_) {} } 144 } 145 } 146 const backdrop = document.getElementById('sidebar-backdrop'); 147 if (window.innerWidth < 768 && iconRail) { iconRail.classList.remove('mobile-mini'); iconRail.style.cssText = ''; } 148 sidebar.classList.remove('hidden'); 149 if (backdrop && window.innerWidth < 768) backdrop.classList.add('visible'); 150 syncRailSide(); 151 }; 152 153 if (hamburgerBtn) { 154 hamburgerBtn.addEventListener('click', (e) => { 155 e.stopPropagation(); 156 const sidebar = document.getElementById('sidebar'); 157 if (e.shiftKey) { 158 sidebar.classList.toggle('right-side'); 159 Storage.set(Storage.KEYS.SIDEBAR_SIDE, sidebar.classList.contains('right-side') ? 'right' : 'left'); 160 syncRailSide(); 161 if (documentModule && documentModule.swapSide) documentModule.swapSide(); 162 return; 163 } 164 165 _userToggledSidebar = true; 166 const isSidebarVisible = !sidebar.classList.contains('hidden'); 167 168 if (window.innerWidth < 768) { 169 // Mobile: full sidebar â hidden â simple toggle, no mini rail 170 const backdrop = document.getElementById('sidebar-backdrop'); 171 if (iconRail) { iconRail.classList.remove('mobile-mini'); iconRail.style.cssText = ''; } 172 173 if (isSidebarVisible) { 174 // Closing sidebar 175 sidebar.classList.add('hidden'); 176 if (backdrop) backdrop.classList.remove('visible'); 177 } else { 178 // Mobile: the hamburger opens the sidebar from the LEFT by default. 179 // If user saved a preference for right, respect that. 180 // Shift+click on hamburger toggles the side. 181 if (sidebar.classList.contains('right-side')) { 182 sidebar.classList.remove('right-side'); 183 if (documentModule && documentModule.swapSide) { try { documentModule.swapSide(); } catch (_) {} } 184 } 185 // Opening sidebar â blur keyboard first, then open after layout settles 186 if (document.activeElement && document.activeElement !== document.body 187 && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) { 188 document.activeElement.blur(); 189 // Wait for keyboard dismiss to settle, then open 190 setTimeout(() => { 191 sidebar.classList.remove('hidden'); 192 if (backdrop) backdrop.classList.add('visible'); 193 syncRailSide(); 194 }, 250); 195 } else { 196 sidebar.classList.remove('hidden'); 197 if (backdrop) backdrop.classList.add('visible'); 198 } 199 } 200 syncRailSide(); 201 return; 202 } 203 204 // Desktop: full sidebar â mini (icon rail) â simple toggle 205 if (isSidebarVisible) { 206 sidebar.classList.add('hidden'); 207 } else { 208 _wasAutoCollapsed = false;
209 iconRail.classList.remove('rail-hidden'); 210 sidebar.classList.remove('hidden'); 211 } 212 syncRailSide(); 213 }); 214 } 215 216 // Icon rail section clicks â open sidebar and scroll to section 217 if (iconRail) { 218 iconRail.addEventListener('click', (e) => { 219 const btn = e.target.closest('.icon-rail-btn'); 220 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; 221 const sectionId = btn.dataset.section; 222 if (!sectionId) return; 223 const sidebar = document.getElementById('sidebar'); 224 sidebar.classList.remove('hidden'); 225 syncRailSide(); 226 const section = document.getElementById(sectionId); 227 if (section) { 228 section.scrollIntoView({ behavior: 'smooth', block: 'start' }); 229 section.classList.remove('collapsed'); 230 } 231 }); 232 } 233 234 // Auto-collapse sidebar when window gets small or chat area is squeezed 235 const AUTO_COLLAPSE_WIDTH = 700; 236 const MIN_CHAT_WIDTH = 380; // collapse sidebar if chat gets narrower than this 237 238 function checkSidebarAutoCollapse() { 239 if (_userToggledSidebar) return; 240 const sidebar = document.getElementById('sidebar'); 241 if (!sidebar) return; 242 const isHidden = sidebar.classList.contains('hidden'); 243 244 // Check if chat area is too narrow (e.g. sidebar + doc panel both open). 245 // BUT â if a tile-snapped modal exists, IT is what's making chat narrow, 246 // and that's the user's explicit choice. Don't auto-collapse the sidebar 247 // in response, or we get a reactive loop: snap â narrow chat â hide 248 // sidebar â safe-rect changes â reclamp modal â new chat width â ... 249 const chatContainer = document.querySelector('.chat-container'); 250 const hasTileSnapped = document.querySelector('.modal-content[data-_tile-zone], .research-pane[data-_tile-zone]'); 251 const chatTooNarrow = chatContainer && chatContainer.offsetWidth < MIN_CHAT_WIDTH && !isHidden && !hasTileSnapped; 252 253 if ((window.innerWidth < AUTO_COLLAPSE_WIDTH || chatTooNarrow) && !isHidden) { 254 sidebar.classList.add('hidden'); 255 _wasAutoCollapsed = true; 256 syncRailSide(); 257 } else if (window.innerWidth >= AUTO_COLLAPSE_WIDTH && isHidden && _wasAutoCollapsed) { 258 // Only restore if chat won't be too narrow 259 sidebar.classList.remove('hidden'); 260 void document.body.offsetWidth; // reflow 261 if (chatContainer && chatContainer.offsetWidth < MIN_CHAT_WIDTH) { 262 sidebar.classList.add('hidden'); 263 } else { 264 _wasAutoCollapsed = false; 265 } 266 syncRailSide(); 267 } 268 } 269 270 window.addEventListener('resize', () => { 271 _userToggledSidebar = false; // allow auto-collapse on actual resize 272 requestAnimationFrame(checkSidebarAutoCollapse); 273 }); 274 // Also re-check when doc panel toggles 275 new MutationObserver(() => requestAnimationFrame(checkSidebarAutoCollapse)) 276 .observe(document.body, { attributes: true, attributeFilter: ['class'] }); 277 278 // Auto-collapse on initial load if window is small 279 if (window.innerWidth < AUTO_COLLAPSE_WIDTH) { 280 const sidebar = document.getElementById('sidebar'); 281 if (sidebar && !sidebar.classList.contains('hidden')) { 282 sidebar.classList.add('hidden'); 283 _wasAutoCollapsed = true; 284 syncRailSide(); 285 } 286 } 287 288 // ââ Mobile sidebar backdrop + swipe-to-close ââ 289 // Backdrop overlay: tapping it closes the sidebar 290 const mobileBackdrop = document.createElement('div'); 291 mobileBackdrop.id = 'sidebar-backdrop'; 292 document.body.appendChild(mobileBackdrop); 293 294 function updateMobileBackdrop() { 295 if (window.innerWidth >= 768) { mobileBackdrop.classList.remove('visible'); return; } 296 const sb = document.getElementById('sidebar'); 297 const rail = document.getElementById('icon-rail'); 298 const sidebarOpen = sb && !sb.classList.contains('hidden'); 299 const miniOpen = rail && rail.classList.contains('mobile-mini'); 300 mobileBackdrop.classList.toggle('visible', sidebarOpen || miniOpen); 301 } 302 303 // Suppress sidebar close briefly after dropdown actions 304 window._suppressSidebarClose = false;
305 mobileBackdrop.addEventListener('click', (e) => { 306 if (window._suppressSidebarClose) return; 307 // Don't close while a session is being renamed inline â the rename input 308 // lives inside the sidebar, and a backdrop tap (e.g. to dismiss the 309 // keyboard) would otherwise kick the user out mid-rename. 310 if (document.querySelector('.session-rename-input')) return; 311 // Don't close if a dropdown or submenu is visible 312 const openDD = document.querySelector('.session-dropdown-menu[style*="display: block"], .session-dropdown-menu[style*="display:block"]'); 313 const openSub = document.querySelector('.session-folder-submenu[style*="display: block"], .session-folder-submenu[style*="display:block"]'); 314 if (openDD || openSub) { 315 if (openSub) openSub.style.display = 'none'; 316 if (openDD) openDD.style.display = 'none'; 317 return; 318 } 319 const sb = document.getElementById('sidebar'); 320 if (sb && !sb.classList.contains('hidden')) { 321 sb.classList.add('hidden'); 322 } 323 mobileBackdrop.classList.remove('visible'); 324 syncRailSide(); 325 }); 326 327 // Patch syncRailSide to also update backdrop 328 const _origSyncRailSideCore = _syncRailSideCore; 329 _syncRailSideFn = function() { _origSyncRailSideCore(); updateMobileBackdrop(); }; 330 window.syncRailSide = syncRailSide; 331 332 // Swipe sidebar toward edge to close 333 const sidebar = document.getElementById('sidebar'); 334 if (sidebar && 'ontouchstart' in window) { 335 let _swStartX = 0, _swStartY = 0, _swSwiping = false; 336 sidebar.addEventListener('touchstart', (e) => { 337 if (e.target.closest('.list-item')) { _swSwiping = false; return; } 338 _swStartX = e.touches[0].clientX; 339 _swStartY = e.touches[0].clientY; 340 _swSwiping = true; 341 }, { passive: true }); 342 sidebar.addEventListener('touchmove', (e) => { 343 if (!_swSwiping) return; 344 const dx = e.touches[0].clientX - _swStartX; 345 const dy = Math.abs(e.touches[0].clientY - _swStartY); 346 if (dy > 40) { _swSwiping = false; return; } 347 const isRight = sidebar.classList.contains('right-side'); 348 if ((!isRight && dx < -60) || (isRight && dx > 60)) { 349 _swSwiping = false; 350 const _backdrop = document.getElementById('sidebar-backdrop'); 351 if (_backdrop) _backdrop.classList.remove('visible'); 352 sidebar.classList.add('hidden'); 353 syncRailSide(); 354 } 355 }, { passive: true }); 356 sidebar.addEventListener('touchend', () => { _swSwiping = false; }, { passive: true }); 357 } 358 359 // ââ Click outside sidebar / icon rail to close (mobile only) ââ 360 document.addEventListener('click', (e) => { 361 if (window.innerWidth >= 700) return; // desktop keeps sidebar open 362 const sb = document.getElementById('sidebar'); 363 const rail = document.getElementById('icon-rail'); 364 // Ignore clicks on elements removed from DOM (e.g. session list re-render during folder toggle) 365 if (!e.target.isConnected) return; 366 // Ignore clicks on the sidebar, icon rail, or hamburger button itself 367 if (e.target.closest('#sidebar') || e.target.closest('#icon-rail') || e.target.closest('#hamburger-btn')) return; 368 // Ignore clicks inside modals or the chat input area 369 if (e.target.closest('.modal') || e.target.closest('.input-bar') || e.target.closest('#message')) return; 370 // Ignore clicks on session/folder dropdowns and the styled prompt 371 // overlay â they're body-level elements logically tied to a sidebar 372 // action (e.g. "Move to folder â New Folderâ¦"), so closing the 373 // sidebar when the user clicks one yanks the action mid-flight. 374 if (e.target.closest('.session-dropdown, .folder-submenu, #styled-prompt-overlay, #styled-confirm-overlay')) return; 375 // Close full sidebar if open (with animation) 376 if (sb && !sb.classList.contains('hidden')) { 377 const backdrop = document.getElementById('sidebar-backdrop'); 378 if (backdrop) backdrop.classList.remove('visible'); 379 sb.classList.add('hidden'); 380 syncRailSide(); 381 return; 382 } 383 // Close mobile-mini icon rail overlay if open 384 if (rail && rail.classList.contains('mobile-mini')) { 385 rail.classList.remove('mobile-mini'); 386 rail.style.cssText = ''; 387 const backdrop = document.getElementById('sidebar-backdrop'); 388 if (backdrop) backdrop.classList.remove('visible'); 389 syncRailSide(); 390 } 391 }); 392 393 // ââ Mobile: close sidebar/rail when a tool button is tapped ââ 394 // The user expects the sidebar to get out of the way the moment a tool 395 // window opens â otherwise the modal lands behind the sidebar on phones. 396 // We remember whether the sidebar was open at the moment the tool was 397 // tapped so we can re-open it when the tool's modal is dismissed; that 398 // way clicking around the app doesn't leave the sidebar permanently 399 // shut. 400 let _sidebarWasOpenBeforeTool = false;
401 let _railWasOpenBeforeTool = false; 402 document.addEventListener('click', (e) => { 403 if (window.innerWidth >= 700) return; 404 const btn = e.target.closest('[id^="tool-"], [id^="rail-"]'); 405 if (!btn) return; 406 setTimeout(() => { 407 const sb = document.getElementById('sidebar'); 408 const rail = document.getElementById('icon-rail'); 409 const backdrop = document.getElementById('sidebar-backdrop'); 410 let changed = false; 411 if (sb && !sb.classList.contains('hidden')) { 412 _sidebarWasOpenBeforeTool = true; 413 sb.classList.add('hidden'); 414 changed = true; 415 } 416 if (rail && rail.classList.contains('mobile-mini')) { 417 _railWasOpenBeforeTool = true; 418 rail.classList.remove('mobile-mini'); 419 rail.style.cssText = ''; 420 changed = true; 421 } 422 if (changed) { 423 if (backdrop) backdrop.classList.remove('visible'); 424 syncRailSide(); 425 } 426 }, 0); 427 }); 428 429 // When a tool is dismissed by swiping it down (ui.js fires `modal-dismissed`), 430 // don't bounce the sidebar back open â the swipe should just dismiss the tool. 431 // Button-close still restores the prior sidebar state (no event fired there). 432 window.addEventListener('modal-dismissed', () => { 433 _sidebarWasOpenBeforeTool = false;
434 _railWasOpenBeforeTool = false; 435 }); 436 437 // ââ Mobile: when a tool modal closes, restore the sidebar/rail to 438 // whatever state it was in before the tool was opened. ââ 439 // We watch every .modal for the .hidden class going on, and if our 440 // remembered "sidebar-was-open" flag is set, undo the auto-close. 441 if (window.innerWidth < 700) { 442 const _restoreSidebar = () => { 443 const sb = document.getElementById('sidebar'); 444 const rail = document.getElementById('icon-rail'); 445 const backdrop = document.getElementById('sidebar-backdrop'); 446 // Skip if any modal is still visible (.modal without .hidden) â we only 447 // restore once the user is back to bare chat. A tool swiped DOWN to a 448 // dock chip is minimized (display:none via .modal-minimized), not closed 449 // â it's still "around", so don't bounce the sidebar open behind it. Only 450 // a full close (no minimized modal, no dock chips) should restore. 451 const anyOpen = [...document.querySelectorAll('.modal')] 452 .some(m => (!m.classList.contains('hidden') && getComputedStyle(m).display !== 'none') 453 || m.classList.contains('modal-minimized')); 454 const anyDocked = document.querySelectorAll('.minimized-dock-chip').length > 0; 455 if (anyOpen || anyDocked) { 456 // A tool is still minimized/docked. The user has left the "launched 457 // from the sidebar" context â drop the restore intent so that later 458 // FULLY closing the tool (e.g. dragging its chip to the trash) doesn't 459 // bounce the sidebar open. (The modal-dismissed listener that normally 460 // clears these gets blocked by modalManager's stopImmediatePropagation.) 461 _sidebarWasOpenBeforeTool = false;
462 _railWasOpenBeforeTool = false; 463 return; 464 } 465 if (_sidebarWasOpenBeforeTool && sb && sb.classList.contains('hidden')) { 466 sb.classList.remove('hidden'); 467 if (backdrop) backdrop.classList.add('visible'); 468 } 469 if (_railWasOpenBeforeTool && rail && !rail.classList.contains('mobile-mini')) { 470 rail.classList.add('mobile-mini'); 471 } 472 _sidebarWasOpenBeforeTool = false; 473 _railWasOpenBeforeTool = false; 474 if (_sidebarWasOpenBeforeTool || _railWasOpenBeforeTool) syncRailSide(); 475 }; 476 const _modalObs = new MutationObserver((muts) => { 477 let triggered = false; 478 for (const m of muts) { 479 if (m.type !== 'attributes' || m.attributeName !== 'class') continue; 480 const t = m.target; 481 if (!(t instanceof HTMLElement) || !t.classList) continue; 482 if (t.classList.contains('modal')) { triggered = true; break; } 483 } 484 if (triggered) setTimeout(_restoreSidebar, 50); 485 }); 486 _modalObs.observe(document.body, { subtree: true, attributes: true, attributeFilter: ['class'] }); 487 } 488 489 // (Mobile swipe-to-open-sidebar is wired at MODULE scope â see 490 // _initChatSwipeToOpenSidebar() at the bottom of this file â so it attaches 491 // independently of this init function completing.) 492} 493 494// ââ Mobile: swipe horizontally on the splash/chat to open the sidebar ââ 495// Wired at MODULE scope (not inside initSidebarLayout) so a throw anywhere in 496// that init can't drop this listener. Bound on `document` so it catches the 497// touch regardless of which child element is under the finger. touchmove is 498// NON-passive and calls preventDefault() once the gesture is locked 499// horizontal â without that, Firefox (and others) treat the horizontal swipe 500// as their own scroll/navigation gesture and our handler never gets to act. 501function _initChatSwipeToOpenSidebar() { 502 if (window.__odySwipeWired) return; 503 window.__odySwipeWired = true; 504 505 // Areas where a horizontal drag means something else (their own scroll/drag). 506 const EXCLUDE = [ 507 '#sidebar', '#icon-rail', '.modal', '.input-bar', '#message', 508 '#minimized-dock', '.minimized-dock-chip', '#dock-trash-zone', 509 'pre', 'table', '.agent-tool-output', '.agent-thread-cmd', 510 'input', 'textarea', 'select', 511 ].join(', '); 512 513 let sx = 0, sy = 0, track = false, decided = false; 514 515 const reset = () => { track = false; decided = false; }; 516 517 document.addEventListener('touchstart', (e) => { 518 reset(); 519 if (window.innerWidth >= 768) return; 520 if (!e.touches || e.touches.length !== 1) return; 521 if (window._chipDragging) return; 522 const sb = document.getElementById('sidebar'); 523 if (sb && !sb.classList.contains('hidden')) return; // already open 524 // Only in the chat / empty-chat view. Not when a document or PDF is open 525 // (body.doc-view), notes is open (body.notes-view), or a tool modal is up. 526 if (document.body.classList.contains('doc-view') || 527 document.body.classList.contains('notes-view')) return; 528 // Not while Compare is running â it takes over #chat-container with its own 529 // panes/scroll, and the swipe-to-open-sidebar gesture gets in the way there. 530 const cc = document.getElementById('chat-container'); 531 if (cc && cc.classList.contains('compare-active')) return; 532 const anyModalOpen = [...document.querySelectorAll('.modal')].some( 533 m => !m.classList.contains('hidden') && getComputedStyle(m).display !== 'none'); 534 if (anyModalOpen) return; 535 const t = e.target; 536 if (t && t.closest && t.closest(EXCLUDE)) return; 537 // The gesture must start within the chat area itself. 538 if (!(t && t.closest && t.closest('#chat-container'))) return; 539 sx = e.touches[0].clientX; 540 sy = e.touches[0].clientY; 541 track = true; 542 }, { passive: true, capture: true }); 543 544 document.addEventListener('touchmove', (e) => { 545 if (!track) return; 546 if (window._chipDragging) { track = false; return; } 547 if (!e.touches || !e.touches.length) return; 548 const dx = e.touches[0].clientX - sx; 549 const dy = e.touches[0].clientY - sy; 550 const adx = Math.abs(dx), ady = Math.abs(dy); 551 if (!decided) { 552 if (adx < 10 && ady < 10) return; // not enough travel to judge 553 if (ady > adx) { track = false; return; } // vertical-dominant â let it scroll 554 decided = true; // locked into a horizontal swipe 555 } 556 // Claim the gesture from the browser so it doesn't scroll/navigate instead. 557 if (e.cancelable) e.preventDefault(); 558 if (adx >= 40) { 559 track = false;
560 // Direction picks the side (per user preference): swipe LEFT â sidebar 561 // on the left, swipe RIGHT â sidebar on the right. dx<0 is a leftward 562 // finger motion; mapping it to 'right' (and dx>0 to 'left') is what makes 563 // it feel correct in practice. 564 const side = dx < 0 ? 'right' : 'left'; 565 // Use the deliberate-open helper (sets _userToggledSidebar so the 566 // auto-collapse observer doesn't instantly re-hide it). Fall back to a 567 // plain unhide if the helper isn't wired yet. 568 if (typeof window._odyOpenSidebar === 'function') { 569 window._odyOpenSidebar(side); 570 } else { 571 const sb = document.getElementById('sidebar'); 572 if (sb) { sb.classList.remove('hidden'); try { syncRailSide(); } catch (_) {} } 573 } 574 } 575 }, { passive: false, capture: true }); 576 577 document.addEventListener('touchend', reset, { passive: true, capture: true }); 578 document.addEventListener('touchcancel', reset, { passive: true, capture: true }); 579} 580 581if (document.readyState === 'loading') { 582 document.addEventListener('DOMContentLoaded', _initChatSwipeToOpenSidebar); 583} else { 584 _initChatSwipeToOpenSidebar(); 585}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.