Release v0.51.581 — full-screen PWA mobile sidebar drawer (#4660) (#4683)

* stage #4660 v2 (franksong2702): full-screen PWA mobile sidebar drawer — REBUILT on current master

REBUILD: the prior stage was cut before tonight's #4597/#4584/#4583 ships, so its 3-dot
diff falsely appeared to revert them (Codex CORE/SILENT flags). Rebuilt on current master
(authoritative head a035b1343): clean apply, tonight's fixes confirmed INTACT (_apply_sidebar_
state_db_overrides, bottomDistance, overflow-x:hidden all present). #4660's own change is
additive — full-screen drawer width, edge-guard, mobile close button, overlay->display:none;
does NOT touch the scroll/perf/mobile-pan lines. Nathan concept-greenlit + visual signed off.
71 mobile-layout+swipe tests pass.

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>

* fix #4660 edge-guard regression: defer preventDefault until horizontal intent + touch-action:pan-y; update 2 tests to assert corrected behavior

* fix #4660 edge-guard hit-test interception (Codex CORE): pointer-events:none + drop guard listener

Deeper fix than the prior preventDefault change: the #pwaSidebarEdgeGuard was a fixed
interactive element (z-index 198, no pointer-events:none) that intercepted hit-testing for
taps/vertical-scrolls starting in the left 24px strip (since .messages is the real scroller
under body:overflow:hidden). Now guard is pointer-events:none with NO dedicated touch
listener; the existing window-level CAPTURE handlers still catch the edge-swipe-to-open.
Removed the orphaned _onPwaSidebarEdgeGuardStart fn. Updated 2 tests to assert the new
non-interactive design. 71 mobile tests pass.

---------

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-06-22 01:10:42 -07:00
1 parent a30348b2c4
commit 55738a50ea
11 files changed
+392 -99

No files matched your search

+6
View File
@@ -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
+48 -15
View File
@@ -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');
}
}
}
+13
View File
@@ -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ę',
+12 -9
View File
@@ -177,7 +177,9 @@
<button class="rail-btn nav-tab has-tooltip" data-panel="settings" onclick="switchPanel('settings',{fromRailClick:true})" data-tooltip="Settings" data-i18n-title="tab_settings" aria-label="Settings"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg><span class="auth-warning-badge" id="authWarningBadgeDesktop" style="display:none;position:absolute;top:4px;right:4px;width:8px;height:8px;border-radius:50%;background:#e05"></span></button>
</nav>
<aside class="sidebar">
<button class="panel-head-btn mobile-sidebar-close has-tooltip has-tooltip--bottom-right" type="button" onclick="closeMobileSidebar()" data-tooltip="Close menu" data-i18n-title="close_menu" aria-label="Close menu" data-i18n-aria-label="close_menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<div class="sidebar-nav">
<button class="nav-tab active has-tooltip has-tooltip--bottom" data-panel="chat" data-label="Chat" onclick="switchPanel('chat',{fromRailClick:true})" data-tooltip="Chat" data-i18n-title="tab_chat"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks',{fromRailClick:true})" data-tooltip="Tasks" data-i18n-title="tab_tasks"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
@@ -365,35 +367,35 @@
placeholder="Search settings…" oninput="filterSettings(this.value)" autocomplete="off">
<div id="settingsSearchResults" class="settings-search-results" style="display:none"></div>
</div>
<button type="button" class="side-menu-item active" data-settings-section="conversation" onclick="switchSettingsSection('conversation')">
<button type="button" class="side-menu-item active" data-settings-section="conversation" onclick="switchSettingsSection('conversation',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span data-i18n="settings_tab_conversation">Conversation</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="appearance" onclick="switchSettingsSection('appearance')">
<button type="button" class="side-menu-item" data-settings-section="appearance" onclick="switchSettingsSection('appearance',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
<span data-i18n="settings_tab_appearance">Appearance</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="preferences" onclick="switchSettingsSection('preferences')">
<button type="button" class="side-menu-item" data-settings-section="preferences" onclick="switchSettingsSection('preferences',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
<span data-i18n="settings_tab_preferences">Preferences</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="providers" onclick="switchSettingsSection('providers')">
<button type="button" class="side-menu-item" data-settings-section="providers" onclick="switchSettingsSection('providers',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
<span data-i18n="providers_tab_title">Providers</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="plugins" onclick="switchSettingsSection('plugins')">
<button type="button" class="side-menu-item" data-settings-section="plugins" onclick="switchSettingsSection('plugins',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2l3 7h7l-5.5 4.3 2.1 7L12 16.2 5.4 20.3l2.1-7L2 9h7z"/></svg>
<span data-i18n="settings_tab_plugins">Plugins</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="extensions" onclick="switchSettingsSection('extensions')">
<button type="button" class="side-menu-item" data-settings-section="extensions" onclick="switchSettingsSection('extensions',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 2v6"/><path d="M15 2v6"/><path d="M7 8h10v4a5 5 0 0 1-10 0V8z"/><path d="M12 17v5"/></svg>
<span data-i18n="settings_tab_extensions">Extensions</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="system" onclick="switchSettingsSection('system')">
<button type="button" class="side-menu-item" data-settings-section="system" onclick="switchSettingsSection('system',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>
<span data-i18n="settings_tab_system">System</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="help" onclick="switchSettingsSection('help')">
<button type="button" class="side-menu-item" data-settings-section="help" onclick="switchSettingsSection('help',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span data-i18n="settings_tab_help">Help</span>
</button>
@@ -1604,6 +1606,7 @@
</div>
</div>
<div class="mobile-overlay" id="mobileOverlay" onclick="closeMobileSidebar()"></div>
<div class="pwa-sidebar-edge-guard" id="pwaSidebarEdgeGuard" aria-hidden="true"></div>
<div class="app-dialog-overlay" id="appDialogOverlay" style="display:none" aria-hidden="true">
<div class="app-dialog" id="appDialog" role="dialog" aria-modal="true" aria-labelledby="appDialogTitle" aria-describedby="appDialogDesc">
<div class="app-dialog-header">
+46 -13
View File
@@ -41,6 +41,8 @@ const APP_TITLEBAR_KEYS = {
memory: 'tab_memory', workspaces: 'tab_workspaces',
profiles: 'tab_profiles', todos: 'tab_todos', insights: 'tab_insights', logs: 'tab_logs', settings: 'tab_settings',
};
const MAIN_VIEW_PANELS = ['settings','skills','memory','tasks','kanban','workspaces','profiles','insights','logs','plugin'];
const MAIN_VIEW_SIDEBAR_PANEL_FALLBACKS = { plugin: 'settings' };
/**
* Update the top app titlebar to reflect the current page or selected conversation.
@@ -235,6 +237,34 @@ function _resyncChatSidebarAfterPanelSwitch() {
else run();
}
function _closeMobileSidebarAfterPanelSelection(){
if(typeof closeMobileSidebar!=='function')return;
if(typeof _isDesktopWidth==='function'&&_isDesktopWidth())return;
closeMobileSidebar();
}
function _panelFromCurrentMainView(){
const mainEl=document.querySelector('main.main');
if(!mainEl)return _currentPanel||'chat';
for(const panel of MAIN_VIEW_PANELS){
if(mainEl.classList.contains('showing-'+panel))return MAIN_VIEW_SIDEBAR_PANEL_FALLBACKS[panel]||panel;
}
if(_currentPanel&&$('panel'+_currentPanel.charAt(0).toUpperCase()+_currentPanel.slice(1)))return _currentPanel;
return 'chat';
}
function _syncMobileSidebarPanelFromMainView(){
const panel=_panelFromCurrentMainView();
if(!panel)return _currentPanel||'chat';
const panelEl=$('panel'+panel.charAt(0).toUpperCase()+panel.slice(1));
if(!panelEl)return _currentPanel||'chat';
_currentPanel=panel;
document.querySelectorAll('[data-panel]').forEach(t=>t.classList.toggle('active',t.dataset.panel===panel));
document.querySelectorAll('.panel-view').forEach(p=>p.classList.remove('active'));
panelEl.classList.add('active');
return panel;
}
async function switchPanel(name, opts = {}) {
const nextPanel = name || 'chat';
const prevPanel = _currentPanel;
@@ -277,22 +307,11 @@ async function switchPanel(name, opts = {}) {
// Update main content view. Each entry in MAIN_VIEW_PANELS gets a matching
// showing-<name> class on <main>; no class means chat (the default).
const mainEl = document.querySelector('main.main');
const _mainViewPanels = ['settings','skills','memory','tasks','kanban','workspaces','profiles','insights','logs','plugin'];
if (mainEl) {
_mainViewPanels.forEach(p => {
MAIN_VIEW_PANELS.forEach(p => {
mainEl.classList.toggle('showing-' + p, nextPanel === p);
});
}
// #4644: close the mobile sidebar drawer after a rail-click panel switch — but
// ONLY for panels that have a main-content view (or chat). Sidebar-only panels
// like Todos render INSIDE the drawer, so closing it would hide the very panel
// the user just opened. Keep the drawer open for those.
const _panelHasMainView = nextPanel === 'chat' || _mainViewPanels.indexOf(nextPanel) !== -1;
if (_panelHasMainView && opts.fromRailClick && typeof _isDesktopWidth === 'function' && !_isDesktopWidth()) {
if (typeof closeMobileSidebar === 'function') {
closeMobileSidebar();
}
}
// Lazy-load panel data
if (nextPanel === 'tasks') await loadCrons();
if (nextPanel === 'kanban') await loadKanban();
@@ -309,6 +328,13 @@ async function switchPanel(name, opts = {}) {
switchSettingsSection(_currentSettingsSection);
loadSettingsPanel();
}
if (opts.fromRailClick && typeof _isDesktopWidth === 'function' && !_isDesktopWidth()) {
const sidebar = document.querySelector('.sidebar');
if (sidebar) {
sidebar.classList.remove('mobile-session-page');
sidebar.classList.add('mobile-panel-drawer', 'mobile-open');
}
}
_resyncChatSidebarAfterPanelSwitch();
if (nextPanel === 'chat' && typeof syncTopbar === 'function') syncTopbar();
else syncAppTitlebar();
@@ -910,6 +936,7 @@ function openCronDetail(id, el){
_stopCronWatch();
_renderCronDetail(job);
_checkCronWatchOnDetail(id);
_closeMobileSidebarAfterPanelSelection();
}
function _clearCronDetail(){
@@ -3011,6 +3038,7 @@ async function loadKanbanTask(taskId){
preview.style.display = '';
preview.innerHTML = _kanbanRenderTaskDetail(data);
}
_closeMobileSidebarAfterPanelSelection();
showToast(`${t('kanban_task')}: ${title}`);
} catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); }
}
@@ -4263,6 +4291,7 @@ async function openSkill(name, el) {
}
_currentSkillDetail = { name, content: data.content || '', linked_files: data.linked_files || {} };
_renderSkillDetail(name, data.content || '', data.linked_files || {});
_closeMobileSidebarAfterPanelSelection();
} catch(e) { setStatus(t('skill_load_failed') + e.message); }
}
@@ -4712,6 +4741,7 @@ async function openMemorySection(section, el) {
await loadNotesSources(false);
}
_renderMemoryDetail(section);
_closeMobileSidebarAfterPanelSelection();
}
function editCurrentMemory() {
@@ -5258,6 +5288,7 @@ function openWorkspaceDetail(path, el){
if (target) target.classList.add('active');
_workspacePreFormDetail = null;
_renderWorkspaceDetail(ws);
_closeMobileSidebarAfterPanelSelection();
}
function _clearWorkspaceDetail(){
@@ -5750,6 +5781,7 @@ function openProfileDetail(name, el){
if (target) target.classList.add('active');
_profilePreFormDetail = null;
_renderProfileDetail(p, _profilesCache.active);
_closeMobileSidebarAfterPanelSelection();
}
function _clearProfileDetail(){
@@ -6577,6 +6609,7 @@ function switchSettingsSection(name,opts){
if(section==='plugins') loadPluginsPanel();
if(section==='extensions') loadExtensionsPanel();
}
if(opts&&opts.fromSidebarItem)_closeMobileSidebarAfterPanelSelection();
}
async function _buildSettingsIndex() {
@@ -8108,7 +8141,7 @@ async function switchPluginPage(event, path, label) {
_currentPanel = 'plugin';
const mainEl = document.querySelector('main.main');
if (mainEl) {
['settings','skills','memory','tasks','kanban','workspaces','profiles','insights','logs','plugin'].forEach(p => {
MAIN_VIEW_PANELS.forEach(p => {
mainEl.classList.toggle('showing-' + p, p === 'plugin');
});
}
+7 -2
View File
@@ -2267,6 +2267,7 @@
.mobile-hamburger{display:none;}
.mobile-files-btn{display:none!important;}
.mobile-overlay{display:none;}
.pwa-sidebar-edge-guard{display:none;}
@media(min-width:901px){
html[data-workspace-panel="closed"] .rightpanel{width:0 !important;opacity:0;transform:translateX(14px);border-left-color:transparent;pointer-events:none;}
@@ -2372,7 +2373,7 @@
/* Viewport-phone rules handle app chrome and touch sizing; they intentionally
overlap the container compact rules for actual phones. */
/* ── Sidebar: slide-in overlay instead of hidden ── */
.sidebar{position:fixed;left:0;top:0;bottom:0;width:min(320px, 100vw);z-index:200;
.sidebar{position:fixed;left:0;top:0;bottom:0;width:100vw;max-width:none;z-index:200;box-sizing:border-box;
transform:translateX(-100%);transition:transform .25s ease;will-change:transform;}
.sidebar.mobile-open{transform:translateX(0);}
.sidebar .resize-handle{display:none;}
@@ -2389,7 +2390,8 @@
/* Overlay backdrop */
.mobile-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);
z-index:199;-webkit-tap-highlight-color:transparent;}
.mobile-overlay.visible{display:block;}
.mobile-overlay.visible{display:none;}
.pwa-sidebar-edge-guard{display:block;position:fixed;left:0;top:calc(38px + var(--app-titlebar-safe-top));bottom:0;width:24px;z-index:198;background:transparent;pointer-events:none;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;}
/* Files button in topbar */
.workspace-toggle-btn,.mobile-files-btn{display:inline-flex!important;}
/* Right panel: slide-over from right */
@@ -2476,6 +2478,8 @@
.sidebar-nav .nav-tab.active{background:var(--accent-bg);}
.sidebar-nav .nav-tab.active::before{left:-4px;top:50%;bottom:auto;transform:translateY(-50%);width:3px;height:16px;border-radius:0 2px 2px 0;}
.sidebar .panel-view{height:100%;margin-left:52px;}
.sidebar .panel-head{padding-right:52px;}
.mobile-sidebar-close{display:inline-flex!important;position:absolute;right:10px;top:calc(8px + var(--app-titlebar-safe-top));width:32px;height:32px;z-index:4;background:var(--surface);border:1px solid var(--border);}
.sidebar .panel-icon-btn{min-width:44px;min-height:44px;width:auto;height:auto;}
/* Touch targets — minimum 44px */
.icon-btn,.mic-btn,.voice-mode-btn{min-width:44px;min-height:44px;}
@@ -4156,6 +4160,7 @@ main.main > #mainPlugin{display:none;}
.panel-head{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:41px;padding:8px 12px;border-bottom:1px solid var(--border);font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;flex-shrink:0;}
.panel-head-actions{display:flex;align-items:center;gap:4px;text-transform:none;letter-spacing:normal;}
.panel-head-btn{width:24px;height:24px;padding:0;display:inline-flex;align-items:center;justify-content:center;border:none;background:transparent;border-radius:6px;color:var(--muted);cursor:pointer;transition:background .15s,color .15s;flex-shrink:0;}
.mobile-sidebar-close{display:none;}
.panel-head-btn:hover{background:var(--accent-bg);color:var(--accent-text);}
.panel-head-btn:disabled,.panel-head-btn[aria-busy="true"]{opacity:.5;cursor:wait;}
.panel-head-btn:disabled:hover,.panel-head-btn[aria-busy="true"]:hover{background:transparent;color:var(--muted);}
+1 -1
View File
@@ -45,7 +45,7 @@ def _contains_post_method(block: str) -> bool:
def test_settings_sidebar_has_extensions_section_and_pane():
assert 'data-settings-section="extensions"' in INDEX_HTML
assert "switchSettingsSection('extensions')" in INDEX_HTML
assert "switchSettingsSection('extensions',{fromSidebarItem:true})" in INDEX_HTML
assert 'id="settingsPaneExtensions"' in INDEX_HTML
assert 'id="extensionsDiagnostics"' in INDEX_HTML
assert 'id="extensionsCopyDiagnosticsBtn"' in INDEX_HTML
+1 -1
View File
@@ -11,7 +11,7 @@ LOCALE_COUNT = 13 # en, it, ja, ru, es, de, zh, zh-TW, pt, ko, fr, tr, pl
def test_help_nav_button_present():
assert 'data-settings-section="help"' in INDEX_HTML
assert "switchSettingsSection('help')" in INDEX_HTML
assert "switchSettingsSection('help',{fromSidebarItem:true})" in INDEX_HTML
assert 'data-i18n="settings_tab_help"' in INDEX_HTML
+5 -1
View File
@@ -48,7 +48,11 @@ def test_kanban_has_sidebar_panel_and_main_board_mounts():
def test_switch_panel_lazy_loads_kanban_and_toggles_main_view():
assert "'kanban'" in re.search(r"\[[^\]]+\]\.forEach\(p => \{\s*mainEl\.classList", PANELS).group(0)
main_view_panels = re.search(r"const MAIN_VIEW_PANELS = \[([^\]]+)\];", PANELS)
assert main_view_panels, "MAIN_VIEW_PANELS should define main-view panels"
assert "'kanban'" in main_view_panels.group(1)
assert "MAIN_VIEW_PANELS.forEach(p => {" in PANELS
assert "mainEl.classList.toggle('showing-' + p, nextPanel === p);" in PANELS
assert "if (nextPanel === 'kanban') await loadKanban();" in PANELS
assert "if (_currentPanel === 'kanban') await loadKanban();" in PANELS
+220 -56
View File
@@ -96,6 +96,26 @@ def _optional_declarations(css, selector):
return {}
def _js_function_body(src, name):
signature = f"function {name}("
start = src.find(signature)
if start == -1:
raise AssertionError(f"Missing JS function {name}()")
brace = src.find("{", start)
if brace == -1:
raise AssertionError(f"Missing function body for {name}()")
depth = 0
for idx in range(brace, len(src)):
char = src[idx]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return src[brace + 1:idx]
raise AssertionError(f"Unterminated JS function {name}()")
def _display_hidden(declarations):
return declarations.get("display", "").replace(" ", "") in {"none", "none!important"}
@@ -429,11 +449,42 @@ def test_composer_compact_switch_is_not_viewport_only():
"Composer compact breakpoint should use container queries, not viewport media at 900px"
def test_mobile_overlay_present():
"""Mobile overlay element must exist for tap-to-close sidebar behavior."""
"""Legacy mobile overlay stays hidden because the phone sidebar is full-screen."""
assert 'id="mobileOverlay"' in HTML, \
"#mobileOverlay element missing from index.html"
assert "mobile-overlay" in CSS, \
".mobile-overlay CSS rule missing from style.css"
mobile_css = "\n".join(_max_width_media_blocks(640))
assert re.search(r'\.mobile-overlay\.visible\{[^}]*display:\s*none', mobile_css), (
"Full-screen mobile sidebar must not dim the PWA status/safe-area with a backdrop"
)
def test_mobile_sidebar_edge_guard_claims_body_edge_only():
"""A narrow body-only edge guard helps iOS hand left swipes to WebUI."""
assert 'id="pwaSidebarEdgeGuard"' in HTML, (
"mobile sidebar edge guard missing from index.html"
)
assert ".pwa-sidebar-edge-guard{display:none;}" in CSS.replace(" ", ""), (
"edge guard should be hidden outside the phone layout"
)
mobile_css = "\n".join(_max_width_media_blocks(640))
guard = _declarations(_rule_body(mobile_css, ".pwa-sidebar-edge-guard"))
assert guard.get("display") == "block"
assert guard.get("position") == "fixed"
assert guard.get("left") == "0"
assert guard.get("top") == "calc(38px + var(--app-titlebar-safe-top))", (
"edge guard should start below the PWA titlebar so it does not block hamburger"
)
assert guard.get("width") == "24px"
assert guard.get("pointer-events") == "none", (
"edge guard must be pointer-events:none so taps/vertical scrolls starting in the "
"strip fall through to the .messages scroller; the edge-swipe gesture is handled by "
"window-level capture listeners, not by the guard intercepting hit-testing (#4660 review)"
)
assert guard.get("z-index") == "198", (
"edge guard should sit below the full-screen sidebar but above the page body"
)
def test_sidebar_nav_present():
@@ -452,13 +503,8 @@ def test_mobile_keeps_panel_navigation_available():
"Phone panel navigation must remain available in the hamburger drawer"
def test_mobile_keeps_hamburger_drawer_with_vertical_44px_panel_targets():
"""Phone panel navigation should be vertical inside the hamburger drawer.
Phones need to preserve horizontal space for the conversation. The titlebar
hamburger opens the session/sidebar drawer; inside that drawer, panel icons
should use a vertical strip with 44px targets instead of a cramped top row.
"""
def test_mobile_sidebar_opens_as_full_screen_surface_with_panel_rail():
"""Phone sidebar should open full-screen while keeping the panel rail visible."""
mobile_css = "\n".join(_max_width_media_blocks(640))
assert re.search(r'\.app-titlebar-hamburger,\s*\.app-titlebar-spacer\{[^}]*display:\s*flex', mobile_css), (
"Phone titlebar hamburger must stay visible"
@@ -466,8 +512,31 @@ def test_mobile_keeps_hamburger_drawer_with_vertical_44px_panel_targets():
assert not re.search(r'\.rail\{[^}]*display:\s*flex[^}]*position:\s*fixed', mobile_css), (
"Phone must not use a persistent left rail that consumes chat width"
)
assert not re.search(r'\.sidebar\s*>\s*\.sidebar-nav\{[^}]*display:\s*none', mobile_css), (
"Phone hamburger drawer must keep the sidebar panel tabs visible"
sidebar_rule = _declarations(_rule_body(mobile_css, ".sidebar"))
sidebar_open_rule = _declarations(_rule_body(mobile_css, ".sidebar.mobile-open"))
assert sidebar_rule.get("left") == "0", (
"Mobile sidebar should stay at left:0 and move with transform"
)
assert sidebar_rule.get("width") == "100vw", (
"Mobile sidebar should fill the viewport like a session page"
)
assert sidebar_rule.get("max-width") == "none", (
"Mobile sidebar must not retain desktop/drawer max width"
)
assert sidebar_rule.get("transform") == "translateX(-100%)", (
"Closed mobile sidebar should sit fully offscreen"
)
assert sidebar_rule.get("transition") == "transform .25s ease", (
"Mobile sidebar should animate with transform"
)
assert sidebar_rule.get("will-change") == "transform", (
"Mobile sidebar should promote the transform layer before opening"
)
assert sidebar_open_rule.get("transform") == "translateX(0)", (
"Open mobile sidebar should slide the full session page into view"
)
assert not re.search(r'\.sidebar\s+\.sidebar-nav\{[^}]*display:\s*none', mobile_css), (
"Full-screen mobile sidebar should keep the panel rail visible"
)
assert re.search(r'\.sidebar-nav\{[^}]*position:\s*absolute', mobile_css), (
"Phone drawer panel tabs should be laid out as an internal side strip"
@@ -484,8 +553,11 @@ def test_mobile_keeps_hamburger_drawer_with_vertical_44px_panel_targets():
assert re.search(r'\.sidebar-nav\s+\.nav-tab\{[^}]*min-height:\s*44px', mobile_css), (
"Phone drawer panel tabs must be at least 44px tall"
)
assert re.search(r'\.sidebar\s+\.panel-view\{[^}]*margin-left:\s*52px', mobile_css), (
"Phone drawer panel content should start beside the vertical icon strip"
assert re.search(r'\.sidebar\s+\.panel-view\{[^}]*height:\s*100%[^}]*margin-left:\s*52px', mobile_css), (
"Full-screen mobile sidebar content should start beside the vertical icon strip"
)
assert re.search(r'\.mobile-sidebar-close\{[^}]*display:\s*inline-flex\s*!important', mobile_css), (
"Full-screen mobile session page needs a visible close affordance"
)
assert re.search(r'\.sidebar\s+\.panel-icon-btn\{[^}]*min-width:\s*44px', mobile_css), (
"Sidebar panel buttons must min-width:44px on phone"
@@ -504,10 +576,8 @@ def test_mobile_keeps_hamburger_drawer_with_vertical_44px_panel_targets():
)
def test_mobile_rail_click_closes_sidebar_for_main_view_panels():
"""Rail clicks on phone should switch panels and then close the mobile drawer —
but ONLY for panels that have a main-content view (or chat). Sidebar-only panels
like Todos render inside the drawer, so the drawer must stay open for them (#4644)."""
def test_mobile_rail_click_opens_full_screen_panel_drawer():
"""Rail clicks on phone should keep the full-screen drawer open for panel switching."""
panels_js = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
assert "opts.fromRailClick" in panels_js, (
"switchPanel() should distinguish rail clicks from programmatic switches"
@@ -515,50 +585,26 @@ def test_mobile_rail_click_closes_sidebar_for_main_view_panels():
assert "!_isDesktopWidth()" in panels_js, (
"Mobile rail-click sidebar handling must be limited to mobile widths"
)
switch_panel = _js_function_body(panels_js, "switchPanel")
mobile_click_match = re.search(
r"if\s*\(\s*_panelHasMainView\s*&&\s*opts\.fromRailClick\s*&&\s*typeof\s+_isDesktopWidth\s*===\s*'function'\s*&&\s*!\s*_isDesktopWidth\(\)\s*\)\s*\{(?P<body>.*?)\n\s*\}",
switch_panel,
re.DOTALL,
switch_panel = panels_js.split("async function switchPanel", 1)[1].split("\n}\n\n// ── Cron panel", 1)[0]
assert "if (opts.fromRailClick && typeof _isDesktopWidth === 'function' && !_isDesktopWidth())" in switch_panel, (
"Missing mobile rail-click sidebar handler"
)
assert mobile_click_match, "Missing mobile rail-click sidebar handler (gated on _panelHasMainView)"
mobile_click_block = mobile_click_match.group("body")
assert "typeof closeMobileSidebar === 'function'" in mobile_click_block, (
"Phone rail clicks should guard closeMobileSidebar with a typeof check"
assert "sidebar.classList.remove('mobile-session-page')" in switch_panel, (
"Phone rail clicks should leave the full-screen session page mode"
)
assert "closeMobileSidebar();" in mobile_click_block, (
"Phone rail clicks should close the mobile sidebar after panel switch"
assert "sidebar.classList.add('mobile-panel-drawer', 'mobile-open')" in switch_panel, (
"Phone rail clicks should open the panel drawer mode"
)
# The close must be gated so sidebar-only panels (e.g. Todos, which has no
# main-content view) keep the drawer open instead of hiding themselves.
assert "_panelHasMainView" in switch_panel, (
"Mobile drawer close must be gated on _panelHasMainView so sidebar-only "
"panels like Todos are not hidden by the close (#4644 Todos regression)"
)
main_view_decl = re.search(r"_panelHasMainView\s*=\s*([^;]+);", switch_panel)
assert main_view_decl, "_panelHasMainView guard must be defined"
guard_expr = main_view_decl.group(1)
assert "nextPanel === 'chat'" in guard_expr, (
"chat (default main view) must be treated as having a main view"
)
assert "_mainViewPanels" in guard_expr, (
"the guard must derive from the main-view panel list, not a hardcoded subset"
)
assert "sidebar.classList.add('mobile-open')" not in mobile_click_block, (
"Phone rail-click path in switchPanel should no longer add mobile-open"
)
assert "overlay.classList.add('visible')" not in mobile_click_block, (
"Phone rail-click path in switchPanel should no longer add mobile overlay"
)
close_idx = switch_panel.index("closeMobileSidebar();")
lazy_load_idx = switch_panel.index("if (nextPanel === 'tasks') await loadCrons();")
assert close_idx < lazy_load_idx, (
"Phone rail clicks should close the drawer before async panel lazy-loads"
rail_handler_idx = switch_panel.index("if (opts.fromRailClick && typeof _isDesktopWidth === 'function' && !_isDesktopWidth())")
close_sidebar_idx = switch_panel.find("closeMobileSidebar();", rail_handler_idx)
assert close_sidebar_idx == -1, "Phone rail clicks should keep the full-screen drawer open for panel switching"
assert "overlay.classList.add('visible')" not in switch_panel[rail_handler_idx:], (
"Full-screen phone rail clicks should not show a backdrop that dims the PWA status bar"
)
def test_mobile_switch_panel_non_chat_opens_sidebar():
"""mobileSwitchPanel() non-chat path should still open the sidebar overlay."""
"""mobileSwitchPanel() non-chat path should open the full-screen panel drawer."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
fn_body = _js_function_body(boot_js, "mobileSwitchPanel")
assert "if(name==='chat')" in fn_body, (
@@ -567,11 +613,129 @@ def test_mobile_switch_panel_non_chat_opens_sidebar():
assert "closeMobileSidebar();" in fn_body, (
"mobileSwitchPanel should still close sidebar on chat"
)
assert "sidebar.classList.add('mobile-open')" in fn_body, (
assert "sidebar.classList.add('mobile-panel-drawer','mobile-open')" in fn_body, (
"mobileSwitchPanel non-chat branch should keep adding mobile-open"
)
assert "overlay.classList.add('visible')" in fn_body, (
"mobileSwitchPanel non-chat branch should keep showing mobile overlay"
assert "overlay.classList.add('visible')" not in fn_body, (
"mobileSwitchPanel non-chat branch should not show a backdrop over the PWA status bar"
)
def test_pwa_edge_swipe_opens_current_mobile_panel():
"""Left-edge swipe should open the current sidebar panel, matching hamburger."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
body = _js_function_body(boot_js, "_openMobileSidebarFromGesture")
assert "switchPanel('chat',{bypassSettingsGuard:true})" not in body, (
"Left-edge gesture should not force Chat; it should preserve the active panel"
)
assert "sidebar.classList.remove('mobile-session-page')" in body, (
"Left-edge gesture should leave the rail-hiding session page mode"
)
assert "sidebar.classList.add('mobile-panel-drawer')" in body, (
"Left-edge gesture should open the full-screen sidebar with panel rail"
)
assert "sidebar.classList.add('mobile-open')" in body
assert "overlay.classList.add('visible')" not in body
assert "_syncMobileSidebarPanelFromMainView()" in body, (
"Left-edge gesture should sync sidebar panel from the visible detail view before opening"
)
def test_mobile_sidebar_open_syncs_panel_from_visible_detail_view():
"""The mobile sidebar should not fall back to Chat when a module detail is visible."""
panels_js = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
assert "const MAIN_VIEW_PANELS =" in panels_js
main_view_panels = panels_js.split("const MAIN_VIEW_PANELS =", 1)[1].split("];", 1)[0]
assert "'todos'" not in main_view_panels, (
"Todos is a sidebar-only panel; adding showing-todos makes main-view sync ambiguous"
)
assert "const MAIN_VIEW_SIDEBAR_PANEL_FALLBACKS = { plugin: 'settings' }" in panels_js
for panel_id in [
"panelSettings",
"panelSkills",
"panelMemory",
"panelTasks",
"panelKanban",
"panelWorkspaces",
"panelProfiles",
"panelTodos",
"panelInsights",
"panelLogs",
]:
assert f'id="{panel_id}"' in HTML, f"{panel_id} should exist for mobile sidebar sync"
assert 'id="panelPlugin"' not in HTML, (
"Plugin pages are main-view only and should sync back to the Settings sidebar list"
)
assert "MAIN_VIEW_PANELS.forEach" in panels_js
panel_from_view = _js_function_body(panels_js, "_panelFromCurrentMainView")
assert "mainEl.classList.contains('showing-'+panel)" in panel_from_view
assert "MAIN_VIEW_SIDEBAR_PANEL_FALLBACKS[panel]||panel" in panel_from_view
assert "$('panel'+_currentPanel.charAt(0).toUpperCase()+_currentPanel.slice(1))" in panel_from_view
assert "return 'chat'" in panel_from_view
sync_body = _js_function_body(panels_js, "_syncMobileSidebarPanelFromMainView")
assert "if(!panelEl)return _currentPanel||'chat'" in sync_body
assert "_currentPanel=panel" in sync_body
assert "document.querySelectorAll('[data-panel]')" in sync_body
assert "document.querySelectorAll('.panel-view')" in sync_body
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
toggle_body = _js_function_body(boot_js, "toggleMobileSidebar")
assert "_syncMobileSidebarPanelFromMainView()" in toggle_body, (
"Hamburger-opened mobile sidebar should also sync from the visible detail view"
)
def test_mobile_skill_selection_closes_sidebar_after_detail_load():
"""Selecting a skill on phone should reveal the newly-loaded detail view."""
panels_js = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
helper = _js_function_body(panels_js, "_closeMobileSidebarAfterPanelSelection")
assert "if(typeof closeMobileSidebar!=='function')return" in helper
assert "if(typeof _isDesktopWidth==='function'&&_isDesktopWidth())return" in helper
assert "closeMobileSidebar()" in helper
body = _js_function_body(panels_js, "openSkill")
assert "_renderSkillDetail(name, data.content || '', data.linked_files || {})" in body
assert "_closeMobileSidebarAfterPanelSelection()" in body
def test_mobile_sidebar_detail_selections_share_close_helper():
"""Sidebar list commits should consistently reveal their main detail on phone."""
panels_js = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
for name in [
"openCronDetail",
"loadKanbanTask",
"openMemorySection",
"openWorkspaceDetail",
"openProfileDetail",
]:
body = _js_function_body(panels_js, name)
assert "_closeMobileSidebarAfterPanelSelection()" in body, (
f"{name} should close the full-screen mobile sidebar after opening detail"
)
settings_body = _js_function_body(panels_js, "switchSettingsSection")
assert "if(opts&&opts.fromSidebarItem)_closeMobileSidebarAfterPanelSelection()" in settings_body
assert "switchSettingsSection(_currentSettingsSection);" in panels_js
assert "mobile-panel-drawer', 'mobile-open'" in panels_js, (
"Opening Settings from the rail should keep the mobile drawer available"
)
for section in ["conversation", "appearance", "preferences", "providers", "plugins", "extensions", "system", "help"]:
assert f"switchSettingsSection('{section}',{{fromSidebarItem:true}})" in HTML, (
f"Settings sidebar item {section} should close after selecting its detail"
)
def test_mobile_session_page_close_button_is_mobile_scoped():
"""The full-screen session page close button should not appear on desktop."""
assert 'class="panel-head-btn mobile-sidebar-close' in HTML, (
"Sidebar needs a close button for the full-screen mobile session page"
)
assert 'onclick="closeMobileSidebar()"' in HTML, (
"Mobile session page close button should close the sidebar"
)
assert "mobile-sidebar-close{display:none" in CSS.replace(" ", ""), (
"Mobile sidebar close button should be hidden by default"
)
mobile_css = "\n".join(_max_width_media_blocks(640))
assert ".mobile-sidebar-close{display:inline-flex!important" in mobile_css.replace(" ", ""), (
"Mobile sidebar close button should be visible in phone layout"
)
+33 -1
View File
@@ -8,18 +8,33 @@ 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
# #4660 review (Codex CORE): the guard element must NOT have its own touch
# listener — it's pointer-events:none and the window-level capture handlers
# below see the edge swipe regardless, so taps/scrolls starting in the strip
# pass through to the app instead of being intercepted.
assert "guard.addEventListener(" not in BOOT_JS
assert "_onPwaSidebarEdgeGuardStart" not in BOOT_JS
assert "window.addEventListener('touchstart', _onPwaSidebarSwipeStart, {capture:true,passive:true})" in BOOT_JS
assert "window.addEventListener('touchmove', _onPwaSidebarSwipeMove, {capture:true,passive:false})" in BOOT_JS
assert "window.addEventListener('touchend', _onPwaSidebarSwipeEnd, {capture:true,passive:true})" in BOOT_JS
assert "window.addEventListener('touchcancel', _onPwaSidebarSwipeCancel, {capture:true,passive:true})" 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
assert "function _isTouchPointerEvent" in BOOT_JS
assert "if(_isTouchPointerEvent(e))return" 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_CLAIM" 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_CLAIM" in BOOT_JS.replace(" ", "")
assert "e.preventDefault()" in BOOT_JS[BOOT_JS.find("function _onPwaSidebarSwipeMove"):BOOT_JS.find("function _onPwaSidebarSwipeEnd")]
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(" ", "")
@@ -28,11 +43,28 @@ def test_pwa_sidebar_swipe_is_edge_gated_standalone_and_horizontal():
assert ".messages" not in BOOT_JS[BOOT_JS.find("function _isInteractiveSwipeTarget"):BOOT_JS.find("function _openMobileSidebarFromGesture")]
def test_pwa_sidebar_edge_guard_is_non_interactive_and_swipe_uses_window_capture():
# #4660 review (Codex CORE): the left edge guard must not intercept hit-testing.
# It is pointer-events:none (CSS) with NO dedicated touch listener, so taps and
# vertical scrolls starting in the strip fall through to the .messages scroller.
# The edge-swipe-to-open gesture is handled by window-level CAPTURE listeners,
# and _onPwaSidebarSwipeMove only preventDefaults once horizontal intent is set.
assert "_onPwaSidebarEdgeGuardStart" not in BOOT_JS, (
"the interactive edge-guard handler must be gone (guard is pointer-events:none)"
)
assert "guard.addEventListener(" not in BOOT_JS
move = BOOT_JS[BOOT_JS.find("function _onPwaSidebarSwipeMove"):BOOT_JS.find("function _onPwaSidebarSwipeEnd")]
assert "_PWA_SIDEBAR_SWIPE_CLAIM" in move and "e.preventDefault()" in move, (
"horizontal-intent claim should preventDefault only inside the move handler"
)
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
body = BOOT_JS[BOOT_JS.find("function _openMobileSidebarFromGesture"):BOOT_JS.find("function _installPwaSidebarSwipeGesture")]
assert "overlay.classList.add('visible')" not in body
assert "toggleSidebar(" not in BOOT_JS[BOOT_JS.find("function _openMobileSidebarFromGesture"):BOOT_JS.find("function _installPwaSidebarSwipeGesture")]