diff --git a/CHANGELOG.md b/CHANGELOG.md
index b0516d95..68c5b7bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,12 @@
## [Unreleased]
+## [v0.51.581] — 2026-06-22 — Release UN (full-screen PWA mobile sidebar drawer)
+
+### Changed
+
+- **Improved the mobile/PWA sidebar drawer.** On phones the sidebar is now a full-screen transform drawer that behaves like a dedicated navigation surface: the hamburger and left-edge swipe open the list for the *currently active* module (instead of always jumping back to Sessions), rail taps switch lists within the drawer rather than dismissing it, and the drawer closes automatically after a selection that drives a detail page so you see the result immediately. Inline sidebar controls stay usable without closing the drawer, and the dimming overlay is dropped for this full-screen drawer so the top safe-area/titlebar no longer looks disabled. Thanks @franksong2702. (#4660)
+
## [v0.51.580] — 2026-06-22 — Release UM (wrap markdown source previews)
### Fixed
diff --git a/static/boot.js b/static/boot.js
index fd910685..9e263160 100644
--- a/static/boot.js
+++ b/static/boot.js
@@ -253,20 +253,23 @@ function syncWorkspacePanelUI(){
function toggleMobileSidebar(){
const sidebar=document.querySelector('.sidebar');
- const overlay=$('mobileOverlay');
if(!sidebar)return;
const isOpen=sidebar.classList.contains('mobile-open');
if(isOpen){closeMobileSidebar();}
- else{sidebar.classList.add('mobile-open');if(overlay)overlay.classList.add('visible');}
+ else{
+ try{if(typeof _syncMobileSidebarPanelFromMainView==='function')_syncMobileSidebarPanelFromMainView();}catch(_){}
+ sidebar.classList.remove('mobile-session-page');sidebar.classList.add('mobile-panel-drawer','mobile-open');
+ }
}
function closeMobileSidebar(){
const sidebar=document.querySelector('.sidebar');
const overlay=$('mobileOverlay');
- if(sidebar)sidebar.classList.remove('mobile-open');
+ if(sidebar)sidebar.classList.remove('mobile-open','mobile-session-page','mobile-panel-drawer');
if(overlay)overlay.classList.remove('visible');
}
const _PWA_SIDEBAR_SWIPE_EDGE=80;
+const _PWA_SIDEBAR_SWIPE_CLAIM=10;
const _PWA_SIDEBAR_SWIPE_TRIGGER=64;
const _PWA_SIDEBAR_SWIPE_MAX_VERTICAL=56;
let _pwaSidebarSwipe=null;
@@ -284,35 +287,55 @@ function _isInteractiveSwipeTarget(target){
catch(_){return false;}
}
+function _pwaSidebarSwipePoint(e){
+ const touch=e&&e.touches&&e.touches[0]||e&&e.changedTouches&&e.changedTouches[0];
+ const src=touch||e;
+ if(!src)return null;
+ return {clientX:Number(src.clientX)||0,clientY:Number(src.clientY)||0};
+}
+
+function _isTouchPointerEvent(e){
+ return !!(e&&e.pointerType==='touch');
+}
+
function _openMobileSidebarFromGesture(){
if(_isDesktopWidth())return;
const sidebar=document.querySelector('.sidebar');
- const overlay=$('mobileOverlay');
if(!sidebar)return;
+ try{if(typeof _syncMobileSidebarPanelFromMainView==='function')_syncMobileSidebarPanelFromMainView();}catch(_){}
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.remove('mobile-session-page');
+ sidebar.classList.add('mobile-panel-drawer');
sidebar.classList.add('mobile-open');
- if(overlay)overlay.classList.add('visible');
}
function _onPwaSidebarSwipeStart(e){
if(_isDesktopWidth())return;
+ if(_isTouchPointerEvent(e))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;
+ const point=_pwaSidebarSwipePoint(e);
+ if(!point)return;
+ if(point.clientX>_PWA_SIDEBAR_SWIPE_EDGE)return;
if(_isInteractiveSwipeTarget(e.target))return;
- _pwaSidebarSwipe={startX:clientX,startY:Number(e.clientY)||0,active:true,opened:false};
+ _pwaSidebarSwipe={startX:point.clientX,startY:point.clientY,active:true,opened:false};
}
function _onPwaSidebarSwipeMove(e){
+ if(_isTouchPointerEvent(e))return;
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;
+ const point=_pwaSidebarSwipePoint(e);
+ if(!point)return;
+ const dx=point.clientX-swipe.startX;
+ const dy=point.clientY-swipe.startY;
if(dx<0||Math.abs(dy)>_PWA_SIDEBAR_SWIPE_MAX_VERTICAL*1.5){_pwaSidebarSwipe=null;return;}
+ if(dx>=_PWA_SIDEBAR_SWIPE_CLAIM&&dx>Math.abs(dy)*1.2){
+ if(e.cancelable)e.preventDefault();
+ }
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;
@@ -320,10 +343,21 @@ function _onPwaSidebarSwipeMove(e){
}
}
-function _onPwaSidebarSwipeEnd(){_pwaSidebarSwipe=null;}
-function _onPwaSidebarSwipeCancel(){_pwaSidebarSwipe=null;}
+function _onPwaSidebarSwipeEnd(e){if(_isTouchPointerEvent(e))return;_pwaSidebarSwipe=null;}
+function _onPwaSidebarSwipeCancel(e){if(_isTouchPointerEvent(e))return;_pwaSidebarSwipe=null;}
function _installPwaSidebarSwipeGesture(){
+ // #4660 review (Codex CORE): the #pwaSidebarEdgeGuard element is now
+ // pointer-events:none (CSS), so it can no longer intercept hit-testing for
+ // taps / vertical scrolls that merely start in the left edge strip — those
+ // pass through to the underlying .messages scroller. The edge-swipe-to-open
+ // gesture is handled entirely by the window-level CAPTURE touch/pointer
+ // listeners below (which see the event regardless of the guard), so no
+ // dedicated guard-element listener is needed.
+ window.addEventListener('touchstart', _onPwaSidebarSwipeStart, {capture:true,passive:true});
+ window.addEventListener('touchmove', _onPwaSidebarSwipeMove, {capture:true,passive:false});
+ window.addEventListener('touchend', _onPwaSidebarSwipeEnd, {capture:true,passive:true});
+ window.addEventListener('touchcancel', _onPwaSidebarSwipeCancel, {capture:true,passive:true});
window.addEventListener('pointerdown', _onPwaSidebarSwipeStart, {passive:true});
window.addEventListener('pointermove', _onPwaSidebarSwipeMove, {passive:false});
window.addEventListener('pointerup', _onPwaSidebarSwipeEnd, {passive:true});
@@ -436,10 +470,9 @@ function mobileSwitchPanel(name){
closeMobileSidebar();
} else {
const sidebar=document.querySelector('.sidebar');
- const overlay=$('mobileOverlay');
if(sidebar){
- sidebar.classList.add('mobile-open');
- if(overlay)overlay.classList.add('visible');
+ sidebar.classList.remove('mobile-session-page');
+ sidebar.classList.add('mobile-panel-drawer','mobile-open');
}
}
}
diff --git a/static/i18n.js b/static/i18n.js
index d78c52e7..bbe6ec5d 100644
--- a/static/i18n.js
+++ b/static/i18n.js
@@ -925,6 +925,7 @@ const LOCALES = {
dashboard_loopback_warning: 'Dashboard is loopback-only on the server. Either browse from the server itself or restart it with --host 0.0.0.0 (insecure).',
tab_logs: 'Logs',
tab_settings: 'Settings',
+ close_menu: 'Close menu',
new_conversation: 'New conversation',
filter_conversations: 'Filter conversations...',
markdown_table_filter: 'Filter table',
@@ -2497,6 +2498,7 @@ const LOCALES = {
dashboard_loopback_warning: 'La dashboard è solo loopback sul server. Naviga dal server stesso o riavvialo con --host 0.0.0.0 (non sicuro).',
tab_logs: 'Log',
tab_settings: 'Impostazioni',
+ close_menu: 'Close menu', // TODO: translate
new_conversation: 'Nuova conversazione',
filter_conversations: 'Filtra conversazioni...',
markdown_table_filter: 'Filtra tabella',
@@ -4060,6 +4062,7 @@ const LOCALES = {
dashboard_loopback_warning: 'ダッシュボードはサーバー上のループバック専用です。サーバー上で閲覧するか、--host 0.0.0.0(安全ではありません)で再起動してください。',
tab_logs: 'ログ',
tab_settings: '設定',
+ close_menu: 'Close menu', // TODO: translate
new_conversation: '新しい会話',
filter_conversations: '会話を絞り込み...',
markdown_table_filter: 'テーブルをフィルター',
@@ -5363,6 +5366,7 @@ const LOCALES = {
dashboard_loopback_warning: 'Панель доступна только через loopback на сервере. Откройте её с самого сервера или перезапустите с --host 0.0.0.0 (небезопасно).',
tab_logs: 'Logs',
tab_settings: 'Настройки',
+ close_menu: 'Close menu', // TODO: translate
logs_title: 'Logs', // TODO: translate
logs_file: 'File', // TODO: translate
@@ -6863,6 +6867,7 @@ const LOCALES = {
dashboard_loopback_warning: 'El panel solo usa loopback en el servidor. Navega desde el propio servidor o reinícialo con --host 0.0.0.0 (inseguro).',
tab_logs: 'Logs',
tab_settings: 'Ajustes',
+ close_menu: 'Close menu', // TODO: translate
logs_title: 'Logs', // TODO: translate
logs_file: 'File', // TODO: translate
@@ -8341,6 +8346,7 @@ const LOCALES = {
dashboard_loopback_warning: 'Das Dashboard ist auf dem Server nur per Loopback erreichbar. Öffne es direkt auf dem Server oder starte es mit --host 0.0.0.0 neu (unsicher).',
tab_logs: 'Logs',
tab_settings: 'Einstellungen',
+ close_menu: 'Close menu', // TODO: translate
logs_title: 'Logs', // TODO: translate
logs_file: 'File', // TODO: translate
@@ -9876,6 +9882,7 @@ const LOCALES = {
tab_profiles: '配置',
tab_logs: '日志',
tab_settings: '设置',
+ close_menu: 'Close menu', // TODO: translate
logs_title: '日志',
logs_file: '文件',
@@ -11626,6 +11633,7 @@ const LOCALES = {
dashboard_loopback_warning: '儀表板在伺服器上僅限 loopback 存取。請從伺服器本機瀏覽,或使用 --host 0.0.0.0 重新啟動(不安全)。',
tab_logs: '記錄',
tab_settings: '設定',
+ close_menu: '關閉選單',
new_conversation: '新增對話',
filter_conversations: '篩選對話……',
markdown_table_filter: '篩選表格',
@@ -13076,6 +13084,7 @@ const LOCALES = {
dashboard_loopback_warning: 'O painel é somente loopback no servidor. Navegue pelo próprio servidor ou reinicie com --host 0.0.0.0 (inseguro).',
tab_logs: 'Logs',
tab_settings: 'Configurações',
+ close_menu: 'Close menu', // TODO: translate
logs_title: 'Logs', // TODO: translate
logs_file: 'File', // TODO: translate
@@ -14540,6 +14549,7 @@ const LOCALES = {
dashboard_loopback_warning: '대시보드는 서버에서 loopback 전용입니다. 서버 자체에서 접속하거나 --host 0.0.0.0(안전하지 않음)으로 다시 시작하세요.',
tab_logs: 'Logs',
tab_settings: '설정',
+ close_menu: 'Close menu', // TODO: translate
logs_title: 'Logs', // TODO: translate
logs_file: 'File', // TODO: translate
@@ -16009,6 +16019,7 @@ const LOCALES = {
dashboard_loopback_warning: 'Le tableau de bord est en boucle uniquement sur le serveur. Naviguez depuis le serveur lui-même ou redémarrez-le avec --host 0.0.0.0 (non sécurisé).',
tab_logs: 'Journaux',
tab_settings: 'Paramètres',
+ close_menu: 'Close menu', // TODO: translate
new_conversation: 'Nouvelle conversation',
filter_conversations: 'Filtrer les conversations...',
markdown_table_filter: 'Filtrer le tableau',
@@ -17607,6 +17618,7 @@ const LOCALES = {
dashboard_loopback_warning: 'Kontrol Paneli sunucuda yalnızca geri döngüye sahiptir. Ya sunucunun kendisinden göz atın ya da --host 0.0.0.0 (güvenli değil) ile yeniden başlatın.',
tab_logs: 'Günlükler',
tab_settings: 'Ayarlar',
+ close_menu: 'Close menu', // TODO: translate
logs_title: 'Günlükler',
logs_file: 'Dosya',
@@ -19185,6 +19197,7 @@ const LOCALES = {
dashboard_loopback_warning: 'Pulpit nawigacyjny na serwerze jest dostępny wyłącznie lokalnie (loopback). Przeglądaj bezpośrednio z serwera lub uruchom go ponownie z flagą --host 0.0.0.0 (niebezpieczne).',
tab_logs: 'Logi',
tab_settings: 'Ustawienia',
+ close_menu: 'Close menu', // TODO: translate
new_conversation: 'Nowa konwersacja',
filter_conversations: 'Filtruj konwersacje...',
markdown_table_filter: 'Filtruj tabelę',
diff --git a/static/index.html b/static/index.html
index 619744c0..a213d508 100644
--- a/static/index.html
+++ b/static/index.html
@@ -177,7 +177,9 @@