fix(model-config): keep selector menus within viewport

Cap preset and agent profile menus to the space above the selector when
opening or resizing the window. Keep the full scroll track reachable
without changing its width, and preserve shared overflow positioning.

Add browser regression coverage for short viewports, resize, native
scrollbar dragging, wheel scrolling, and preset selection through overflow.
Update the plugin contracts and README.

Validation: 11 focused tests passed; live UI verified at localhost:32081.
This commit is contained in:
Alessandro 2026-10-13 14:57:02 +02:00
parent 29288a8acc
commit c946b3c347
4 changed files with 119 additions and 4 deletions

View file

@ -39,6 +39,7 @@
- Presets may change outside the editor, so the `open_modal_before` hook reloads them before every preset-editor open, including `/presets`, and resolves the chat's active preset unless `openPresetEditor()` pinned one.
- The preset editor maps each model provider's API-key field to the shared API-key store; saving the editor persists dirty keys separately and never writes secrets into preset YAML.
- The compact chat selector label combines the effective preset with only the leaf name of its main model; utility and provider text stay out of the closed selector.
- Preset and agent-profile menus cap their height to the space above the selector on opening and window resize, keeping the full scroll track inside the viewport. Overflow submenus retain the shared `x-overflow` positioning limits.
- The compact selector strip exposes `model-context-strip-end` after the agent
profile selector so adjacent bundled controls can stay plugin-owned.
- The selector strip uses the shared `x-overflow` directive for built-in and plugin controls. Its root must remain shrinkable; overflow entries open the original selector panels and retain their Alpine state. The profile avatar uses `data-overflow-icon` so its color, initials, or image also appear in overflow.

View file

@ -31,7 +31,7 @@ The normal plugin resolution order selects the most specific available reference
- Agent Settings shows the global selection, its three resolved models, and actions for preset editing, API keys, and per-project/agent settings.
- The full plugin settings modal uses the generic scope selector and stores only the chosen preset at that scope.
- The closed chat switcher shows the effective preset plus the main model's short name; its menu supports a chat-only selection or returning to the scoped preset.
- The closed chat switcher shows the effective preset plus the main model's short name; its menu supports a chat-only selection or returning to the scoped preset. Preset and profile menus shrink to fit short windows so the full scroll track stays reachable.
- The adjacent profile switcher shows the active agent's effective title and avatar and links to Create, Edit, and Manage agents.
- The preset editor exposes the shared API key for each selected provider and saves key changes separately from secret-free preset definitions.
- Project Settings selects from the same global preset definitions.

View file

@ -5,7 +5,15 @@
<div x-data>
<template x-if="$store.modelConfig">
<div class="model-switcher-root" x-data="{ showDropdown: false, showProfileDropdown: false }"
<div class="model-switcher-root" x-data="{
showDropdown: false, showProfileDropdown: false, dropdownMaxHeight: 550,
resizeDropdowns() {
this.dropdownMaxHeight = Math.max(0, this.$el.getBoundingClientRect().top - 6 - 8);
}
}"
:style="{ '--switcher-available-height': dropdownMaxHeight + 'px' }"
x-effect="if (showDropdown || showProfileDropdown) resizeDropdowns()"
@resize.window="resizeDropdowns()"
x-init="
await Promise.all([
$store.modelConfig.refreshSwitcher($store.chats?.selected || ''),
@ -292,7 +300,7 @@
bottom: calc(100% + 6px);
left: 0;
min-width: 280px;
max-height: 550px;
max-height: min(550px, var(--switcher-available-height, 550px));
overflow-y: auto;
background-color: var(--color-panel);
border: 1px solid var(--color-border);
@ -307,7 +315,7 @@
left: 0;
min-width: 260px;
max-width: min(320px, 90vw);
max-height: 550px;
max-height: min(550px, var(--switcher-available-height, 550px));
overflow-y: auto;
background-color: var(--color-panel);
border: 1px solid var(--color-border);
@ -316,6 +324,10 @@
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
z-index: 100;
}
.overflow-hidden .model-switcher-dropdown,
.overflow-hidden .agent-profile-dropdown {
max-height: 550px;
}
.model-switcher-item {
padding: 6px 10px;
border-radius: 6px;

View file

@ -282,6 +282,108 @@ def test_overflow_preserves_original_controls_and_plugin_menus():
browser.close()
def test_model_switcher_menus_fit_short_windows_and_remain_scrollable():
component = (ROOT / "plugins/_model_config/extensions/webui/chat-input-progress-start/model-switcher.html").read_text()
component = component.split("</script>", 1)[1]
html = """<!doctype html><html><head>
<link rel="stylesheet" href="/index.css">
<link rel="stylesheet" href="/css/buttons.css">
<link rel="stylesheet" href="/css/overflow.css">
<script type="module">
import { registerOverflow } from '/js/overflow.js';
document.addEventListener('alpine:init', () => {
registerOverflow(Alpine);
Alpine.store('chats', { selected: 'test', selectedContext: { agent_profile: 'agent0' } });
Alpine.store('modelConfig', {
switcherAllowed: true, switcherLoading: false, switcherOverride: null,
switcherEffectivePreset: 'Preset 0', agentProfilesLoading: false,
switcherPresets: Array.from({length: 20}, (_, i) => ({
name: `Preset ${i}`, chat: {provider: 'test', name: 'main-model'},
utility: {provider: 'test', name: 'utility-model'},
embedding: {provider: 'test', name: 'embedding-model'},
})),
refreshSwitcher() {}, loadAgentProfiles() {},
getSwitcherLabel() { return this.switcherEffectivePreset; },
selectPresetSwitch(chat, name) { this.switcherEffectivePreset = name; },
getAgentProfileVisual() { return {color: 'gray', initials: 'A0', url: ''}; },
getAgentProfileList() {
return Array.from({length: 20}, (_, i) => ({key: `agent${i}`, label: `Agent ${i}`}));
},
});
});
await import('/vendor/alpine/alpine.min.js');
</script>
</head><body>
<div id="composer" style="position:fixed; bottom:120px; left:20px; right:20px;">
""" + component + "</div></body></html>"
with playwright.sync_playwright() as p:
try:
browser = p.chromium.launch(ignore_default_args=["--hide-scrollbars"])
except playwright.Error as error:
if "Executable doesn't exist" in str(error):
pytest.skip("An installed Playwright Chromium browser is required.")
raise
page = browser.new_page(viewport={"width": 912, "height": 780})
errors = []
page.on("pageerror", lambda error: errors.append(str(error)))
serve_page(page, html)
page.goto("http://switcher.test/")
page.wait_for_selector(".model-switcher-btn")
def assert_menu_fits(selector):
page.wait_for_function("""selector => {
const r = document.querySelector(selector).getBoundingClientRect();
return r.top >= 8 && r.bottom <= innerHeight - 8
&& r.left >= 8 && r.right <= innerWidth - 8 && r.height > 0;
}""", arg=selector)
for trigger, selector in [
(".model-switcher-btn", ".model-switcher-dropdown"),
(".agent-profile-btn", ".agent-profile-dropdown"),
]:
page.locator(trigger).click()
menu = page.locator(selector)
assert_menu_fits(selector)
for height in [780, 440, 280, 780]:
page.set_viewport_size({"width": 912, "height": height})
assert_menu_fits(selector)
assert menu.evaluate("el => el.scrollHeight > el.clientHeight")
assert menu.evaluate("el => getComputedStyle(el).scrollbarWidth") == "thin"
assert menu.bounding_box()["height"] == 550
page.set_viewport_size({"width": 912, "height": 440})
assert_menu_fits(selector)
bounds = menu.bounding_box()
page.mouse.move(bounds["x"] + bounds["width"] - 6, bounds["y"] + 32)
page.mouse.down()
page.mouse.move(bounds["x"] + bounds["width"] - 6, bounds["y"] + bounds["height"] - 18, steps=12)
page.mouse.up()
page.wait_for_function("""selector => {
const el = document.querySelector(selector);
return el.scrollTop >= el.scrollHeight - el.clientHeight - 1;
}""", arg=selector)
menu.hover()
page.mouse.wheel(0, -10000)
page.wait_for_function("selector => document.querySelector(selector).scrollTop === 0", arg=selector)
page.locator(trigger).click()
# The original panel must also fit when opened through the overflow menu.
page.set_viewport_size({"width": 390, "height": 440})
page.evaluate("document.getElementById('composer').style.width = '80px'")
more = page.get_by_role("button", name="More controls", exact=True)
playwright.expect(more).to_be_visible()
more.click()
page.get_by_role("menuitem", name="Preset 0").click()
page.wait_for_selector(".model-switcher-dropdown:popover-open")
assert_menu_fits(".model-switcher-dropdown")
last_preset = page.locator(".model-switcher-preset-name").get_by_text("Preset 19", exact=True)
last_preset.scroll_into_view_if_needed()
last_preset.click()
playwright.expect(page.locator(".model-switcher-label")).to_have_text("Preset 19")
assert errors == []
browser.close()
def test_browser_zoom_does_not_create_false_overflow():
html = """<!doctype html><html><head>
<link rel="stylesheet" href="/index.css">