diff --git a/tests/test_file_browser_navigation.py b/tests/test_file_browser_navigation.py
index 4ef4ebf84..68bb91339 100644
--- a/tests/test_file_browser_navigation.py
+++ b/tests/test_file_browser_navigation.py
@@ -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 '
' 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")
diff --git a/tests/test_webui_component_loader.py b/tests/test_webui_component_loader.py
new file mode 100644
index 000000000..7ecf2fa3a
--- /dev/null
+++ b/tests/test_webui_component_loader.py
@@ -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
diff --git a/webui/components/modals/file-browser/AGENTS.md b/webui/components/modals/file-browser/AGENTS.md
index f1dcb53f6..a17eee0d7 100644
--- a/webui/components/modals/file-browser/AGENTS.md
+++ b/webui/components/modals/file-browser/AGENTS.md
@@ -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.
diff --git a/webui/components/modals/file-browser/file-browser-store.js b/webui/components/modals/file-browser/file-browser-store.js
index 74c8c1277..32cae8c34 100644
--- a/webui/components/modals/file-browser/file-browser-store.js
+++ b/webui/components/modals/file-browser/file-browser-store.js
@@ -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 ----------------------------------------------------------
diff --git a/webui/components/modals/file-browser/file-browser.html b/webui/components/modals/file-browser/file-browser.html
index 25d4a11be..f91966304 100644
--- a/webui/components/modals/file-browser/file-browser.html
+++ b/webui/components/modals/file-browser/file-browser.html
@@ -194,7 +194,7 @@
-