mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-10-19 07:23:51 +00:00
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:
parent
29288a8acc
commit
c946b3c347
4 changed files with 119 additions and 4 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue