diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b2a5bd2..8bd401e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/static/style.css b/static/style.css index fb4fb924..354fef81 100644 --- a/static/style.css +++ b/static/style.css @@ -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){ diff --git a/static/ui.js b/static/ui.js index 9fc6006d..e6e6d42b 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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){ diff --git a/tests/test_issue1431_toolsets_chip_responsive.py b/tests/test_issue1431_toolsets_chip_responsive.py index 5400f9fa..a533ff60 100644 --- a/tests/test_issue1431_toolsets_chip_responsive.py +++ b/tests/test_issue1431_toolsets_chip_responsive.py @@ -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" + )