From cfe93c5ca1da4be4034b9966b3f46a163e7e2d28 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 21 Jun 2026 19:35:49 -0700 Subject: [PATCH] stage batch #4644+#4645 (franksong2702): mobile drawer close-after-tap + transform animation (#4651) #4644: close mobile sidebar drawer after a rail-click panel switch (was left open). #4645: animate drawer open/close with transform:translateX instead of left offset. Disjoint product files (panels.js vs style.css); both add tests to test_mobile_layout.py in distinct functions (no conflict). Mobile-only, desktop unaffected. 60 mobile-layout tests pass. Code byte-faithful from PR heads. Co-authored-by: nesquena-hermes Co-authored-by: franksong2702 --- CHANGELOG.md | 7 +++ static/panels.js | 19 ++++--- static/style.css | 6 +- tests/test_mobile_layout.py | 109 +++++++++++++++++++++++++++++++----- 4 files changed, 118 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1c0da89..f06e9ee2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ ## [Unreleased] +## [v0.51.569] — 2026-06-22 — Release UB (mobile drawer fixes) + +### Fixed + +- **The mobile sidebar drawer now closes after you tap a panel.** Previously, tapping a rail icon (Tasks, Logs, Insights, etc.) on phone switched the panel but left the drawer open, so you had to dismiss it manually. The drawer now closes automatically after a rail-click panel switch on mobile (desktop is unaffected). Thanks @franksong2702. (#4644) +- **Smoother mobile sidebar drawer animation.** The drawer open/close now animates with a GPU-accelerated `transform` instead of a `left` offset, for a smoother slide on phones. Thanks @franksong2702. (#4645) + ## [v0.51.568] — 2026-06-22 — Release UA (toggle installed extensions) ### Added diff --git a/static/panels.js b/static/panels.js index 034c9213..107a9bb4 100644 --- a/static/panels.js +++ b/static/panels.js @@ -277,11 +277,22 @@ async function switchPanel(name, opts = {}) { // Update main content view. Each entry in MAIN_VIEW_PANELS gets a matching // showing- class on
; 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) { - ['settings','skills','memory','tasks','kanban','workspaces','profiles','insights','logs','plugin'].forEach(p => { + _mainViewPanels.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(); @@ -298,12 +309,6 @@ async function switchPanel(name, opts = {}) { switchSettingsSection(_currentSettingsSection); loadSettingsPanel(); } - if (opts.fromRailClick && typeof _isDesktopWidth === 'function' && !_isDesktopWidth()) { - const sidebar = document.querySelector('.sidebar'); - const overlay = document.getElementById('mobileOverlay'); - if (sidebar) sidebar.classList.add('mobile-open'); - if (overlay) overlay.classList.add('visible'); - } _resyncChatSidebarAfterPanelSwitch(); if (nextPanel === 'chat' && typeof syncTopbar === 'function') syncTopbar(); else syncAppTitlebar(); diff --git a/static/style.css b/static/style.css index 0c5e3abd..55ff1aeb 100644 --- a/static/style.css +++ b/static/style.css @@ -2369,9 +2369,9 @@ /* 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:-320px;top:0;bottom:0;width:min(320px, 100vw);z-index:200; - transition:left .25s ease;} - .sidebar.mobile-open{left:0;} + .sidebar{position:fixed;left:0;top:0;bottom:0;width:min(320px, 100vw);z-index:200; + transform:translateX(-100%);transition:transform .25s ease;will-change:transform;} + .sidebar.mobile-open{transform:translateX(0);} .sidebar .resize-handle{display:none;} /* Hamburger button (in app titlebar on mobile) */ .app-titlebar{justify-content:space-between;} diff --git a/tests/test_mobile_layout.py b/tests/test_mobile_layout.py index 819195bd..fc336f78 100644 --- a/tests/test_mobile_layout.py +++ b/tests/test_mobile_layout.py @@ -72,6 +72,23 @@ def _declarations(rule_body): return declarations +def _js_function_body(source, function_name): + """Return a JavaScript function body using balanced braces.""" + match = re.search(rf'function\s+{re.escape(function_name)}\s*\([^)]*\)\s*\{{', source) + if not match: + raise AssertionError(f"Missing JavaScript function {function_name}()") + open_brace = match.end() - 1 + depth = 0 + for idx in range(open_brace, len(source)): + if source[idx] == "{": + depth += 1 + elif source[idx] == "}": + depth -= 1 + if depth == 0: + return source[open_brace + 1:idx] + raise AssertionError(f"Could not parse JavaScript function {function_name}()") + + def _optional_declarations(css, selector): try: return _declarations(_rule_body(css, selector)) @@ -209,6 +226,26 @@ def test_rightpanel_mobile_slide_over_css(): "mobile workspace header should keep comfortable row spacing" +def test_mobile_sidebar_drawer_uses_transform_instead_of_left(): + """Mobile sidebar drawer open/close must animate with transform not left offsets.""" + mobile_640 = "\n".join(_max_width_media_blocks(640)) + assert mobile_640, "Missing @media(max-width:640px) block in style.css" + + sidebar_rule = _declarations(_rule_body(mobile_640, ".sidebar")) + sidebar_open_rule = _declarations(_rule_body(mobile_640, ".sidebar.mobile-open")) + + assert sidebar_rule.get("left") == "0", \ + "Mobile .sidebar should keep left:0 in the drawer rules" + assert sidebar_rule.get("transform") == "translateX(-100%)", \ + "Closed mobile .sidebar should use transform:translateX(-100%)" + assert sidebar_rule.get("transition") == "transform .25s ease", \ + "Mobile .sidebar should transition transform for drawer animation" + assert sidebar_rule.get("will-change") == "transform", \ + "Mobile .sidebar should promote the transform layer before drawer animation" + assert sidebar_open_rule.get("transform") == "translateX(0)", \ + "Open mobile .sidebar should use transform:translateX(0)" + + def test_workspace_panel_inline_width_is_desktop_only(): """Persisted rightpanel width must only be restored above compact/mobile breakpoints.""" boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8") @@ -467,28 +504,74 @@ def test_mobile_keeps_hamburger_drawer_with_vertical_44px_panel_targets(): ) -def test_mobile_rail_click_opens_sidebar_for_all_panels(): - """Rail clicks on phone must reveal the selected sidebar panel.""" +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).""" 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" ) assert "!_isDesktopWidth()" in panels_js, ( - "Rail-click sidebar opening must be limited to mobile widths" + "Mobile rail-click sidebar handling must be limited to mobile widths" ) - mobile_click_block = re.search( - r'if\s*\(\s*opts\.fromRailClick[^{}]*!\s*_isDesktopWidth\(\)[\s\S]*?\n\s*\}', - panels_js, + 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.*?)\n\s*\}", + switch_panel, + re.DOTALL, ) - assert mobile_click_block, "Missing mobile rail-click sidebar handler" - assert "sidebar.classList.add('mobile-open')" in panels_js, ( - "Phone rail clicks should open the sidebar panel" + 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 "overlay.classList.add('visible')" in panels_js, ( - "Phone rail clicks should show the overlay behind the opened sidebar" + assert "closeMobileSidebar();" in mobile_click_block, ( + "Phone rail clicks should close the mobile sidebar after panel switch" ) - assert "nextPanel === 'chat'" not in mobile_click_block.group(0), ( - "Chat rail clicks must open the session list on phone, not close the sidebar" + # 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" + ) + + +def test_mobile_switch_panel_non_chat_opens_sidebar(): + """mobileSwitchPanel() non-chat path should still open the sidebar overlay.""" + 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, ( + "mobileSwitchPanel must close sidebar only on chat target" + ) + assert "closeMobileSidebar();" in fn_body, ( + "mobileSwitchPanel should still close sidebar on chat" + ) + assert "sidebar.classList.add('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" )