diff --git a/static/style.css b/static/style.css index b031db52..d4620e57 100644 --- a/static/style.css +++ b/static/style.css @@ -3247,6 +3247,14 @@ .ws-dropdown-footer{left:0;right:auto;bottom:calc(100% + 4px);min-width:280px;max-width:min(420px,calc(100vw - 32px));} .model-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;min-width:280px;max-width:min(420px,calc(100vw - 32px));background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:min(70vh,640px);overflow-y:auto;} .model-dropdown.open{display:block;} +/* Phone path (#6080): the model picker is reparented to and switched to + position:fixed by _positionModelDropdown() so it escapes the .composer-footer + containing block (container-type:inline-size, plus a backdrop-filter under the + Geist Contrast skin — both establish a fixed containing block that would + otherwise resolve `fixed` against the FOOTER, dropping the menu below the + fold). Same idiom as #profileDropdown. JS owns left/top/width/maxHeight; this + rule only flips positioning and relaxes the desktop width clamps. */ +.model-dropdown.model-dropdown--floating{position:fixed;left:0;bottom:auto;min-width:0;} .model-scope-note{position:sticky;top:0;z-index:2;padding:9px 14px;border-bottom:1px solid var(--border);color:var(--text);font-size:11px;font-weight:650;line-height:1.4;background:color-mix(in srgb,var(--surface) 82%,var(--accent-bg));box-shadow:0 1px 0 rgba(0,0,0,.12);} .model-group{padding:8px 14px 4px;font-size:10px;font-weight:700;letter-spacing:.04em;color:var(--muted);text-transform:uppercase;border-top:1px solid var(--border2);margin-top:2px;}.model-group.collapsible{cursor:pointer;user-select:none}.model-group.collapsible:before{content:'\25b6';display:inline-block;font-size:8px;margin-right:5px;transition:transform .15s ease}.model-group.collapsible.open:before{transform:rotate(90deg)}.model-group-body:empty{display:none} .model-group.sub{padding:4px 14px 2px 22px;font-size:9px;border-top:none;margin-top:0;letter-spacing:.03em;color:var(--muted);background:transparent;}.model-group.sub.collapsible:before{content:'\25b6';display:inline-block;font-size:7px;margin-right:4px;transition:transform .15s ease}.model-group.sub.collapsible.open:before{transform:rotate(90deg)}.model-group-body.sub{padding-left:8px;border-left:1px solid var(--border);} diff --git a/static/ui.js b/static/ui.js index f4d66e18..a71b9bf7 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3714,6 +3714,35 @@ function syncModelChip(){ if(mobileAction) mobileAction.classList.toggle('active',!!(dd&&dd.classList.contains('open'))); } +// Remembers where #composerModelDropdown lives in the composer-footer so the +// phone path can move it to and put it back exactly. Captured lazily on +// the first reparent (see _positionModelDropdown phone branch). +let _modelDropdownHome=null; + +// Return the model dropdown into its original .composer-footer slot and clear +// every inline style the phone path wrote, so the desktop CSS (position:absolute +// anchored on the relatively-positioned .composer-footer) fully governs again. +// Safe to call when the element never moved — it just no-ops the reinsert. +function _restoreModelDropdownHome(){ + const dd=document.getElementById('composerModelDropdown'); + if(!dd) return; + dd.classList.remove('model-dropdown--floating'); + dd.style.left=''; + dd.style.top=''; + dd.style.bottom=''; + dd.style.width=''; + dd.style.maxWidth=''; + dd.style.maxHeight=''; + if(_modelDropdownHome&&_modelDropdownHome.parent&&dd.parentNode!==_modelDropdownHome.parent){ + const ref=_modelDropdownHome.nextSibling; + if(ref&&ref.parentNode===_modelDropdownHome.parent){ + _modelDropdownHome.parent.insertBefore(dd,ref); + }else{ + _modelDropdownHome.parent.appendChild(dd); + } + } +} + function _positionModelDropdown(){ const dd=$('composerModelDropdown'); const chip=$('composerModelChip'); @@ -3723,9 +3752,62 @@ function _positionModelDropdown(){ const panel=$('composerMobileConfigPanel'); const anchor=(panel&&panel.classList.contains('open')&&mobileAction)?mobileAction:(chip&&chip.offsetParent?chip:mobileAction); if(!anchor) return; - const chipRect=anchor.getBoundingClientRect(); + const isPhone=typeof window.matchMedia==='function'&&window.matchMedia('(max-width:640px)').matches; + if(isPhone){ + // #6080: .composer-footer sets container-type:inline-size (and a + // backdrop-filter under the Geist Contrast skin) — both establish a fixed + // containing block, so a position:fixed dropdown left inside the footer + // resolves against the FOOTER (bottom of screen) instead of the viewport + // and lands below the fold. Reparent to — exactly the working + // #profileDropdown idiom — so position:fixed is viewport-relative on ALL + // skins, then compute coordinates against the visual viewport. + if(!_modelDropdownHome){ + _modelDropdownHome={parent:dd.parentNode,nextSibling:dd.nextSibling}; + } + if(dd.parentNode!==document.body) document.body.appendChild(dd); + dd.classList.add('model-dropdown--floating'); + const anchorRect=anchor.getBoundingClientRect(); + const visualViewport=window.visualViewport; + const viewportWidth=Math.max(1,Number(visualViewport&&visualViewport.width)||window.innerWidth||1); + const viewportHeight=Math.max(1,Number(visualViewport&&visualViewport.height)||window.innerHeight||1); + const viewportTop=Math.max(0,Number(visualViewport&&visualViewport.offsetTop)||0); + const viewportBottom=viewportTop+viewportHeight; + const margin=8; + const gap=6; + const viewportLeft=Math.max(0,Number(visualViewport&&visualViewport.offsetLeft)||0); + const viewportRight=viewportLeft+viewportWidth; + const titlebar=document.querySelector('.app-titlebar'); + const titlebarBottom=titlebar&&typeof titlebar.getBoundingClientRect==='function' + ? Number(titlebar.getBoundingClientRect().bottom)||0 + : 0; + const contentTop=Math.max(viewportTop+margin,titlebarBottom+margin); + const menuWidth=Math.max(1,viewportWidth-margin*2); + const left=Math.max(viewportLeft+margin,Math.min(anchorRect.left,viewportRight-menuWidth-margin)); + dd.style.left=`${left}px`; + dd.style.width=`${menuWidth}px`; + dd.style.maxWidth=`${menuWidth}px`; + dd.style.bottom='auto'; + const menuHeight=Math.max(dd.scrollHeight,dd.offsetHeight); + const aboveSpace=Math.max(0,anchorRect.top-contentTop-gap-margin); + const belowSpace=Math.max(0,viewportBottom-anchorRect.bottom-gap-margin); + const openAbove=aboveSpace>=Math.min(menuHeight,belowSpace)||aboveSpace>=belowSpace; + const availableHeight=Math.max(1,openAbove?aboveSpace:belowSpace); + dd.style.maxHeight=`${availableHeight}px`; + const visibleHeight=Math.min(menuHeight||availableHeight,availableHeight); + const top=openAbove + ? anchorRect.top-gap-visibleHeight + : anchorRect.bottom+gap; + dd.style.top=`${Math.max(contentTop,Math.min(top,viewportBottom-margin-visibleHeight))}px`; + return; + } + // Desktop (>640px): keep the current master behaviour — an absolutely + // positioned .composer-footer child. Restore the element into the footer (in + // case a prior phone open moved it to ) and clear the phone inline + // styles so the desktop CSS anchor is byte-for-byte identical to master. + _restoreModelDropdownHome(); + const anchorRect=anchor.getBoundingClientRect(); const footerRect=footer.getBoundingClientRect(); - let left=chipRect.left-footerRect.left; + let left=anchorRect.left-footerRect.left; const maxLeft=Math.max(0, footer.clientWidth-dd.offsetWidth); left=Math.max(0, Math.min(left, maxLeft)); dd.style.left=`${left}px`; @@ -4532,6 +4614,10 @@ function closeModelDropdown(){ if(dd) dd.classList.remove('open'); if(chip) chip.classList.remove('active'); if(mobileAction) mobileAction.classList.remove('active'); + // If the phone path reparented the menu onto , put it back in the + // footer and clear the fixed-position inline styles so the DOM returns to its + // baseline shape and the next desktop open anchors correctly (#6080). + if(typeof _restoreModelDropdownHome==='function') _restoreModelDropdownHome(); } function closeSettingsModelDropdown(){ @@ -4653,6 +4739,26 @@ window.addEventListener('resize',()=>{ } }); +// visualViewport resize/scroll fire on mobile when the on-screen keyboard opens +// or the URL bar collapses/expands — the phone dropdown is fixed to the visual +// viewport, so it must be re-measured against the new offsets. Coalesce with rAF +// so a burst of scroll/resize events triggers at most one reposition per frame. +let _modelDropdownRepositionScheduled=false; +function _repositionOpenModelDropdown(){ + const dd=$('composerModelDropdown'); + if(!(dd&&dd.classList.contains('open'))||_modelDropdownRepositionScheduled) return; + _modelDropdownRepositionScheduled=true; + requestAnimationFrame(()=>{ + _modelDropdownRepositionScheduled=false; + const openDd=$('composerModelDropdown'); + if(openDd&&openDd.classList.contains('open')) _positionModelDropdown(); + }); +} +if(window.visualViewport){ + window.visualViewport.addEventListener('resize',_repositionOpenModelDropdown); + window.visualViewport.addEventListener('scroll',_repositionOpenModelDropdown); +} + // ── Fit-based composer footer collapse ────────────────────────────────────── // Stage classes on .composer-footer: // (none) full labels · .cf-icons icon chips · .cf-icons.cf-burger hamburger. diff --git a/tests/test_issue6080_model_picker_reparent.py b/tests/test_issue6080_model_picker_reparent.py new file mode 100644 index 00000000..855d21bd --- /dev/null +++ b/tests/test_issue6080_model_picker_reparent.py @@ -0,0 +1,279 @@ +"""Regression guards for #6080 — mobile composer model picker lands off-screen. + +Root cause: #composerModelDropdown is a child of .composer-footer, which sets +`container-type:inline-size` (and, under the Geist Contrast skin, a +`backdrop-filter`). Both establish a fixed containing block, so a phone +`position:fixed` dropdown resolves against the FOOTER (pinned to the bottom of +the screen) instead of the visual viewport, dropping the menu below the fold — +the "tapping the model picker does nothing / opens off-screen" symptom. + +Fix (PR #6105 rework, credit @webtecnica): on the phone path the dropdown is +reparented to and switched to position:fixed via a `--floating` class, +exactly the working #profileDropdown idiom, so `fixed` is viewport-relative on +ALL skins. Desktop (>640px) is unchanged: the menu stays an absolutely +positioned .composer-footer child. + +The node-executed test below is the load-bearing one: it runs the real +`_positionModelDropdown` / `closeModelDropdown` against a stub DOM and asserts +DOM parentage, i.e. that the dropdown is NOT a descendant of the footer while +open on a phone (so no container-type ancestor can trap its fixed positioning), +and that it is restored into the footer on close and on desktop. +""" +import json +import re +import subprocess +import textwrap +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent.resolve() +UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8") +CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8") + + +def _function_body(src: str, marker: str) -> str: + start = src.index(marker) + depth = 0 + opened = False + for idx, ch in enumerate(src[start:], start): + if ch == "{": + depth += 1 + opened = True + elif ch == "}": + depth -= 1 + if opened and depth == 0: + return src[start : idx + 1] + raise AssertionError(f"Could not extract function body for {marker}") + + +# ── Static structure guards ────────────────────────────────────────────────── + +def test_css_has_floating_fixed_rule(): + """The reparented phone menu needs a body-level position:fixed rule.""" + m = re.search(r"\.model-dropdown\.model-dropdown--floating\{([^}]*)\}", CSS) + assert m, ".model-dropdown--floating rule missing (phone reparent CSS)" + body = m.group(1) + assert "position:fixed" in body, ( + "floating model dropdown must be position:fixed so it anchors to the " + "viewport once reparented to (#6080)" + ) + + +def test_no_geist_backdrop_override_added_for_model_dropdown(): + """Because the menu leaves the footer, the Geist backdrop-filter workaround + must NOT be introduced (the reparent is the fix, not a per-skin override).""" + assert "container-type:normal" not in CSS.replace(" ", ""), ( + "do not neutralise container-type on the footer — reparent instead" + ) + + +def test_position_fn_has_phone_reparent_branch(): + body = _function_body(UI_JS, "function _positionModelDropdown(") + assert "matchMedia('(max-width:640px)')" in body, "phone breakpoint check missing" + assert "document.body.appendChild(dd)" in body, ( + "phone path must reparent the dropdown to to escape the " + ".composer-footer containing block (#6080)" + ) + assert "model-dropdown--floating" in body, "phone path must apply the floating class" + # PR #6105 visual-viewport math must be preserved. + assert "window.visualViewport" in body + assert "titlebar" in body and "app-titlebar" in body, "titlebar clamp preserved" + assert "openAbove" in body, "above/below flip preserved" + assert "menuWidth" in body and "margin*2" in body, "width=viewport-16px preserved" + + +def test_close_restores_home_parent(): + body = _function_body(UI_JS, "function closeModelDropdown(") + assert "_restoreModelDropdownHome" in body, ( + "closeModelDropdown must return the reparented menu to the footer" + ) + + +def test_raf_coalesced_visualviewport_listeners_present(): + assert "_repositionOpenModelDropdown" in UI_JS + assert "requestAnimationFrame" in _function_body(UI_JS, "function _repositionOpenModelDropdown(") + assert "window.visualViewport.addEventListener('resize',_repositionOpenModelDropdown)" in UI_JS + assert "window.visualViewport.addEventListener('scroll',_repositionOpenModelDropdown)" in UI_JS + + +# ── Load-bearing DOM-parentage guard (executed in node) ────────────────────── + +def test_dropdown_escapes_footer_containing_block_when_open_on_phone(): + """Prove via real execution that the open phone dropdown is reparented to + and is NOT a descendant of .composer-footer (the container-type + ancestor), and that it is restored into the footer on close / on desktop.""" + block_start = UI_JS.index("let _modelDropdownHome=null;") + block_end = UI_JS.index("function _readModelOverflowData(") + position_block = UI_JS[block_start:block_end] + close_block = _function_body(UI_JS, "function closeModelDropdown(") + snippets = [position_block, close_block] + + script = textwrap.dedent( + f""" + const assert = require('assert'); + const snippets = {json.dumps(snippets)}; + + class ClassList {{ + constructor(el) {{ this.el = el; this.set = new Set(); }} + add(n) {{ this.set.add(n); this._sync(); }} + remove(n) {{ this.set.delete(n); this._sync(); }} + contains(n) {{ return this.set.has(n); }} + toggle(n, f) {{ + if (f === undefined) f = !this.set.has(n); + if (f) this.add(n); else this.remove(n); + return f; + }} + _sync() {{ this.el.className = [...this.set].join(' '); }} + }} + class Node {{ + constructor(tag, id) {{ + this.tagName = (tag || 'div').toUpperCase(); + this.id = id || ''; + this.className = ''; + this.classList = new ClassList(this); + this.style = {{}}; + this.children = []; + this.parentNode = null; + this.dataset = {{}}; + this._rect = {{left:0, top:0, right:0, bottom:0, width:0, height:0}}; + this.scrollHeight = 0; this.offsetHeight = 0; + this.offsetWidth = 0; this.clientWidth = 0; + this.offsetParent = null; + }} + _detach(child) {{ + const i = this.children.indexOf(child); + if (i >= 0) this.children.splice(i, 1); + child.parentNode = null; + }} + appendChild(child) {{ + if (child.parentNode) child.parentNode._detach(child); + child.parentNode = this; this.children.push(child); return child; + }} + insertBefore(child, ref) {{ + if (child.parentNode) child.parentNode._detach(child); + child.parentNode = this; + const i = ref ? this.children.indexOf(ref) : -1; + if (i >= 0) this.children.splice(i, 0, child); + else this.children.push(child); + return child; + }} + get nextSibling() {{ + if (!this.parentNode) return null; + const i = this.parentNode.children.indexOf(this); + return this.parentNode.children[i + 1] || null; + }} + getBoundingClientRect() {{ return this._rect; }} + }} + + const body = new Node('body', 'body'); + const footer = new Node('div'); // .composer-footer (container-type ancestor) + footer.className = 'composer-footer'; + footer.clientWidth = 390; + footer._rect = {{left:0, top:560, right:390, bottom:600, width:390, height:40}}; + const titlebar = new Node('div'); + titlebar.className = 'app-titlebar'; + titlebar._rect = {{left:0, top:0, right:390, bottom:44, width:390, height:44}}; + + const dd = new Node('div', 'composerModelDropdown'); + dd.scrollHeight = 300; dd.offsetHeight = 300; dd.offsetWidth = 280; + const chip = new Node('button', 'composerModelChip'); + chip.offsetParent = footer; // visible on the desktop composer + chip._rect = {{left:100, top:560, right:160, bottom:600, width:60, height:40}}; + const mobileAction = new Node('button', 'composerMobileModelAction'); + mobileAction.offsetParent = footer; + mobileAction._rect = {{left:20, top:560, right:120, bottom:600, width:100, height:40}}; + const panel = new Node('div', 'composerMobileConfigPanel'); + + // dd starts life inside the footer, followed by a sibling — proves the + // insertBefore restore path returns it to the exact original slot. + const trailingSibling = new Node('div', 'uploadBarWrap'); + footer.appendChild(dd); + footer.appendChild(trailingSibling); + + const elements = new Map([ + ['composerModelDropdown', dd], + ['composerModelChip', chip], + ['composerMobileModelAction', mobileAction], + ['composerMobileConfigPanel', panel], + ]); + + let PHONE = true; + globalThis.document = {{ + body, + getElementById: (id) => elements.get(id) || null, + querySelector: (sel) => {{ + if (sel === '.composer-footer') return footer; + if (sel === '.app-titlebar') return titlebar; + return null; + }}, + }}; + globalThis.window = {{ + matchMedia: (q) => ({{ matches: q.indexOf('max-width:640px') !== -1 ? PHONE : false }}), + innerWidth: 390, innerHeight: 800, + visualViewport: {{ width: 390, height: 600, offsetTop: 0, offsetLeft: 0 }}, + }}; + globalThis.$ = (id) => elements.get(id) || null; + + eval(snippets.join(String.fromCharCode(10)) + String.fromCharCode(10) + `;globalThis.__t = {{ + position: _positionModelDropdown, + close: closeModelDropdown, + restore: _restoreModelDropdownHome, + }};`); + + function ancestorIsFooter(el) {{ + let p = el.parentNode; + while (p) {{ if (p === footer) return true; p = p.parentNode; }} + return false; + }} + + // ── Phone: open ────────────────────────────────────────────────────── + PHONE = true; + dd.classList.add('open'); + __t.position(); + assert.strictEqual(dd.parentNode, body, + 'open phone dropdown must be reparented to '); + assert.notStrictEqual(dd.parentNode, footer, + 'open phone dropdown must NOT remain a footer child'); + assert.strictEqual(ancestorIsFooter(dd), false, + 'open phone dropdown must have NO .composer-footer ancestor — this is ' + + 'what escapes the container-type/backdrop-filter fixed-containing-block trap (#6080)'); + assert.strictEqual(dd.classList.contains('model-dropdown--floating'), true, + 'phone dropdown must carry the floating (position:fixed) class'); + assert.ok(dd.style.top && dd.style.top.endsWith('px'), 'phone path sets a px top'); + assert.ok(dd.style.width && dd.style.width.endsWith('px'), 'phone path sets a px width'); + + // ── Phone: close restores it to the exact original footer slot ─────── + __t.close(); + assert.strictEqual(dd.parentNode, footer, + 'closed dropdown must be restored into .composer-footer'); + assert.strictEqual(dd.nextSibling, trailingSibling, + 'restore must reinsert the dropdown before its original next sibling'); + assert.strictEqual(dd.classList.contains('model-dropdown--floating'), false, + 'close must drop the floating class'); + assert.strictEqual(dd.style.top, '', 'close must clear the fixed inline top'); + assert.strictEqual(dd.style.width, '', 'close must clear the inline width'); + + // ── Desktop: never reparents, stays an absolutely positioned footer child ── + PHONE = false; + dd.classList.add('open'); + __t.position(); + assert.strictEqual(dd.parentNode, footer, + 'desktop dropdown must stay inside .composer-footer (behaviour identical to master)'); + assert.strictEqual(dd.classList.contains('model-dropdown--floating'), false, + 'desktop dropdown must not use the floating class'); + assert.ok(dd.style.top === '' || dd.style.top === undefined || dd.style.top === null, + 'desktop path must not write a fixed top'); + + console.log('OK'); + """ + ) + proc = subprocess.run( + ["node", "-e", script], + cwd=REPO_ROOT, + check=False, + text=True, + capture_output=True, + ) + assert proc.returncode == 0, ( + f"node DOM-parentage assertions failed:\n{proc.stdout}\n{proc.stderr}" + ) + assert "OK" in proc.stdout