Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions static/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 43 additions & 0 deletions tests/test_pwa_sidebar_swipe.py
Original file line number Diff line number Diff line change
@@ -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
Loading