fix(mobile): workspace panel sliver + composer footer collapse (#1300)

From PR #1328.

Co-authored-by: Frank Song <franksong2702@gmail.com>
This commit is contained in:
nesquena-hermes
2026-04-30 15:24:36 +00:00
parent 4683a4a0d0
commit aa2b9d504d
4 changed files with 258 additions and 13 deletions
+1
View File
@@ -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
+24 -2
View File
@@ -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;
+59 -9
View File
@@ -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;}
+174 -2
View File
@@ -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, \