PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/sidebar-layout.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 17:00:30 UTC 26,662 bytes, 585 lines download raw bytes

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.