mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 03:00:23 +00:00
Apply Opus pre-release fixes: dropdown resize guard + display:block
Three fixes from Opus advisor review of stage-261:
1. CRITICAL: dropdown-survives-resize bug. The composerToolsetsDropdown is a
DOM sibling of composerToolsetsWrap, not a child, so CSS hiding the wrap
does not cascade-hide an open dropdown. If a user opens the dropdown at
composer-footer >= 1100px and then opens the workspace panel (or resizes
the window), the dropdown would stay open without a visible anchor.
Fixed in three places (defense-in-depth):
- resize listener: closes dropdown when chip.offsetParent === null
- _positionToolsetsDropdown: closes if chip hidden (defense-in-depth)
- toggleToolsetsDropdown: early-returns if chip hidden (defense against
future #1431 redesign code that might invoke from elsewhere)
2. MEDIUM: display:flex changed to display:block to match sibling wraps
(.composer-profile-wrap, .composer-model-wrap, .composer-reasoning-wrap
all use the natural block display).
3. Added 3 new regression tests to pin all three guards.
Refs #1431, #1433.
This commit is contained in:
+6
-1
@@ -3,7 +3,12 @@
|
||||
## [v0.50.261] — 2026-05-02
|
||||
|
||||
### Changed
|
||||
- **Composer footer: session-toolsets chip is now responsive** — the per-session toolsets restriction chip (introduced in #493) was crowding the composer footer on standard widths once it shared space with model, reasoning, profile, workspace, context-ring, and send. The PR #1433 fix hid it unconditionally via JS; this release replaces that with a responsive CSS rule so the chip is visible only when the composer-footer container is at least 1100px wide (i.e. wide desktops with the workspace panel closed). At narrower widths the chip is hidden by the base CSS rule, and the existing `@container composer-footer (max-width: 520px)` and `@media (max-width: 640px)` rules continue to enforce hidden on tablets and phones. JS no longer sets `display:none` directly — visibility is controlled entirely by CSS so the responsive cascade is the single source of truth. The underlying state and `/api/session/toolsets` endpoint continue to work for cron and scripted callers regardless of UI visibility. Inline `style="display:none"` removed from `index.html` so the CSS base rule is the only source of the default-hidden state. 10 new regression tests in `tests/test_issue1431_toolsets_chip_responsive.py` pin the base rule, the wide-container reveal rule, the narrow-container hide rule, the mobile viewport hide rule, the JS-doesn't-force-display-none invariant, and that `/api/session/toolsets` plus `toggleToolsetsDropdown`/`_populateToolsetsDropdown` machinery is preserved. Refs #1431, #1433. @nesquena-hermes (`static/ui.js`, `static/style.css`, `static/index.html`, `tests/test_issue1431_toolsets_chip_responsive.py`)
|
||||
- **Composer footer: session-toolsets chip is now responsive** — the per-session toolsets restriction chip (introduced in #493) was crowding the composer footer on standard widths once it shared space with model, reasoning, profile, workspace, context-ring, and send. The PR #1433 fix hid it unconditionally via JS; this release replaces that with a responsive CSS rule so the chip is visible only when the composer-footer container is at least 1100px wide (i.e. wide desktops with the workspace panel closed). At narrower widths the chip is hidden by the base CSS rule, and the existing `@container composer-footer (max-width: 520px)` and `@media (max-width: 640px)` rules continue to enforce hidden on tablets and phones. JS no longer sets `display:none` directly — visibility is controlled entirely by CSS so the responsive cascade is the single source of truth. The underlying state and `/api/session/toolsets` endpoint continue to work for cron and scripted callers regardless of UI visibility. Inline `style="display:none"` removed from `index.html` so the CSS base rule is the only source of the default-hidden state. Refs #1431, #1433. @nesquena-hermes (`static/ui.js`, `static/style.css`, `static/index.html`)
|
||||
|
||||
### Fixed (Opus pre-release advisor)
|
||||
- **Toolsets dropdown stays open after resize crosses 1100px threshold** — Opus advisor caught a latent bug promoted by the new responsive cascade. The `composerToolsetsDropdown` is a DOM sibling of `composerToolsetsWrap`, not a child, so CSS hiding the wrap does NOT cascade-hide an open dropdown. If a user opened the dropdown at composer-footer ≥ 1100px and then opened the workspace panel (or resized the window), the dropdown would stay open without a visible anchor and the resize handler would re-anchor it to the footer's left edge with no chip in sight. The bug existed pre-stage-261 at the 520/640 thresholds but those fire rarely; the new 1100px threshold is reachable with a single workspace-panel toggle. **Three fixes**: (1) resize listener now closes the dropdown (instead of repositioning it) when `chip.offsetParent === null`. (2) `_positionToolsetsDropdown()` now early-returns + closes when chip is hidden — defense-in-depth. (3) `toggleToolsetsDropdown()` early-returns when chip is hidden — currently latent (only the chip's own onclick invokes it) but defensive against future #1431 redesign code. (`static/ui.js`)
|
||||
- **`display:flex` → `display:block` on the wrap** — Opus advisor noted that sibling wraps (`.composer-profile-wrap`, `.composer-model-wrap`, `.composer-reasoning-wrap`) all use the natural block display, while `display:flex` would blockify the chip's `inline-flex` layout. Changed for consistency. (`static/style.css`)
|
||||
- **13 regression tests** in `tests/test_issue1431_toolsets_chip_responsive.py` pin: the base hide rule, the wide-container reveal rule (block or flex), the narrow-container hide rule (520px container), the mobile viewport hide rule (640px @media), the JS-doesn't-force-display-none invariant, the JS-clears-inline-style invariant, the state-tracking-still-works invariant, the no-inline-display-none-in-html invariant, the /api/session/toolsets endpoint preservation, the dropdown-machinery preservation (`toggleToolsetsDropdown`, `_populateToolsetsDropdown`), AND the three Opus-found resize-guard invariants (resize handler closes dropdown when chip hidden, `_positionToolsetsDropdown` defense-in-depth, `toggleToolsetsDropdown` defense-in-depth). (`tests/test_issue1431_toolsets_chip_responsive.py`)
|
||||
|
||||
## [v0.50.260] — 2026-05-01
|
||||
|
||||
|
||||
+4
-2
@@ -1203,9 +1203,11 @@
|
||||
toolsets+context-ring+send all need room to breathe, see #1431). At
|
||||
narrower container widths the chip is hidden by the base rule above —
|
||||
the underlying state and /api/session/toolsets endpoint continue to work
|
||||
for scripted callers regardless of UI visibility. */
|
||||
for scripted callers regardless of UI visibility. Uses display:block to
|
||||
match sibling wraps (.composer-profile-wrap, .composer-model-wrap, etc.)
|
||||
rather than display:flex — the chip child has its own inline-flex layout. */
|
||||
@container composer-footer (min-width: 1100px) {
|
||||
.composer-toolsets-wrap{display:flex;}
|
||||
.composer-toolsets-wrap{display:block;}
|
||||
}
|
||||
|
||||
@media(max-width:640px){
|
||||
|
||||
+15
-2
@@ -988,6 +988,10 @@ function _positionToolsetsDropdown() {
|
||||
const chip = $('composerToolsetsChip');
|
||||
const footer = document.querySelector('.composer-footer');
|
||||
if (!dd || !chip || !footer) return;
|
||||
// Defense: if the chip has been hidden by responsive CSS (e.g. resize across
|
||||
// 1100px container threshold while dropdown was open), don't try to anchor
|
||||
// to a zero-rect element — close the dropdown instead. (#1431)
|
||||
if (chip.offsetParent === null) { closeToolsetsDropdown(); return; }
|
||||
const chipRect = chip.getBoundingClientRect();
|
||||
const footerRect = footer.getBoundingClientRect();
|
||||
let left = chipRect.left - footerRect.left;
|
||||
@@ -1001,6 +1005,9 @@ function toggleToolsetsDropdown() {
|
||||
const chip = $('composerToolsetsChip');
|
||||
if (!dd || !chip) return;
|
||||
if (typeof S === 'undefined' || !S || !S.session) return;
|
||||
// Don't open when the chip itself is hidden by responsive CSS (#1431).
|
||||
// offsetParent === null catches display:none on the element or any ancestor.
|
||||
if (chip.offsetParent === null) return;
|
||||
const open = dd.classList.contains('open');
|
||||
if (open) { closeToolsetsDropdown(); return; }
|
||||
if (typeof closeProfileDropdown === 'function') closeProfileDropdown();
|
||||
@@ -1078,10 +1085,16 @@ document.addEventListener('click', function(e) {
|
||||
}
|
||||
});
|
||||
|
||||
// Position toolsets dropdown on resize
|
||||
// Position toolsets dropdown on resize, OR close it if the chip is no longer
|
||||
// visible (e.g. resize crossed the 1100px container threshold while dropdown
|
||||
// was open — the wrap is hidden by CSS but the dropdown sibling stays open
|
||||
// without an anchor). (#1431)
|
||||
window.addEventListener('resize', () => {
|
||||
const dd = $('composerToolsetsDropdown');
|
||||
if (dd && dd.classList.contains('open')) _positionToolsetsDropdown();
|
||||
if (!dd || !dd.classList.contains('open')) return;
|
||||
const chip = $('composerToolsetsChip');
|
||||
if (!chip || chip.offsetParent === null) { closeToolsetsDropdown(); return; }
|
||||
_positionToolsetsDropdown();
|
||||
});
|
||||
|
||||
function _syncMobileComposerConfigButton(open){
|
||||
|
||||
@@ -36,14 +36,15 @@ class TestToolsetsChipResponsiveCSS:
|
||||
def test_wide_container_query_shows_chip(self):
|
||||
"""An @container composer-footer (min-width: 1100px) rule must reveal the chip."""
|
||||
css = _src("style.css")
|
||||
# Find the min-width container query
|
||||
# Find the min-width container query — accept either display:block or display:flex
|
||||
# (we use block to match sibling wraps but either is a valid reveal)
|
||||
m = re.search(
|
||||
r'@container\s+composer-footer\s*\(\s*min-width:\s*1100px\s*\)\s*\{[^}]*\.composer-toolsets-wrap\s*\{[^}]*display:\s*flex[^}]*\}',
|
||||
r'@container\s+composer-footer\s*\(\s*min-width:\s*1100px\s*\)\s*\{[^}]*\.composer-toolsets-wrap\s*\{[^}]*display:\s*(block|flex)[^}]*\}',
|
||||
css, re.DOTALL,
|
||||
)
|
||||
assert m, (
|
||||
"Must have @container composer-footer (min-width: 1100px) rule "
|
||||
"that shows .composer-toolsets-wrap with display:flex"
|
||||
"that shows .composer-toolsets-wrap with display:block or display:flex"
|
||||
)
|
||||
|
||||
def test_narrow_container_query_keeps_hiding(self):
|
||||
@@ -176,3 +177,70 @@ class TestToolsetsAPIStillWorks:
|
||||
assert "_populateToolsetsDropdown" in js, (
|
||||
"_populateToolsetsDropdown must still exist for picker population"
|
||||
)
|
||||
|
||||
|
||||
class TestToolsetsDropdownResizeGuard:
|
||||
"""Opus-found defense: dropdown must close when chip becomes hidden by CSS.
|
||||
|
||||
The dropdown is a DOM sibling of the wrap, not a child. CSS hiding the
|
||||
wrap (e.g. by crossing the 1100px container threshold mid-session via the
|
||||
workspace-panel toggle) does NOT cascade-hide the open dropdown. Without
|
||||
a guard, the dropdown would either snap to the footer's left edge with no
|
||||
anchor, or stay open with no visible chip to dismiss it from.
|
||||
"""
|
||||
|
||||
def test_resize_handler_closes_dropdown_when_chip_hidden(self):
|
||||
"""Resize listener must close dropdown when the chip is no longer visible."""
|
||||
js = _src("ui.js")
|
||||
# Find the resize handler block for the toolsets dropdown
|
||||
# It must check chip.offsetParent === null and close, not reposition
|
||||
m = re.search(
|
||||
r"window\.addEventListener\('resize',\s*\([^)]*\)\s*=>\s*\{[^}]*composerToolsetsDropdown[^}]*\}",
|
||||
js, re.DOTALL,
|
||||
)
|
||||
assert m, "Toolsets resize handler must exist"
|
||||
body = m.group(0)
|
||||
assert "offsetParent" in body, (
|
||||
"Resize handler must check chip.offsetParent === null — without it "
|
||||
"the open dropdown stays open after CSS hides the chip mid-session "
|
||||
"(e.g. workspace-panel toggle crossing 1100px threshold)"
|
||||
)
|
||||
assert "closeToolsetsDropdown" in body, (
|
||||
"Resize handler must call closeToolsetsDropdown() when chip is "
|
||||
"hidden — repositioning a hidden chip leaves the dropdown anchored "
|
||||
"to a zero-rect element"
|
||||
)
|
||||
|
||||
def test_position_dropdown_guards_against_hidden_chip(self):
|
||||
"""_positionToolsetsDropdown must close-not-reposition if chip hidden."""
|
||||
js = _src("ui.js")
|
||||
m = re.search(
|
||||
r"function _positionToolsetsDropdown\(\)\s*\{.*?\n\}",
|
||||
js, re.DOTALL,
|
||||
)
|
||||
assert m, "_positionToolsetsDropdown function must exist"
|
||||
body = m.group(0)
|
||||
# Defense-in-depth: even direct callers of _positionToolsetsDropdown
|
||||
# must not anchor to a hidden chip.
|
||||
assert "offsetParent" in body, (
|
||||
"_positionToolsetsDropdown must check chip.offsetParent === null "
|
||||
"before reading getBoundingClientRect — defense-in-depth"
|
||||
)
|
||||
|
||||
def test_toggle_dropdown_guards_against_hidden_chip(self):
|
||||
"""toggleToolsetsDropdown must early-return if chip is hidden by CSS."""
|
||||
js = _src("ui.js")
|
||||
m = re.search(
|
||||
r"function toggleToolsetsDropdown\(\)\s*\{.*?\n\}",
|
||||
js, re.DOTALL,
|
||||
)
|
||||
assert m, "toggleToolsetsDropdown function must exist"
|
||||
body = m.group(0)
|
||||
# Currently the only invoker is the chip's own onclick (so this is
|
||||
# latent), but defensive guard is needed because the function is in
|
||||
# global scope and could be called by future #1431 redesign code.
|
||||
assert "offsetParent" in body, (
|
||||
"toggleToolsetsDropdown must check chip.offsetParent === null "
|
||||
"before opening — function is global and could be invoked when "
|
||||
"the chip is hidden by responsive CSS"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user