Add contextual file browser surface actions

Route Markdown files to Editor, txt and Office documents to Desktop, and browser-renderable files to Browser from the file browser action menu. Extend the Desktop/document allowlists for txt files, keep unsupported small files on the legacy editor path, and harden tooltip cleanup for dropdown-triggered modal closes.

Alessandro committed May 22, 2026 at 14:45 UTC 1c9b5c8b2167f81ff331e874bba9d431aa728b16
8 files changed +188 -11
plugins/_desktop/helpers/desktop_session.py
+1 -1
@@ -23,7 +23,7 @@ from plugins._desktop.helpers import desktop_state
23 from plugins._office.helpers import document_store, libreoffice
24
25
26 -OFFICIAL_EXTENSIONS = {"odt", "ods", "odp", "docx", "xlsx", "pptx"}
26 +OFFICIAL_EXTENSIONS = {"odt", "ods", "odp", "docx", "xlsx", "pptx", "txt"}
27 PLUGIN_NAME = "_desktop"
28 SYSTEM_SESSION_ID = "agent-zero-desktop"
29 SYSTEM_FILE_ID = "system-desktop"
plugins/_desktop/webui/desktop-store.js
+4 -3
@@ -42,7 +42,7 @@ function extensionOf(path = "") {
42 }
43
44 function isOfficialExtension(extension = "") {
45 - return ["odt", "ods", "odp", "docx", "xlsx", "pptx"].includes(String(extension || "").toLowerCase());
45 + return ["odt", "ods", "odp", "docx", "xlsx", "pptx", "txt"].includes(String(extension || "").toLowerCase());
46 }
47
48 function parentPath(path = "") {
@@ -579,7 +579,7 @@ const model = {
579 },
580
581 async openPath(path) {
582 - await this.openSession({ path: String(path || "") });
582 + return await this.openSession({ path: String(path || "") });
583 },
584
585 async openSession(payload = {}) {
@@ -1047,7 +1047,7 @@ const model = {
1047
1048 isBinaryOffice(tab = this.session) {
1049 const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
1050 - return ["odt", "ods", "odp", "docx", "xlsx", "pptx"].includes(ext);
1050 + return ["odt", "ods", "odp", "docx", "xlsx", "pptx", "txt"].includes(ext);
1051 },
1052
1053 hasOfficialOffice(tab = this.session) {
@@ -2321,6 +2321,7 @@ const model = {
2321 if (ext === "odt" || ext === "docx") return "description";
2322 if (ext === "ods" || ext === "xlsx") return "table_chart";
2323 if (ext === "odp" || ext === "pptx") return "co_present";
2324 + if (ext === "txt") return "notes";
2325 return "draft";
2326 },
2327
plugins/_editor/webui/editor-store.js
+1 -1
@@ -874,7 +874,7 @@ const model = {
874 },
875
876 async openPath(path) {
877 - await this.openSession({ path: String(path || "") });
877 + return await this.openSession({ path: String(path || "") });
878 },
879
880 async openSession(payload = {}) {
plugins/_office/helpers/document_store.py
+4 -1
@@ -23,7 +23,8 @@ from plugins._office.helpers import pptx_writer
23 PLUGIN_NAME = "_office"
24 OPEN_DOCUMENT_EXTENSIONS = {"odt", "ods", "odp"}
25 OOXML_EXTENSIONS = {"docx", "xlsx", "pptx"}
26 -SUPPORTED_EXTENSIONS = {"md", *OPEN_DOCUMENT_EXTENSIONS, *OOXML_EXTENSIONS}
26 +DESKTOP_TEXT_EXTENSIONS = {"txt"}
27 +SUPPORTED_EXTENSIONS = {"md", *OPEN_DOCUMENT_EXTENSIONS, *OOXML_EXTENSIONS, *DESKTOP_TEXT_EXTENSIONS}
28 DEFAULT_TTL_SECONDS = 8 * 60 * 60
29 MAX_SAVE_BYTES = 512 * 1024 * 1024
30 ODF_OFFICE_NS = "urn:oasis:names:tc:opendocument:xmlns:office:1.0"
@@ -636,6 +637,8 @@ def template_bytes(kind: str, ext: str, title: str, content: str) -> bytes:
637 ext = normalize_extension(ext or "md")
638 if ext == "md":
639 return _markdown(title, content).encode("utf-8")
640 + if ext == "txt":
641 + return (str(content or "") or str(title or "")).encode("utf-8")
642 if ext == "odt":
643 return odt_bytes(title, content)
644 if ext == "ods":
tests/test_office_document_store.py
+11
@@ -83,6 +83,17 @@ def test_document_store_create_defaults_to_markdown(office_state):
83 assert Path(doc["path"]).read_text(encoding="utf-8").startswith("# Research Note")
84
85
86 +def test_text_files_register_as_desktop_documents(office_state):
87 + path = office_state.workdir / "plain-note.txt"
88 + path.write_text("Plain text belongs on the Desktop surface.\n", encoding="utf-8")
89 +
90 + doc = document_store.register_document(path)
91 +
92 + assert doc["extension"] == "txt"
93 + assert "txt" in document_store.DESKTOP_TEXT_EXTENSIONS
94 + assert "txt" in desktop_session.OFFICIAL_EXTENSIONS
95 +
96 +
97 @pytest.mark.parametrize(
98 ("kind", "title", "fmt", "expected_name"),
99 [
webui/components/modals/file-browser/file-browser-store.js
+144
@@ -2,6 +2,47 @@ import { createStore } from "/js/AlpineStore.js";
2 import { fetchApi } from "/js/api.js";
3 import { formatDateTime } from "/js/time-utils.js";
4 import { store as fileEditorStore } from "/components/modals/file-editor/file-editor-store.js";
5 +import { openLatest as openLatestSurface } from "/js/surfaces.js";
6 +
7 +const MARKDOWN_EXTENSIONS = new Set(["md", "markdown", "mdown"]);
8 +const DESKTOP_EXTENSIONS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx", "txt"]);
9 +const BROWSER_EXTENSIONS = new Set([
10 + "html",
11 + "htm",
12 + "xhtml",
13 + "svg",
14 + "xml",
15 + "pdf",
16 + "png",
17 + "jpg",
18 + "jpeg",
19 + "gif",
20 + "webp",
21 + "bmp",
22 + "ico",
23 +]);
24 +
25 +const SURFACE_ACTIONS = {
26 + editor: {
27 + label: "Open in Editor",
28 + icon: "article",
29 + title: "Open Markdown in Editor",
30 + },
31 + desktop: {
32 + label: "Open in Desktop",
33 + icon: "desktop_windows",
34 + title: "Open document in Desktop",
35 + },
36 + browser: {
37 + label: "Open in Browser",
38 + icon: "language",
39 + title: "Open web-viewable file in Browser",
40 + },
41 +};
42 +
43 +function delay(ms) {
44 + return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
45 +}
46
47 // Model migrated from legacy file_browser.js (lift-and-shift)
48 const model = {
@@ -214,6 +255,57 @@ const model = {
255 return path.startsWith("/") ? path : `/${path}`;
256 },
257
258 + fileExtension(file = {}) {
259 + const name = String(file?.name || file?.path || "").split(/[?#]/, 1)[0].toLowerCase();
260 + const index = name.lastIndexOf(".");
261 + return index >= 0 ? name.slice(index + 1) : "";
262 + },
263 +
264 + fileSurfaceTarget(file = {}) {
265 + if (!file || file.is_dir) return "";
266 + const ext = this.fileExtension(file);
267 + if (MARKDOWN_EXTENSIONS.has(ext)) return "editor";
268 + if (BROWSER_EXTENSIONS.has(ext)) return "browser";
269 + if (DESKTOP_EXTENSIONS.has(ext)) return "desktop";
270 + return "";
271 + },
272 +
273 + canOpenInSurface(file = {}) {
274 + return Boolean(this.fileSurfaceTarget(file));
275 + },
276 +
277 + surfaceAction(file = {}) {
278 + const target = this.fileSurfaceTarget(file);
279 + return target ? SURFACE_ACTIONS[target] : null;
280 + },
281 +
282 + surfaceActionLabel(file = {}) {
283 + return this.surfaceAction(file)?.label || "Open";
284 + },
285 +
286 + surfaceActionIcon(file = {}) {
287 + return this.surfaceAction(file)?.icon || "open_in_new";
288 + },
289 +
290 + surfaceActionTitle(file = {}) {
291 + return this.surfaceAction(file)?.title || "Open file";
292 + },
293 +
294 + fileUrl(file = {}) {
295 + const path = this.normalizePath(String(file?.path || ""));
296 + const encodedPath = path
297 + .split("/")
298 + .map((part) => encodeURIComponent(part))
299 + .join("/");
300 + return `file://${encodedPath}`;
301 + },
302 +
303 + storeHasPath(surfaceStore = {}, path = "") {
304 + const normalizedPath = this.normalizePath(path);
305 + const activePath = surfaceStore?.session?.path || surfaceStore?.session?.document?.path || "";
306 + return this.normalizePath(activePath) === normalizedPath;
307 + },
308 +
309 buildChildPath(name) {
310 const base = this.normalizePath(this.browser.currentPath || "");
311 const trimmedBase = base.replace(/\/$/, "");
@@ -720,6 +812,58 @@ const model = {
812 return store._handleFileUpload(event); // bind to model to ensure correct context
813 },
814
815 + async openInSurface(file = {}) {
816 + const target = this.fileSurfaceTarget(file);
817 + const path = this.normalizePath(String(file?.path || ""));
818 + if (!target || !path) return;
819 +
820 + this.closeDropdown();
821 +
822 + try {
823 + if (target === "browser") {
824 + const url = this.fileUrl(file);
825 + const { store: browserStore } = await import("/plugins/_browser/webui/browser-store.js");
826 + await openLatestSurface("browser", { url, source: "file-browser" });
827 +
828 + let opened = false;
829 + for (let attempt = 0; attempt < 40 && !opened; attempt += 1) {
830 + opened = await browserStore.openUrlIntent(url, { source: "file-browser" });
831 + if (!opened) await delay(75);
832 + }
833 + if (!opened) {
834 + throw new Error("Browser surface is unavailable.");
835 + }
836 + } else {
837 + await openLatestSurface(target, { path, source: "file-browser" });
838 + if (target === "editor") {
839 + const { store: editorStore } = await import("/plugins/_editor/webui/editor-store.js");
840 + if (!this.storeHasPath(editorStore, path)) {
841 + const session = await editorStore.openPath(path);
842 + if (!session || session.ok === false) {
843 + throw new Error(editorStore.error || "Markdown could not be opened.");
844 + }
845 + }
846 + }
847 + if (target === "desktop") {
848 + const { store: desktopStore } = await import("/plugins/_desktop/webui/desktop-store.js");
849 + if (!this.storeHasPath(desktopStore, path)) {
850 + const session = await desktopStore.openPath(path);
851 + if (!session || session.ok === false) {
852 + throw new Error(desktopStore.error || "Document could not be opened.");
853 + }
854 + }
855 + }
856 + }
857 +
858 + await window.closeModal?.("modals/file-browser/file-browser.html");
859 + } catch (error) {
860 + window.toastFrontendError?.(
861 + error?.message || "Could not open file",
862 + "File Browser"
863 + );
864 + }
865 + },
866 +
867 async _handleFileUpload(event) {
868 try {
869 const files = event.target.files;
webui/components/modals/file-browser/file-browser.html
+12 -1
@@ -199,7 +199,18 @@
199 <button
200 type="button"
201 class="dropdown-item"
202 - x-show="!file.is_dir && file.size <= 1048576"
202 + x-show="$store.fileBrowser.canOpenInSurface(file)"
203 + @click="$store.fileBrowser.openInSurface(file)"
204 + :title="$store.fileBrowser.surfaceActionTitle(file)"
205 + >
206 + <span class="material-symbols-outlined" x-text="$store.fileBrowser.surfaceActionIcon(file)"></span>
207 + <span x-text="$store.fileBrowser.surfaceActionLabel(file)"></span>
208 + </button>
209 +
210 + <button
211 + type="button"
212 + class="dropdown-item"
213 + x-show="!file.is_dir && file.size <= 1048576 && !$store.fileBrowser.canOpenInSurface(file)"
214 @click="$store.fileBrowser.openFileEditor(file)"
215 >
216 <span class="material-symbols-outlined">file_open</span>
webui/components/tooltips/tooltip-store.js
+11 -4
@@ -47,6 +47,16 @@ function initBootstrapTooltips(root = document) {
47 tooltipTargets.forEach((element) => ensureBootstrapTooltip(element));
48 }
49
50 +function disposeBootstrapTooltip(element) {
51 + const instance = globalThis.bootstrap?.Tooltip?.getInstance(element);
52 + if (!instance) return;
53 + try {
54 + instance.dispose();
55 + } catch {
56 + // Bootstrap 5 can throw while disposing an already-torn-down tooltip node.
57 + }
58 +}
59 +
60 function observeBootstrapTooltips() {
61 if (!globalThis.bootstrap?.Tooltip) return;
62
@@ -75,10 +85,7 @@ function observeBootstrapTooltips() {
85 );
86 tooltipElements.forEach((el) => {
87 if (el.isConnected) return;
78 - const instance = globalThis.bootstrap?.Tooltip?.getInstance(el);
79 - if (instance) {
80 - instance.dispose();
81 - }
88 + disposeBootstrapTooltip(el);
89 });
90 });
91