mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-09 17:08:29 +00:00
Fix file browser action menu clipping
Render file action menus outside the scroll container with fixed positioning so dropdowns remain visible while file list scrolling stays intact. Deduplicate component body assets during x-component loading and add regressions for the menu, opaque header, and duplicate scoped styles.
This commit is contained in:
parent
0f7429daef
commit
8ecd7a7f52
7 changed files with 136 additions and 63 deletions
|
|
@ -123,11 +123,32 @@ def test_file_browser_editor_picker_modes_have_primary_footer_actions() -> None:
|
|||
assert "Open in Editor action visible outside the overflow menu" in dox
|
||||
|
||||
editor_button_index = html.index("file-editor-open-action")
|
||||
dropdown_menu_index = html.index('class="dropdown-menu"')
|
||||
dropdown_menu_index = html.index('class="dropdown-menu file-actions-menu"')
|
||||
assert editor_button_index < dropdown_menu_index
|
||||
assert 'x-show="$store.fileBrowser.canOpenInActionMenu(file)"' in html
|
||||
|
||||
|
||||
def test_file_browser_dropdown_escapes_scroll_container_and_header_is_opaque() -> None:
|
||||
html = read("webui", "components", "modals", "file-browser", "file-browser.html")
|
||||
store = read("webui", "components", "modals", "file-browser", "file-browser-store.js")
|
||||
|
||||
assert '<div class="files-list" @scroll="$store.fileBrowser.closeDropdown()">' in html
|
||||
assert 'overflow: auto;' in html
|
||||
assert 'x-teleport="body"' in html
|
||||
assert 'class="dropdown-menu file-actions-menu"' in html
|
||||
assert ':style="$store.fileBrowser.dropdownStyle"' in html
|
||||
assert '@click.stop="$store.fileBrowser.toggleDropdown(file.path, $event.currentTarget)"' in html
|
||||
assert "getDropdownStyle(triggerElement)" in store
|
||||
assert 'position: "fixed"' in store
|
||||
assert 'zIndex: "6000"' in store
|
||||
|
||||
assert "var(--secondary-bg)" not in html
|
||||
assert "var(--border-color)" not in html
|
||||
assert "var(--text-secondary)" not in html
|
||||
assert "background: color-mix(in srgb, var(--color-panel) 88%, var(--color-background) 12%);" in html
|
||||
assert "border-bottom: 1px solid var(--color-border);" in html
|
||||
|
||||
|
||||
def test_file_browser_empty_api_path_uses_default_workdir_contract() -> None:
|
||||
api_source = read("api", "get_work_dir_files.py")
|
||||
api_dox = read("api", "get_work_dir_files.py.dox.md")
|
||||
|
|
|
|||
17
tests/test_webui_component_loader.py
Normal file
17
tests/test_webui_component_loader.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(*parts: str) -> str:
|
||||
return PROJECT_ROOT.joinpath(*parts).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_component_loader_deduplicates_body_assets() -> None:
|
||||
source = read("webui", "js", "components.js")
|
||||
|
||||
assert 'const componentAssetSelector = "style, script, link[rel=\'stylesheet\']";' in source
|
||||
assert "...doc.querySelectorAll(componentAssetSelector)" in source
|
||||
assert "...Array.from(doc.body.childNodes).filter(" in source
|
||||
assert "(node) => !node.matches?.(componentAssetSelector)" in source
|
||||
|
|
@ -19,6 +19,7 @@
|
|||
- Empty mounted startup states must self-heal to the `$WORK_DIR` default instead of rendering a blank path and empty list.
|
||||
- Preserve picker modes for Editor Open and Save As: Editor Open selects one or more Markdown or plain text files with a pinned primary action, and Save As selects the current folder plus a `.md` or `.txt` file name.
|
||||
- Keep the row-level Open in Editor action visible outside the overflow menu for Editor-owned `.md` and `.txt` files.
|
||||
- Keep row action menus visible without disabling file-list scrolling; menus may float outside the scroll container but must still close on outside click, Escape, action click, and list scroll.
|
||||
- Keep the file list readable in narrow canvas/modal containers by hiding the Modified date column before sacrificing the Name or Size columns.
|
||||
- Keep New file and New folder controls icon-only across canvas and modal modes while preserving accessible labels.
|
||||
- Keep narrow mobile controls compact: Up shares the path row, and New file/New folder share the search row.
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ const model = {
|
|||
renamePerformAction: null,
|
||||
renameValidateName: null,
|
||||
openDropdownPath: null, // Track which dropdown is currently open
|
||||
dropdownStyle: {},
|
||||
searchQuery: "",
|
||||
isBulkBusy: false,
|
||||
pickerMode: PICKER_MODE_NONE,
|
||||
|
|
@ -783,9 +784,14 @@ const model = {
|
|||
},
|
||||
|
||||
// --- Dropdown Management -------------------------------------------------
|
||||
toggleDropdown(filePath) {
|
||||
toggleDropdown(filePath, triggerElement = null) {
|
||||
// Toggle: if already open, close it; otherwise open this one (closing any other)
|
||||
this.openDropdownPath = this.openDropdownPath === filePath ? null : filePath;
|
||||
if (this.openDropdownPath === filePath) {
|
||||
this.closeDropdown();
|
||||
return;
|
||||
}
|
||||
this.openDropdownPath = filePath;
|
||||
this.dropdownStyle = this.getDropdownStyle(triggerElement);
|
||||
},
|
||||
|
||||
isDropdownOpen(filePath) {
|
||||
|
|
@ -794,6 +800,33 @@ const model = {
|
|||
|
||||
closeDropdown() {
|
||||
this.openDropdownPath = null;
|
||||
this.dropdownStyle = {};
|
||||
},
|
||||
|
||||
getDropdownStyle(triggerElement) {
|
||||
if (!triggerElement) return {};
|
||||
|
||||
const rect = triggerElement.getBoundingClientRect();
|
||||
const gap = 6;
|
||||
const padding = 8;
|
||||
const minWidth = 180;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - gap - padding;
|
||||
const spaceAbove = rect.top - gap - padding;
|
||||
const openUp = spaceBelow < 160 && spaceAbove > spaceBelow;
|
||||
const maxHeight = Math.max(96, openUp ? spaceAbove : spaceBelow);
|
||||
const maxLeft = Math.max(padding, window.innerWidth - minWidth - padding);
|
||||
const left = Math.min(Math.max(rect.right - minWidth, padding), maxLeft);
|
||||
|
||||
return {
|
||||
position: "fixed",
|
||||
left: `${Math.round(left)}px`,
|
||||
right: "auto",
|
||||
top: openUp ? "auto" : `${Math.round(rect.bottom + gap)}px`,
|
||||
bottom: openUp ? `${Math.round(window.innerHeight - rect.top + gap)}px` : "auto",
|
||||
minWidth: `${minWidth}px`,
|
||||
maxHeight: `${Math.round(maxHeight)}px`,
|
||||
zIndex: "6000",
|
||||
};
|
||||
},
|
||||
|
||||
// --- Navigation ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Files list -->
|
||||
<div class="files-list">
|
||||
<div class="files-list" @scroll="$store.fileBrowser.closeDropdown()">
|
||||
<div class="file-header">
|
||||
<div class="file-cell-select">
|
||||
<input
|
||||
|
|
@ -252,7 +252,7 @@
|
|||
<button
|
||||
type="button"
|
||||
class="btn-icon-action dropdown-trigger"
|
||||
@click.stop="$store.fileBrowser.toggleDropdown(file.path)"
|
||||
@click.stop="$store.fileBrowser.toggleDropdown(file.path, $event.currentTarget)"
|
||||
:aria-expanded="$store.fileBrowser.isDropdownOpen(file.path).toString()"
|
||||
aria-label="More actions"
|
||||
title="More actions"
|
||||
|
|
@ -260,54 +260,56 @@
|
|||
<span class="material-symbols-outlined">more_vert</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="dropdown-menu"
|
||||
x-show="$store.fileBrowser.isDropdownOpen(file.path)"
|
||||
x-transition
|
||||
:class="{ 'bottom': $el.getBoundingClientRect().bottom > window.innerHeight - 100 }"
|
||||
style="display: none;"
|
||||
@click="$store.fileBrowser.closeDropdown()"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item"
|
||||
x-show="$store.fileBrowser.canOpenInActionMenu(file)"
|
||||
@click="$store.fileBrowser.openInSurface(file)"
|
||||
:title="$store.fileBrowser.surfaceActionTitle(file)"
|
||||
<template x-teleport="body">
|
||||
<div
|
||||
class="dropdown-menu file-actions-menu"
|
||||
x-show="$store.fileBrowser.isDropdownOpen(file.path)"
|
||||
x-transition
|
||||
:style="$store.fileBrowser.dropdownStyle"
|
||||
style="display: none;"
|
||||
@click.stop="$store.fileBrowser.closeDropdown()"
|
||||
>
|
||||
<span class="material-symbols-outlined" x-text="$store.fileBrowser.surfaceActionIcon(file)"></span>
|
||||
<span x-text="$store.fileBrowser.surfaceActionLabel(file)"></span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item"
|
||||
x-show="$store.fileBrowser.canOpenInActionMenu(file)"
|
||||
@click="$store.fileBrowser.openInSurface(file)"
|
||||
:title="$store.fileBrowser.surfaceActionTitle(file)"
|
||||
>
|
||||
<span class="material-symbols-outlined" x-text="$store.fileBrowser.surfaceActionIcon(file)"></span>
|
||||
<span x-text="$store.fileBrowser.surfaceActionLabel(file)"></span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item"
|
||||
x-show="!file.is_dir && file.size <= 1048576 && !$store.fileBrowser.canOpenInSurface(file)"
|
||||
@click="$store.fileBrowser.openFileEditor(file)"
|
||||
>
|
||||
<span class="material-symbols-outlined">file_open</span>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item"
|
||||
x-show="!file.is_dir && file.size <= 1048576 && !$store.fileBrowser.canOpenInSurface(file)"
|
||||
@click="$store.fileBrowser.openFileEditor(file)"
|
||||
>
|
||||
<span class="material-symbols-outlined">file_open</span>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item"
|
||||
x-show="file.is_dir"
|
||||
@click="$store.fileBrowser.downloadFile(file)"
|
||||
>
|
||||
<span class="material-symbols-outlined">folder_zip</span>
|
||||
<span>Download ZIP</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item"
|
||||
x-show="file.is_dir"
|
||||
@click="$store.fileBrowser.downloadFile(file)"
|
||||
>
|
||||
<span class="material-symbols-outlined">folder_zip</span>
|
||||
<span>Download ZIP</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item"
|
||||
@click="$store.fileBrowser.openRenameModal(file)"
|
||||
>
|
||||
<span class="material-symbols-outlined">drive_file_rename_outline</span>
|
||||
<span>Rename</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item"
|
||||
@click="$store.fileBrowser.openRenameModal(file)"
|
||||
>
|
||||
<span class="material-symbols-outlined">drive_file_rename_outline</span>
|
||||
<span>Rename</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<button class="btn-icon-action" x-show="!file.is_dir" @click.stop="$store.fileBrowser.downloadFile(file)" title="Download file">
|
||||
<span class="material-symbols-outlined">download</span>
|
||||
|
|
@ -520,7 +522,6 @@
|
|||
.files-list {
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
/* Removed overflow: hidden to allow dropdown menus to be visible */
|
||||
}
|
||||
|
||||
.file-browser-shell.is-surface .files-list {
|
||||
|
|
@ -558,10 +559,10 @@
|
|||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: 2.5rem minmax(0, 1.5fr) minmax(5.5rem, 0.7fr) minmax(9rem, 1fr) 8.75rem;
|
||||
background: var(--secondary-bg);
|
||||
background: color-mix(in srgb, var(--color-panel) 88%, var(--color-background) 12%);
|
||||
padding: 8px 0;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.file-cell-select,
|
||||
|
|
@ -637,13 +638,13 @@
|
|||
}
|
||||
.file-size,
|
||||
.file-date {
|
||||
color: var(--text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
/* No Files Message */
|
||||
.no-files {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.no-files .btn-clear-search {
|
||||
display: inline-flex;
|
||||
|
|
@ -974,11 +975,8 @@
|
|||
border-color: color-mix(in srgb, var(--color-primary) 52%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
}
|
||||
.file-actions .dropdown-menu {
|
||||
top: auto !important;
|
||||
bottom: 100% !important;
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
.file-actions-menu {
|
||||
margin: 0;
|
||||
}
|
||||
.btn-new-item {
|
||||
display: inline-flex;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
- `scrollModal(id)` scrolls inside the top modal's `.modal-scroll`.
|
||||
- Keep extension loader cache keys and extension point names stable for plugins.
|
||||
- HTML extension loading turns discovered HTML files into `<x-component>` tags; JavaScript extensions must export a default function.
|
||||
- `<x-component>` loading must process component `style`, `script`, and stylesheet-link assets only once, even when a component keeps its scoped `<style>` inside `<body>`.
|
||||
- Frontend extension hooks such as `confirm_dialog_after_render` and `get_tool_message_handler` must preserve their mutable context contracts.
|
||||
- Sanitize or safely render user/model-provided HTML and markdown.
|
||||
- Do not expose secrets in localStorage, console logs, URLs, or WebSocket payloads.
|
||||
|
|
|
|||
|
|
@ -50,10 +50,12 @@ export async function importComponent(path, targetElement) {
|
|||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
|
||||
const componentAssetSelector = "style, script, link[rel='stylesheet']";
|
||||
const allNodes = [
|
||||
...doc.querySelectorAll("style"),
|
||||
...doc.querySelectorAll("script"),
|
||||
...doc.body.childNodes,
|
||||
...doc.querySelectorAll(componentAssetSelector),
|
||||
...Array.from(doc.body.childNodes).filter(
|
||||
(node) => !node.matches?.(componentAssetSelector)
|
||||
),
|
||||
];
|
||||
|
||||
const loadPromises = [];
|
||||
|
|
@ -264,4 +266,4 @@ const observer = new MutationObserver((mutations) => {
|
|||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue