diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b692fc449f..3f647a3ab06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Added + +- Installed/mobile PWA sessions now support an edge swipe from the left side of the screen to open the mobile sidebar drawer, while preserving the existing hamburger and overlay controls. + ## [v0.51.130] — 2026-05-24 — Release DB (stage-batch12 — 3-PR profile-isolation + boot-precedence + workspace Artifacts tab) ### Fixed diff --git a/static/boot.js b/static/boot.js index 5a7a21de534..e32d11d3b70 100644 --- a/static/boot.js +++ b/static/boot.js @@ -228,6 +228,71 @@ function closeMobileSidebar(){ if(overlay)overlay.classList.remove('visible'); } +const _PWA_SIDEBAR_SWIPE_EDGE=28; +const _PWA_SIDEBAR_SWIPE_TRIGGER=72; +const _PWA_SIDEBAR_SWIPE_MAX_VERTICAL=48; +let _pwaSidebarSwipe=null; + +function _isPwaStandalone(){ + try{ + return document.documentElement.classList.contains('pwa-standalone') + || window.matchMedia('(display-mode: standalone)').matches + || window.navigator.standalone===true; + }catch(_){return false;} +} + +function _isInteractiveSwipeTarget(target){ + try{return !!(target&&target.closest&&target.closest('input,textarea,select,button,a,[contenteditable="true"],.topbar-chips,.composer-left,.sidebar,.rightpanel'));} + catch(_){return false;} +} + +function _openMobileSidebarFromGesture(){ + if(_isDesktopWidth())return; + const sidebar=document.querySelector('.sidebar'); + const overlay=$('mobileOverlay'); + if(!sidebar)return; + const layout=document.querySelector('.layout'); + if(layout)layout.classList.remove('sidebar-collapsed'); + sidebar.classList.remove('sidebar-collapsed'); + try{document.documentElement.removeAttribute('data-sidebar-collapsed');}catch(_){} + sidebar.classList.add('mobile-open'); + if(overlay)overlay.classList.add('visible'); +} + +function _onPwaSidebarSwipeStart(e){ + if(!_isPwaStandalone()||_isDesktopWidth())return; + if(e.pointerType==='mouse'||(e.pointerType&&e.pointerType!=='touch'&&e.pointerType!=='pen'))return; + if(document.querySelector('.sidebar')?.classList.contains('mobile-open'))return; + const clientX=Number(e.clientX)||0; + if(clientX>_PWA_SIDEBAR_SWIPE_EDGE)return; + if(_isInteractiveSwipeTarget(e.target))return; + _pwaSidebarSwipe={startX:clientX,startY:Number(e.clientY)||0,active:true,opened:false}; +} + +function _onPwaSidebarSwipeMove(e){ + const swipe=_pwaSidebarSwipe; + if(!swipe||!swipe.active||swipe.opened)return; + const dx=(Number(e.clientX)||0)-swipe.startX; + const dy=(Number(e.clientY)||0)-swipe.startY; + if(dx<0||Math.abs(dy)>_PWA_SIDEBAR_SWIPE_MAX_VERTICAL*1.5){_pwaSidebarSwipe=null;return;} + if(dx>=_PWA_SIDEBAR_SWIPE_TRIGGER&&Math.abs(dy)<=_PWA_SIDEBAR_SWIPE_MAX_VERTICAL&&dx>Math.abs(dy)*1.5){ + if(e.cancelable)e.preventDefault(); + swipe.opened=true; + _openMobileSidebarFromGesture(); + } +} + +function _onPwaSidebarSwipeEnd(){_pwaSidebarSwipe=null;} +function _onPwaSidebarSwipeCancel(){_pwaSidebarSwipe=null;} + +function _installPwaSidebarSwipeGesture(){ + window.addEventListener('pointerdown', _onPwaSidebarSwipeStart, {passive:true}); + window.addEventListener('pointermove', _onPwaSidebarSwipeMove, {passive:false}); + window.addEventListener('pointerup', _onPwaSidebarSwipeEnd, {passive:true}); + window.addEventListener('pointercancel', _onPwaSidebarSwipeCancel, {passive:true}); +} +_installPwaSidebarSwipeGesture(); + // ── Desktop sidebar collapse toggle ──────────────────────────────────────── // Two discoverability paths into the same state: // (1) Click the already-active rail icon → collapse / expand the sidebar. diff --git a/tests/test_pwa_sidebar_swipe.py b/tests/test_pwa_sidebar_swipe.py new file mode 100644 index 00000000000..4c8b4b5c500 --- /dev/null +++ b/tests/test_pwa_sidebar_swipe.py @@ -0,0 +1,43 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") +STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8") + + +def test_pwa_edge_swipe_gesture_is_registered_for_mobile_sidebar(): + assert "function _installPwaSidebarSwipeGesture" in BOOT_JS + assert "window.addEventListener('pointerdown', _onPwaSidebarSwipeStart" in BOOT_JS + assert "window.addEventListener('pointermove', _onPwaSidebarSwipeMove" in BOOT_JS + assert "window.addEventListener('pointerup', _onPwaSidebarSwipeEnd" in BOOT_JS + assert "window.addEventListener('pointercancel', _onPwaSidebarSwipeCancel" in BOOT_JS + + +def test_pwa_sidebar_swipe_is_edge_gated_standalone_and_horizontal(): + assert "_isPwaStandalone()" in BOOT_JS + assert "_PWA_SIDEBAR_SWIPE_EDGE" in BOOT_JS + assert "_PWA_SIDEBAR_SWIPE_TRIGGER" in BOOT_JS + assert "_PWA_SIDEBAR_SWIPE_MAX_VERTICAL" in BOOT_JS + assert "clientX>_PWA_SIDEBAR_SWIPE_EDGE" in BOOT_JS.replace(" ", "") + assert "dx>=_PWA_SIDEBAR_SWIPE_TRIGGER" in BOOT_JS.replace(" ", "") + assert "Math.abs(dy)<=_PWA_SIDEBAR_SWIPE_MAX_VERTICAL" in BOOT_JS.replace(" ", "") + assert "dx>Math.abs(dy)*1.5" in BOOT_JS.replace(" ", "") + + assert "input,textarea,select,button,a,[contenteditable=\"true\"],.topbar-chips,.composer-left,.sidebar,.rightpanel" in BOOT_JS + assert ".messages" not in BOOT_JS[BOOT_JS.find("function _isInteractiveSwipeTarget"):BOOT_JS.find("function _openMobileSidebarFromGesture")] + + +def test_pwa_sidebar_swipe_opens_existing_mobile_drawer_without_desktop_collapse(): + assert "_openMobileSidebarFromGesture" in BOOT_JS + assert "sidebar.classList.remove('sidebar-collapsed')" in BOOT_JS + assert "sidebar.classList.add('mobile-open')" in BOOT_JS + assert "overlay.classList.add('visible')" in BOOT_JS + assert "toggleSidebar(" not in BOOT_JS[BOOT_JS.find("function _openMobileSidebarFromGesture"):BOOT_JS.find("function _installPwaSidebarSwipeGesture")] + + +def test_pwa_sidebar_swipe_does_not_disable_horizontal_scrollers_globally(): + compact = STYLE_CSS.replace(" ", "") + assert "html{touch-action" not in compact + assert "body{touch-action" not in compact + assert ".layout{touch-action" not in compact