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.

Alessandro committed Jul 1, 2026 at 16:25 UTC 8ecd7a7f52d537e498321a5ad023d3c7c53ddac8
7 files changed +139 -66
tests/test_file_browser_navigation.py
+22 -1
@@ -123,11 +123,32 @@ def test_file_browser_editor_picker_modes_have_primary_footer_actions() -> None:
123 assert "Open in Editor action visible outside the overflow menu" in dox
124
125 editor_button_index = html.index("file-editor-open-action")
126 - dropdown_menu_index = html.index('class="dropdown-menu"')
126 + dropdown_menu_index = html.index('class="dropdown-menu file-actions-menu"')
127 assert editor_button_index < dropdown_menu_index
128 assert 'x-show="$store.fileBrowser.canOpenInActionMenu(file)"' in html
129
130
131 +def test_file_browser_dropdown_escapes_scroll_container_and_header_is_opaque() -> None:
132 + html = read("webui", "components", "modals", "file-browser", "file-browser.html")
133 + store = read("webui", "components", "modals", "file-browser", "file-browser-store.js")
134 +
135 + assert '<div class="files-list" @scroll="$store.fileBrowser.closeDropdown()">' in html
136 + assert 'overflow: auto;' in html
137 + assert 'x-teleport="body"' in html
138 + assert 'class="dropdown-menu file-actions-menu"' in html
139 + assert ':style="$store.fileBrowser.dropdownStyle"' in html
140 + assert '@click.stop="$store.fileBrowser.toggleDropdown(file.path, $event.currentTarget)"' in html
141 + assert "getDropdownStyle(triggerElement)" in store
142 + assert 'position: "fixed"' in store
143 + assert 'zIndex: "6000"' in store
144 +
145 + assert "var(--secondary-bg)" not in html
146 + assert "var(--border-color)" not in html
147 + assert "var(--text-secondary)" not in html
148 + assert "background: color-mix(in srgb, var(--color-panel) 88%, var(--color-background) 12%);" in html
149 + assert "border-bottom: 1px solid var(--color-border);" in html
150 +
151 +
152 def test_file_browser_empty_api_path_uses_default_workdir_contract() -> None:
153 api_source = read("api", "get_work_dir_files.py")
154 api_dox = read("api", "get_work_dir_files.py.dox.md")
tests/test_webui_component_loader.py new
+17
@@ -0,0 +1,17 @@
1 +from pathlib import Path
2 +
3 +
4 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
5 +
6 +
7 +def read(*parts: str) -> str:
8 + return PROJECT_ROOT.joinpath(*parts).read_text(encoding="utf-8")
9 +
10 +
11 +def test_component_loader_deduplicates_body_assets() -> None:
12 + source = read("webui", "js", "components.js")
13 +
14 + assert 'const componentAssetSelector = "style, script, link[rel=\'stylesheet\']";' in source
15 + assert "...doc.querySelectorAll(componentAssetSelector)" in source
16 + assert "...Array.from(doc.body.childNodes).filter(" in source
17 + assert "(node) => !node.matches?.(componentAssetSelector)" in source
webui/components/modals/file-browser/AGENTS.md
+1
@@ -19,6 +19,7 @@
19 - Empty mounted startup states must self-heal to the `$WORK_DIR` default instead of rendering a blank path and empty list.
20 - 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.
21 - Keep the row-level Open in Editor action visible outside the overflow menu for Editor-owned `.md` and `.txt` files.
22 +- 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.
23 - Keep the file list readable in narrow canvas/modal containers by hiding the Modified date column before sacrificing the Name or Size columns.
24 - Keep New file and New folder controls icon-only across canvas and modal modes while preserving accessible labels.
25 - Keep narrow mobile controls compact: Up shares the path row, and New file/New folder share the search row.
webui/components/modals/file-browser/file-browser-store.js
+35 -2
@@ -88,6 +88,7 @@ const model = {
88 renamePerformAction: null,
89 renameValidateName: null,
90 openDropdownPath: null, // Track which dropdown is currently open
91 + dropdownStyle: {},
92 searchQuery: "",
93 isBulkBusy: false,
94 pickerMode: PICKER_MODE_NONE,
@@ -783,9 +784,14 @@ const model = {
784 },
785
786 // --- Dropdown Management -------------------------------------------------
786 - toggleDropdown(filePath) {
787 + toggleDropdown(filePath, triggerElement = null) {
788 // Toggle: if already open, close it; otherwise open this one (closing any other)
788 - this.openDropdownPath = this.openDropdownPath === filePath ? null : filePath;
789 + if (this.openDropdownPath === filePath) {
790 + this.closeDropdown();
791 + return;
792 + }
793 + this.openDropdownPath = filePath;
794 + this.dropdownStyle = this.getDropdownStyle(triggerElement);
795 },
796
797 isDropdownOpen(filePath) {
@@ -794,6 +800,33 @@ const model = {
800
801 closeDropdown() {
802 this.openDropdownPath = null;
803 + this.dropdownStyle = {};
804 + },
805 +
806 + getDropdownStyle(triggerElement) {
807 + if (!triggerElement) return {};
808 +
809 + const rect = triggerElement.getBoundingClientRect();
810 + const gap = 6;
811 + const padding = 8;
812 + const minWidth = 180;
813 + const spaceBelow = window.innerHeight - rect.bottom - gap - padding;
814 + const spaceAbove = rect.top - gap - padding;
815 + const openUp = spaceBelow < 160 && spaceAbove > spaceBelow;
816 + const maxHeight = Math.max(96, openUp ? spaceAbove : spaceBelow);
817 + const maxLeft = Math.max(padding, window.innerWidth - minWidth - padding);
818 + const left = Math.min(Math.max(rect.right - minWidth, padding), maxLeft);
819 +
820 + return {
821 + position: "fixed",
822 + left: `${Math.round(left)}px`,
823 + right: "auto",
824 + top: openUp ? "auto" : `${Math.round(rect.bottom + gap)}px`,
825 + bottom: openUp ? `${Math.round(window.innerHeight - rect.top + gap)}px` : "auto",
826 + minWidth: `${minWidth}px`,
827 + maxHeight: `${Math.round(maxHeight)}px`,
828 + zIndex: "6000",
829 + };
830 },
831
832 // --- Navigation ----------------------------------------------------------
webui/components/modals/file-browser/file-browser.html
+57 -59
@@ -194,7 +194,7 @@
194 </div>
195
196 <!-- Files list -->
197 - <div class="files-list">
197 + <div class="files-list" @scroll="$store.fileBrowser.closeDropdown()">
198 <div class="file-header">
199 <div class="file-cell-select">
200 <input
@@ -252,7 +252,7 @@
252 <button
253 type="button"
254 class="btn-icon-action dropdown-trigger"
255 - @click.stop="$store.fileBrowser.toggleDropdown(file.path)"
255 + @click.stop="$store.fileBrowser.toggleDropdown(file.path, $event.currentTarget)"
256 :aria-expanded="$store.fileBrowser.isDropdownOpen(file.path).toString()"
257 aria-label="More actions"
258 title="More actions"
@@ -260,54 +260,56 @@
260 <span class="material-symbols-outlined">more_vert</span>
261 </button>
262
263 - <div
264 - class="dropdown-menu"
265 - x-show="$store.fileBrowser.isDropdownOpen(file.path)"
266 - x-transition
267 - :class="{ 'bottom': $el.getBoundingClientRect().bottom > window.innerHeight - 100 }"
268 - style="display: none;"
269 - @click="$store.fileBrowser.closeDropdown()"
270 - >
271 - <button
272 - type="button"
273 - class="dropdown-item"
274 - x-show="$store.fileBrowser.canOpenInActionMenu(file)"
275 - @click="$store.fileBrowser.openInSurface(file)"
276 - :title="$store.fileBrowser.surfaceActionTitle(file)"
277 - >
278 - <span class="material-symbols-outlined" x-text="$store.fileBrowser.surfaceActionIcon(file)"></span>
279 - <span x-text="$store.fileBrowser.surfaceActionLabel(file)"></span>
280 - </button>
281 -
282 - <button
283 - type="button"
284 - class="dropdown-item"
285 - x-show="!file.is_dir && file.size <= 1048576 && !$store.fileBrowser.canOpenInSurface(file)"
286 - @click="$store.fileBrowser.openFileEditor(file)"
287 - >
288 - <span class="material-symbols-outlined">file_open</span>
289 - <span>Edit</span>
290 - </button>
291 -
292 - <button
293 - type="button"
294 - class="dropdown-item"
295 - x-show="file.is_dir"
296 - @click="$store.fileBrowser.downloadFile(file)"
297 - >
298 - <span class="material-symbols-outlined">folder_zip</span>
299 - <span>Download ZIP</span>
300 - </button>
301 -
302 - <button
303 - type="button"
304 - class="dropdown-item"
305 - @click="$store.fileBrowser.openRenameModal(file)"
263 + <template x-teleport="body">
264 + <div
265 + class="dropdown-menu file-actions-menu"
266 + x-show="$store.fileBrowser.isDropdownOpen(file.path)"
267 + x-transition
268 + :style="$store.fileBrowser.dropdownStyle"
269 + style="display: none;"
270 + @click.stop="$store.fileBrowser.closeDropdown()"
271 >
307 - <span class="material-symbols-outlined">drive_file_rename_outline</span>
308 - <span>Rename</span>
309 - </button>
310 - </div>
272 + <button
273 + type="button"
274 + class="dropdown-item"
275 + x-show="$store.fileBrowser.canOpenInActionMenu(file)"
276 + @click="$store.fileBrowser.openInSurface(file)"
277 + :title="$store.fileBrowser.surfaceActionTitle(file)"
278 + >
279 + <span class="material-symbols-outlined" x-text="$store.fileBrowser.surfaceActionIcon(file)"></span>
280 + <span x-text="$store.fileBrowser.surfaceActionLabel(file)"></span>
281 + </button>
282 +
283 + <button
284 + type="button"
285 + class="dropdown-item"
286 + x-show="!file.is_dir && file.size <= 1048576 && !$store.fileBrowser.canOpenInSurface(file)"
287 + @click="$store.fileBrowser.openFileEditor(file)"
288 + >
289 + <span class="material-symbols-outlined">file_open</span>
290 + <span>Edit</span>
291 + </button>
292 +
293 + <button
294 + type="button"
295 + class="dropdown-item"
296 + x-show="file.is_dir"
297 + @click="$store.fileBrowser.downloadFile(file)"
298 + >
299 + <span class="material-symbols-outlined">folder_zip</span>
300 + <span>Download ZIP</span>
301 + </button>
302 +
303 + <button
304 + type="button"
305 + class="dropdown-item"
306 + @click="$store.fileBrowser.openRenameModal(file)"
307 + >
308 + <span class="material-symbols-outlined">drive_file_rename_outline</span>
309 + <span>Rename</span>
310 + </button>
311 + </div>
312 + </template>
313 </div>
314 <button class="btn-icon-action" x-show="!file.is_dir" @click.stop="$store.fileBrowser.downloadFile(file)" title="Download file">
315 <span class="material-symbols-outlined">download</span>
@@ -520,7 +522,6 @@
522 .files-list {
523 width: 100%;
524 border-radius: 4px;
523 - /* Removed overflow: hidden to allow dropdown menus to be visible */
525 }
526
527 .file-browser-shell.is-surface .files-list {
@@ -558,10 +559,10 @@
559 overflow: hidden;
560 display: grid;
561 grid-template-columns: 2.5rem minmax(0, 1.5fr) minmax(5.5rem, 0.7fr) minmax(9rem, 1fr) 8.75rem;
561 - background: var(--secondary-bg);
562 + background: color-mix(in srgb, var(--color-panel) 88%, var(--color-background) 12%);
563 padding: 8px 0;
564 font-weight: bold;
564 - border-bottom: 1px solid var(--border-color);
565 + border-bottom: 1px solid var(--color-border);
566 color: var(--color-primary);
567 }
568 .file-cell-select,
@@ -637,13 +638,13 @@
638 }
639 .file-size,
640 .file-date {
640 - color: var(--text-secondary);
641 + color: var(--color-text-secondary);
642 }
643 /* No Files Message */
644 .no-files {
645 padding: 32px;
646 text-align: center;
646 - color: var(--text-secondary);
647 + color: var(--color-text-secondary);
648 }
649 .no-files .btn-clear-search {
650 display: inline-flex;
@@ -974,11 +975,8 @@
975 border-color: color-mix(in srgb, var(--color-primary) 52%, var(--color-border));
976 background: color-mix(in srgb, var(--color-primary) 10%, transparent);
977 }
977 - .file-actions .dropdown-menu {
978 - top: auto !important;
979 - bottom: 100% !important;
980 - margin-top: 0;
981 - margin-bottom: var(--spacing-xs);
978 + .file-actions-menu {
979 + margin: 0;
980 }
981 .btn-new-item {
982 display: inline-flex;
webui/js/AGENTS.md
+1
@@ -36,6 +36,7 @@
36 - `scrollModal(id)` scrolls inside the top modal's `.modal-scroll`.
37 - Keep extension loader cache keys and extension point names stable for plugins.
38 - HTML extension loading turns discovered HTML files into `<x-component>` tags; JavaScript extensions must export a default function.
39 +- `<x-component>` loading must process component `style`, `script`, and stylesheet-link assets only once, even when a component keeps its scoped `<style>` inside `<body>`.
40 - Frontend extension hooks such as `confirm_dialog_after_render` and `get_tool_message_handler` must preserve their mutable context contracts.
41 - Sanitize or safely render user/model-provided HTML and markdown.
42 - Do not expose secrets in localStorage, console logs, URLs, or WebSocket payloads.
webui/js/components.js
+6 -4
@@ -50,10 +50,12 @@ export async function importComponent(path, targetElement) {
50 const parser = new DOMParser();
51 const doc = parser.parseFromString(html, "text/html");
52
53 + const componentAssetSelector = "style, script, link[rel='stylesheet']";
54 const allNodes = [
54 - ...doc.querySelectorAll("style"),
55 - ...doc.querySelectorAll("script"),
56 - ...doc.body.childNodes,
55 + ...doc.querySelectorAll(componentAssetSelector),
56 + ...Array.from(doc.body.childNodes).filter(
57 + (node) => !node.matches?.(componentAssetSelector)
58 + ),
59 ];
60
61 const loadPromises = [];
@@ -264,4 +266,4 @@ const observer = new MutationObserver((mutations) => {
266 }
267 }
268 });
267 -observer.observe(document.body, { childList: true, subtree: true });
\ No newline at end of file
269 +observer.observe(document.body, { childList: true, subtree: true });