* 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:
11 files changed
+392
-99
No files matched your search
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user