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