From aa2b9d504d0241ff5ae44acc514b3c1144f680bc Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 30 Apr 2026 15:24:36 +0000 Subject: [PATCH] fix(mobile): workspace panel sliver + composer footer collapse (#1300) From PR #1328. Co-authored-by: Frank Song --- CHANGELOG.md | 1 + static/boot.js | 26 +++++- static/style.css | 68 ++++++++++++-- tests/test_mobile_layout.py | 176 +++++++++++++++++++++++++++++++++++- 4 files changed, 258 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d40ac1c..5e02f260 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Fixed +- **Compact/mobile workspace panel and composer layout** — prevents a saved desktop workspace-panel width from leaking into compact layouts, so mobile-width browsers no longer show a right-edge workspace sliver or closed-panel shadow. Composer footer controls now compact progressively based on the footer's own width, avoiding intermediate icon/text overlap when sidebars constrain the chat column. (`static/boot.js`, `static/style.css`, `tests/test_mobile_layout.py`) — fixes #1300 ## [v0.50.244] — 2026-04-30 diff --git a/static/boot.js b/static/boot.js index 5ef00292..f3cf8ea6 100644 --- a/static/boot.js +++ b/static/boot.js @@ -20,6 +20,23 @@ function _isCompactWorkspaceViewport(){ return window.matchMedia('(max-width: 900px)').matches; } +function _syncWorkspacePanelInlineWidth(){ + const {panel}= _workspacePanelEls(); + if(!panel) return; + + const isCompact = _isCompactWorkspaceViewport(); + if(isCompact){ + if(panel.style.width) panel.style.removeProperty('width'); + return; + } + + const saved = localStorage.getItem('hermes-panel-w'); + if(!saved) return; + const parsed = parseInt(saved, 10); + if(Number.isNaN(parsed) || parsed <= 0) return; + panel.style.width = `${parsed}px`; +} + function _workspacePanelEls(){ return { layout: document.querySelector('.layout'), @@ -578,6 +595,7 @@ document.querySelectorAll('.suggestion').forEach(btn=>{ }); window.addEventListener('resize',()=>{ + _syncWorkspacePanelInlineWidth(); syncWorkspacePanelState(); }); @@ -592,8 +610,12 @@ window.addEventListener('resize',()=>{ if(!handle || !targetEl) return; // Restore saved width - const saved = localStorage.getItem(storageKey); - if(saved) targetEl.style.width = saved + 'px'; + if(storageKey === 'hermes-panel-w'){ + _syncWorkspacePanelInlineWidth(); + }else{ + const saved = localStorage.getItem(storageKey); + if(saved) targetEl.style.width = saved + 'px'; + } let startX=0, startW=0; diff --git a/static/style.css b/static/style.css index 450a47c3..e9a19cd7 100644 --- a/static/style.css +++ b/static/style.css @@ -840,7 +840,7 @@ .attach-thumb{width:56px;height:56px;object-fit:cover;border-radius:4px;display:block;cursor:default;} textarea#msg{width:100%;background:transparent;border:none;outline:none;color:var(--text);font-size:16px;line-height:1.65;padding:12px 16px 6px;resize:none;min-height:44px;max-height:200px;font-family:inherit;} textarea#msg::placeholder{color:var(--muted);} - .composer-footer{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:6px 10px 10px;position:relative;} + .composer-footer{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:6px 10px 10px;position:relative;container-type:inline-size;container-name:composer-footer;} .composer-left{display:flex;align-items:center;gap:4px;min-width:0;flex:1;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;} .composer-left::-webkit-scrollbar{display:none;} .composer-divider{width:1px;height:16px;background:var(--border);margin:0 3px;flex-shrink:0;} @@ -1074,6 +1074,50 @@ #btnCollapseWorkspacePanel{display:none;} } + @container composer-footer (max-width: 700px){ + /* Stage 1: remove long workspace/model text first to avoid clipping. + The left sidebar + panel layout can consume width before right panel opens, so this + must be container-driven and independent of panel state. */ + .composer-workspace-label, + .composer-workspace-chevron, + #composerWorkspaceLabel, + .composer-model-label, + .composer-model-chevron, + #composerModelLabel{display:none;} + + .composer-workspace-chip, + .composer-model-chip{max-width:52px;min-width:44px;min-height:44px;padding:6px;justify-content:center;gap:0;font-size:11px;} + .composer-workspace-group{min-height:44px;} + .composer-workspace-files-btn{min-width:44px;padding:6px 8px;} + .composer-divider{display:none;} + } + + @container composer-footer (max-width: 520px){ + /* Stage 2: full icon-only for tighter widths. */ + .composer-profile-label, + .composer-workspace-label, + .composer-model-label, + .composer-reasoning-label, + .composer-profile-chevron, + .composer-workspace-chevron, + .composer-model-chevron, + .composer-reasoning-chevron, + #composerProfileLabel, + #composerWorkspaceLabel, + #composerModelLabel, + #composerReasoningLabel{display:none;} + + .composer-profile-chip, + .composer-model-chip, + .composer-reasoning-chip, + .composer-workspace-chip{max-width:44px;min-width:44px;min-height:44px;padding:6px;justify-content:center;gap:0;font-size:11px;} + + .composer-workspace-group{min-height:44px;} + .composer-workspace-files-btn{min-width:44px;padding:6px 8px;} + .composer-workspace-chip{min-width:44px;max-width:44px;padding:6px 8px;gap:0;} + .composer-divider{display:none;} + } + @media(max-width:640px){ /* ── Sidebar: slide-in overlay instead of hidden ── */ .sidebar{position:fixed;left:-300px;top:0;bottom:0;width:280px;z-index:200; @@ -1091,10 +1135,12 @@ /* Files button in topbar */ .workspace-toggle-btn,.mobile-files-btn{display:inline-flex!important;} /* Right panel: slide-over from right */ - .rightpanel{display:flex!important;position:fixed;right:-320px;top:0;bottom:0; - width:300px;z-index:200;transition:right .25s ease; - box-shadow:-4px 0 24px rgba(0,0,0,.4);} - .rightpanel.mobile-open{right:0;} + .rightpanel{display:flex!important;position:fixed; + --mobile-rightpanel-width:min(300px, 100vw); + right:calc(-1 * var(--mobile-rightpanel-width))!important; + top:0;bottom:0;width:var(--mobile-rightpanel-width)!important;max-width:100vw!important;z-index:200;transition:right .25s ease; + box-shadow:none!important;} + .rightpanel.mobile-open{right:0!important;box-shadow:-4px 0 24px rgba(0,0,0,.4)!important;} .rightpanel .resize-handle{display:none;} /* Topbar adjustments */ .topbar{padding:8px 12px;gap:8px;} @@ -1111,21 +1157,25 @@ .composer-box{border-radius:12px;} .composer-box textarea{min-height:40px;} .composer-footer{padding:6px 8px 8px!important;gap:8px;} - /* icon-only composer chips below 768px */ .composer-profile-label, .composer-workspace-label, .composer-model-label, .composer-reasoning-label, .composer-profile-chevron, + .composer-workspace-chevron, .composer-model-chevron, - .composer-reasoning-chevron{display:none;} + .composer-reasoning-chevron, + #composerProfileLabel, + #composerWorkspaceLabel, + #composerModelLabel, + #composerReasoningLabel{display:none;} .composer-profile-chip, .composer-model-chip, .composer-reasoning-chip{max-width:44px;min-width:44px;min-height:44px;padding:6px;justify-content:center;gap:0;font-size:11px;} - /* Workspace group: keep split layout on mobile — files icon + chevron-only picker */ .composer-workspace-group{min-height:44px;} .composer-workspace-files-btn{min-width:44px;padding:6px 8px;} - .composer-workspace-chip{min-width:32px;padding:6px 8px;gap:0;} + .composer-workspace-chip{min-width:44px;max-width:44px;padding:6px 8px;gap:0;} + /* icon-only composer chips continue below mobile widths */ .composer-divider{display:none;} .composer-status{max-width:96px;font-size:10px;} .send-btn{width:32px;height:32px;} diff --git a/tests/test_mobile_layout.py b/tests/test_mobile_layout.py index b46c6f14..db4ee43e 100644 --- a/tests/test_mobile_layout.py +++ b/tests/test_mobile_layout.py @@ -49,10 +49,182 @@ def test_rightpanel_mobile_slide_over_css(): "style.css must have position:fixed for rightpanel mobile slide-over" assert ".rightpanel.mobile-open{right:0" in CSS or ".rightpanel.mobile-open {right:0" in CSS, \ ".rightpanel.mobile-open must set right:0 to slide panel in from right" - assert "right:-320px" in CSS or "right: -320px" in CSS, \ - "rightpanel must start off-screen (right:-320px) on mobile" + assert "min(300px, 100vw)" in CSS or "min(300px,100vw)" in CSS, \ + "rightpanel mobile width should be capped defensively with 100vw" + assert "var(--mobile-rightpanel-width)" in CSS, \ + "mobile rightpanel width variable should be used in compact mode rules" + assert "calc(-1 * var(--mobile-rightpanel-width))" in CSS, \ + "closed mobile rightpanel should be off-canvas using a width-based negative offset" + mobile_640 = re.search(r'@media\(max-width:640px\)\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}', CSS, re.DOTALL) + assert mobile_640, "@media(max-width:640px) block missing from style.css" + rightpanel_block = mobile_640.group(1) + assert re.search(r'\.rightpanel\{[^}]*width:\s*var\(--mobile-rightpanel-width\)\s*!important', + rightpanel_block, re.DOTALL), \ + ".rightpanel width must use var(--mobile-rightpanel-width) with !important in mobile block" + assert re.search(r'\.rightpanel\.mobile-open\{[^}]*right:\s*0\s*!important', + rightpanel_block, re.DOTALL), \ + "mobile-open mobile rightpanel must force right:0 with !important" + assert re.search(r'\.rightpanel\{[^}]*box-shadow:\s*none\s*!important', + rightpanel_block, re.DOTALL), \ + "closed mobile rightpanel should have no shadow to avoid right-edge bleed" + assert re.search(r'\.rightpanel\.mobile-open\{[^}]*box-shadow:\s*-4px 0 24px rgba\(0,\s*0,\s*0,\s*\.?4\)', + rightpanel_block, re.DOTALL), \ + "open mobile rightpanel should keep the edge shadow" +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") + assert "function _syncWorkspacePanelInlineWidth()" in boot_js, \ + "_syncWorkspacePanelInlineWidth() must exist to keep panel width mobile-safe" + assert "_syncWorkspacePanelInlineWidth();" in boot_js, \ + "_syncWorkspacePanelInlineWidth() must be called when viewport changes" + assert "localStorage.getItem('hermes-panel-w')" in boot_js, \ + "Panel width helper must source hermes-panel-w from localStorage" + assert "_workspacePanelEls();" in boot_js and "style.removeProperty('width')" in boot_js, \ + "Panel helper must clear inline width while in compact/mobile viewport" + + +def _container_query_block(css: str, container_query: str): + query_pattern = re.compile( + rf'@container\s+{re.escape(container_query)}\s*\{{', + re.DOTALL, + ) + for match in query_pattern.finditer(css): + start = match.end() - 1 + end = css.find("@container", start + 1) + if end == -1: + end = css.find("@media", start + 1) + if end == -1: + end = len(css) + block = css[start + 1:end] + return block + return "" + + +def _container_media_block(css: str, media_query: str): + query_pattern = re.compile( + rf'@media\s*\(\s*max-width:\s*{re.escape(media_query)}\s*\)\s*\{{', + re.DOTALL, + ) + + def _media_block_end(css_text: str, open_brace_idx: int) -> int: + depth = 0 + for idx in range(open_brace_idx, len(css_text)): + if css_text[idx] == "{": + depth += 1 + elif css_text[idx] == "}": + depth -= 1 + if depth == 0: + return idx + return -1 + + def _strip_nested_media(block: str) -> str: + parts = [] + cursor = 0 + while True: + nested = block.find("@media", cursor) + if nested == -1: + parts.append(block[cursor:]) + break + parts.append(block[cursor:nested]) + nested_open = block.find("{", nested) + if nested_open == -1: + break + nested_close = _media_block_end(block, nested_open) + if nested_close == -1: + break + cursor = nested_close + 1 + return "".join(parts) + + for match in query_pattern.finditer(css): + start = match.end() - 1 + end = _media_block_end(css, start) + if end == -1: + continue + block = css[start + 1:end] + block = _strip_nested_media(block) + if ".composer-profile-label" in block or ".composer-profile-chip" in block: + return block + return "" + + +def test_composer_controls_switch_to_icon_only_by_container_width(): + """Composer controls should progressively compact based on footer width.""" + assert re.search(r'\.composer-footer\s*\{[^}]*container-type:inline-size[^}]*container-name:composer-footer[^}]*\}', CSS), \ + ".composer-footer should define container-type:inline-size and container-name:composer-footer" + compact_700 = _container_query_block(CSS, "composer-footer (max-width: 700px)") + assert compact_700, "Expected composer mid-width compact rules at @container composer-footer (max-width: 700px)" + for selector in ( + ".composer-workspace-label", + ".composer-model-label", + ".composer-workspace-chevron", + ".composer-model-chevron", + "#composerWorkspaceLabel", + "#composerModelLabel", + ".composer-workspace-chip", + ".composer-model-chip", + ".composer-divider", + ): + assert selector in compact_700, f"{selector} should be present in the 700px composer compact block" + assert "display:none" in compact_700 + assert "max-width:52px" in compact_700 + # Ensure this first stage does not prematurely remove profile/reasoning labels. + assert ".composer-profile-label" not in compact_700 + assert ".composer-reasoning-label" not in compact_700 + assert ".composer-profile-chevron" not in compact_700 + assert ".composer-reasoning-chevron" not in compact_700 + + compact_520 = _container_query_block(CSS, "composer-footer (max-width: 520px)") + assert compact_520, "Expected full composer icon-only rules at @container composer-footer (max-width: 520px)" + for selector in ( + ".composer-profile-label", + ".composer-workspace-label", + ".composer-model-label", + ".composer-reasoning-label", + ".composer-profile-chevron", + ".composer-workspace-chevron", + ".composer-model-chevron", + ".composer-reasoning-chevron", + "#composerProfileLabel", + "#composerWorkspaceLabel", + "#composerModelLabel", + "#composerReasoningLabel", + ".composer-workspace-chip", + ".composer-model-chip", + ".composer-profile-chip", + ".composer-reasoning-chip", + ): + assert selector in compact_520, f"{selector} should be present in the 520px composer compact block" + assert "max-width:44px" in compact_520 + assert "display:none" in compact_520 + + # Regression intent: + # - this container rule should not depend on right-panel open/closed state. + # - left-sidebar-only constriction must still collapse composer controls together. + assert ".layout:not(.workspace-panel-collapsed)" not in compact_700, \ + "composer-footer compact rule should be state-agnostic (left sidebar + closed right panel case included)" + assert ".layout:not(.workspace-panel-collapsed)" not in compact_520, \ + "composer-footer compact rule should be state-agnostic (left sidebar + closed right panel case included)" + + +def test_composer_compact_switch_is_not_viewport_only(): + """Compact controls should be container-triggered, not bound to viewport width alone.""" + assert "composer-footer (max-width: 700px)" in CSS, \ + "Container-query breakpoint should track composer footer width" + assert "composer-footer (max-width: 520px)" in CSS, \ + "Container-query second-stage breakpoint should track composer footer width" + assert re.search(r'@container\s+composer-footer\s*\(max-width:\s*860px\)', CSS) is None, \ + "Full icon-only should not be tied to a 860px threshold any more" + assert re.search(r'@container\s+composer-footer\s*\(max-width:\s*1000px\)', CSS) is None, \ + "Full icon-only/first-stage container gate should not be tied to 1000px" + media_860 = _container_media_block(CSS, "860px") + assert media_860 == "", \ + "Composer compact breakpoint should not be a dedicated 860px viewport media query" + media_900 = _container_media_block(CSS, "900px") + assert media_900 == "", \ + "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.""" assert 'id="mobileOverlay"' in HTML, \