Promote Files into shared canvas surface

Add Files to the universal canvas rail and sidebar flow, with a reusable floating surface modal chrome that matches Browser/Desktop behavior. Make the File Browser modal draggable/resizable with Focus mode, keep Editor on the same draggable modal helper, and preserve dock/undock handoff state. Harden File Browser startup so empty paths resolve to the default workdir, restyle the Up control, compact New file/New folder actions, and hide Modified before Name/Size in narrow containers. Update DOX contracts and focused regression coverage for the new Files surface, modal chrome, default-path fallback, and compact layout behavior.

Alessandro committed Jun 23, 2026 at 11:19 UTC ffddc3ebcbedaa3f430d2c1ef8c669a8e144d3ad
20 files changed +879 -110
api/get_work_dir_files.py
+1 -1
@@ -9,7 +9,7 @@ class GetWorkDirFiles(ApiHandler):
9 return ["GET"]
10
11 async def process(self, input: dict, request: Request) -> dict | Response:
12 - current_path = request.args.get("path", "")
12 + current_path = request.args.get("path", "") or "$WORK_DIR"
13 if current_path == "$WORK_DIR":
14 # if runtime.is_development():
15 # current_path = "work_dir"
api/get_work_dir_files.py.dox.md
+1
@@ -29,6 +29,7 @@
29 ## Key Concepts
30
31 - Important called helpers/classes observed in the source: `FileBrowser`, `browser.get_files`, `runtime.call_development_function`.
32 +- Empty `path` requests and explicit `$WORK_DIR` requests resolve to the default workdir path before `FileBrowser` is called, so the WebUI never receives an empty startup path for the default file browser view.
33 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
34
35 ## Work Guidance
extensions/webui/AGENTS.md
+1
@@ -41,6 +41,7 @@ Direct child DOX files:
41 | [initFw_end/AGENTS.md](initFw_end/AGENTS.md) | Post-WebUI-framework-initialization extensions. |
42 | [json_api_call_after/AGENTS.md](json_api_call_after/AGENTS.md) | Frontend hooks after `callJsonApi()` calls. |
43 | [json_api_call_before/AGENTS.md](json_api_call_before/AGENTS.md) | Frontend hooks before `callJsonApi()` calls. |
44 +| [right-canvas-panels/AGENTS.md](right-canvas-panels/AGENTS.md) | Built-in right-canvas panel HTML contributions. |
45 | [right_canvas_register_surfaces/AGENTS.md](right_canvas_register_surfaces/AGENTS.md) | Built-in right-canvas surface registrations. |
46 | [set_messages_after_loop/AGENTS.md](set_messages_after_loop/AGENTS.md) | Frontend hooks after message DOM updates. |
47 | [set_messages_before_loop/AGENTS.md](set_messages_before_loop/AGENTS.md) | Frontend hooks before message DOM updates. |
extensions/webui/right-canvas-panels/AGENTS.md new
+28
@@ -0,0 +1,28 @@
1 +# Right Canvas Panel Extensions DOX
2 +
3 +## Purpose
4 +
5 +- Own built-in HTML panel contributions for the right-canvas surface area.
6 +
7 +## Ownership
8 +
9 +- `.html` files mount WebUI components into the `right-canvas-panels` extension point.
10 +- Panel wrappers own `data-surface-id` anchors and active/mounted visibility bindings.
11 +
12 +## Local Contracts
13 +
14 +- Each panel must correspond to a registered right-canvas surface ID.
15 +- Use `<x-component>` for reusable component content instead of duplicating panel implementations.
16 +- Keep canvas panels compatible with `.right-canvas-surface-panel` layout semantics.
17 +
18 +## Work Guidance
19 +
20 +- Prefer thin wrappers that delegate lifecycle and state to the owning component store.
21 +
22 +## Verification
23 +
24 +- Smoke-test opening the matching surface from the right-canvas rail.
25 +
26 +## Child DOX Index
27 +
28 +No child DOX files.
extensions/webui/right-canvas-panels/files-panel.html new
+11
@@ -0,0 +1,11 @@
1 +<div
2 + class="right-canvas-surface-panel files-canvas-surface"
3 + data-surface-id="files"
4 + :class="{
5 + 'is-active': $store.rightCanvas?.isSurfaceVisible('files'),
6 + 'is-mounted': $store.rightCanvas?.isSurfaceRendered('files')
7 + }"
8 + :aria-hidden="(!$store.rightCanvas?.isSurfaceVisible('files')).toString()"
9 +>
10 + <x-component path="modals/file-browser/file-browser.html" mode="canvas"></x-component>
11 +</div>
extensions/webui/right_canvas_register_surfaces/AGENTS.md
+1 -1
@@ -6,7 +6,7 @@
6
7 ## Ownership
8
9 -- JavaScript files own registration of remote link, space agent, and future core canvas surfaces.
9 +- JavaScript files own registration of remote link, space agent, file browser, and future core canvas surfaces.
10
11 ## Local Contracts
12
extensions/webui/right_canvas_register_surfaces/register-files.js new
+44
@@ -0,0 +1,44 @@
1 +import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
2 +
3 +function waitForElement(selector, timeoutMs = 3000) {
4 + const found = document.querySelector(selector);
5 + if (found) return Promise.resolve(found);
6 + return new Promise((resolve) => {
7 + const timeout = globalThis.setTimeout(() => {
8 + observer.disconnect();
9 + resolve(document.querySelector(selector));
10 + }, timeoutMs);
11 + const observer = new MutationObserver(() => {
12 + const element = document.querySelector(selector);
13 + if (!element) return;
14 + globalThis.clearTimeout(timeout);
15 + observer.disconnect();
16 + resolve(element);
17 + });
18 + observer.observe(document.body, { childList: true, subtree: true });
19 + });
20 +}
21 +
22 +export default async function registerFilesSurface(surfaces) {
23 + surfaces.registerSurface({
24 + id: "files",
25 + title: "Files",
26 + icon: "folder",
27 + order: 5,
28 + modalPath: "modals/file-browser/file-browser.html",
29 + beginDockHandoff() {
30 + fileBrowserStore.beginSurfaceHandoff?.();
31 + },
32 + finishDockHandoff(payload = {}) {
33 + fileBrowserStore.finishSurfaceHandoff?.(payload);
34 + },
35 + cancelDockHandoff() {
36 + fileBrowserStore.cancelSurfaceHandoff?.();
37 + },
38 + async open(payload = {}) {
39 + const panel = await waitForElement('[data-surface-id="files"] .file-browser-root');
40 + if (!panel) throw new Error("Files surface panel did not mount.");
41 + await fileBrowserStore.openSurface(payload.path || payload.filePath || payload.directory || "");
42 + },
43 + });
44 +}
plugins/_editor/AGENTS.md
+1
@@ -16,6 +16,7 @@
16
17 - Keep editor session state synchronized across API, WebSocket, and WebUI panel behavior.
18 - Do not expose unsaved content or local paths beyond intended chat/context surfaces.
19 +- Keep the floating Editor modal on the shared surface modal chrome so the header remains draggable while existing Focus mode continues to work.
20
21 ## Work Guidance
22
plugins/_editor/webui/editor-store.js
+16 -30
@@ -2,7 +2,10 @@ import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi } from "/js/api.js";
3 import { getNamespacedClient } from "/js/websocket.js";
4 import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
5 -import { placeSurfaceModalHeaderAction } from "/js/surfaces.js";
5 +import {
6 + placeSurfaceModalHeaderAction,
7 + setupFloatingSurfaceModalChrome,
8 +} from "/js/surfaces.js";
9 import {
10 buildMarkdownPages,
11 isExternalHref,
@@ -1658,38 +1661,21 @@ const model = {
1661 if (!inner || !header || inner.dataset.editorModalReady === "1") return;
1662 inner.dataset.editorModalReady = "1";
1663 inner.classList.add("editor-modal");
1661 - const cleanup = [];
1662 - const focusButton = document.createElement("button");
1663 - focusButton.type = "button";
1664 - focusButton.className = "surface-button editor-modal-focus-button";
1665 - focusButton.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">fullscreen</span>';
1666 - const updateFocusButton = (active) => {
1667 - const label = active ? "Restore size" : "Focus mode";
1668 - focusButton.setAttribute("aria-label", label);
1669 - focusButton.setAttribute("title", label);
1670 - focusButton.querySelector(".material-symbols-outlined").textContent = active ? "fullscreen_exit" : "fullscreen";
1671 - };
1672 - updateFocusButton(false);
1673 - const onFocusClick = () => {
1674 - const active = !inner.classList.contains("is-focus-mode");
1675 - inner.classList.toggle("is-focus-mode", active);
1676 - updateFocusButton(active);
1677 - };
1678 - focusButton.addEventListener("click", onFocusClick);
1679 - placeSurfaceModalHeaderAction(header, focusButton, "window");
1680 - cleanup.push(() => focusButton.removeEventListener("click", onFocusClick));
1681 - cleanup.push(() => focusButton.remove());
1682 -
1683 - this._headerCleanup = () => {
1684 - cleanup.splice(0).reverse().forEach((entry) => entry());
1685 - delete inner.dataset.editorModalReady;
1686 - inner.classList.remove("editor-modal", "is-focus-mode");
1687 - };
1664 + const floatingCleanup = setupFloatingSurfaceModalChrome({
1665 + root,
1666 + modalClass: "editor-modal",
1667 + focusButtonClass: "editor-modal-focus-button",
1668 + minWidth: 640,
1669 + minHeight: 460,
1670 + onBoundsChange: () => this.refreshSourceEditorLayout(),
1671 + onFocusChange: () => this.refreshSourceEditorLayout(),
1672 + });
1673 const menuCleanup = this.installHeaderNewMenu(header);
1689 - const previousCleanup = this._headerCleanup;
1674 this._headerCleanup = () => {
1675 menuCleanup?.();
1692 - previousCleanup?.();
1676 + floatingCleanup?.();
1677 + delete inner.dataset.editorModalReady;
1678 + inner.classList.remove("editor-modal", "is-focus-mode");
1679 };
1680 },
1681 };
tests/test_file_browser_navigation.py
+78
@@ -28,6 +28,13 @@ def test_file_browser_editable_path_bar_and_remembered_directory_contract() -> N
28 workdir_settings = read("webui", "components", "settings", "agent", "workdir.html")
29
30 assert 'class="path-navigator"' in html
31 + assert 'class="nav-button back-button"' in html
32 + assert 'class="text-button back-button"' not in html
33 + assert ".nav-button:focus-visible" in html
34 + assert ".nav-button .material-symbols-outlined" in html
35 + assert 'class="nav-button-label">Up</span>' in html
36 + assert "flex-direction: column;" in html
37 + assert ".nav-button-label" in html
38 assert 'x-model="$store.fileBrowser.pathInput"' in html
39 assert '@submit.prevent="$store.fileBrowser.submitPath()"' in html
40 assert "Go to directory" in html
@@ -39,6 +46,12 @@ def test_file_browser_editable_path_bar_and_remembered_directory_contract() -> N
46 assert "getRememberedDirectory()" in store
47 assert "rememberCurrentDirectory(this.browser.currentPath)" in store
48 assert "clearRememberedDirectory()" in store
49 + assert "scheduleMountedDefaultLoad()" in store
50 + assert 'this.browser.currentPath = "";' in store
51 + assert 'this.browser.parentPath = "";' in store
52 + assert 'const requestedPath = this.normalizeOpeningPath(path) || "$WORK_DIR";' in store
53 + assert "`/get_work_dir_files?path=${encodeURIComponent(requestedPath)}`" in store
54 + assert 'result.current_path || (requestedPath === "$WORK_DIR" ? "/a0" : requestedPath)' in store
55
56 explicit_path_index = store.index("const explicitPath = this.normalizeOpeningPath")
57 remembered_path_index = store.index("const rememberedPath = !explicitPath")
@@ -48,6 +61,71 @@ def test_file_browser_editable_path_bar_and_remembered_directory_contract() -> N
61 assert "$store.settings.settings.file_browser_remember_last_directory" in workdir_settings
62
63
64 +def test_file_browser_compact_controls_and_narrow_layout_contract() -> None:
65 + html = read("webui", "components", "modals", "file-browser", "file-browser.html")
66 + dox = read("webui", "components", "modals", "file-browser", "AGENTS.md")
67 +
68 + assert 'aria-label="New file"' in html
69 + assert 'title="New file"' in html
70 + assert 'aria-label="New folder"' in html
71 + assert 'title="New folder"' in html
72 + assert ">New File<" not in html
73 + assert ">New Folder<" not in html
74 + assert ".btn-new-item" in html
75 + assert "width: 2.8rem;" in html
76 + assert "height: 2.8rem;" in html
77 +
78 + assert "container: file-browser / inline-size;" in html
79 + assert "@container file-browser (max-width: 620px)" in html
80 + assert "grid-template-columns: 2.25rem minmax(0, 1fr) minmax(4.25rem, max-content) 5.25rem;" in html
81 + assert ".file-cell-date,\n .file-date {\n display: none;" in html
82 + assert ".file-cell-size,\n .file-size" not in html
83 +
84 + assert "hiding the Modified date column" in dox
85 + assert "New file and New folder controls icon-only" in dox
86 +
87 +
88 +def test_file_browser_empty_api_path_uses_default_workdir_contract() -> None:
89 + api_source = read("api", "get_work_dir_files.py")
90 + api_dox = read("api", "get_work_dir_files.py.dox.md")
91 +
92 + assert 'current_path = request.args.get("path", "") or "$WORK_DIR"' in api_source
93 + assert 'current_path = "/a0"' in api_source
94 + assert "Empty `path` requests and explicit `$WORK_DIR` requests resolve" in api_dox
95 +
96 +
97 +def test_file_browser_is_registered_as_right_canvas_surface() -> None:
98 + html = read("webui", "components", "modals", "file-browser", "file-browser.html")
99 + store = read("webui", "components", "modals", "file-browser", "file-browser-store.js")
100 + surfaces = read("webui", "js", "surfaces.js")
101 + register = read("extensions", "webui", "right_canvas_register_surfaces", "register-files.js")
102 + panel = read("extensions", "webui", "right-canvas-panels", "files-panel.html")
103 + input_store = read("webui", "components", "chat", "input", "input-store.js")
104 +
105 + assert 'id: "files"' in surfaces
106 + assert 'title: "Files"' in surfaces
107 + assert 'modalPath: "modals/file-browser/file-browser.html"' in surfaces
108 + assert 'await store.openSurface(payload.path || payload.filePath || payload.directory || "")' in surfaces
109 + assert 'data-surface-id="files"' in html
110 + assert 'data-surface-modal-path="modals/file-browser/file-browser.html"' in html
111 + assert 'class="surface-modal file-browser-modal modal-no-backdrop"' in html
112 + assert 'class="file-browser-modal-body"' in html
113 + assert 'x-create="$store.fileBrowser.onMount($el, xAttrs($el) || {})"' in html
114 + assert 'x-destroy="$store.fileBrowser.onUnmount(xAttrs($el) || {})"' in html
115 + assert ".modal-inner.file-browser-modal" in html
116 + assert "resize: both" in html
117 + assert "openSurface(path" in store
118 + assert "setupFloatingSurfaceModalChrome" in store
119 + assert 'focusButtonClass: "file-browser-modal-focus-button"' in store
120 + assert "beginSurfaceHandoff()" in store
121 + assert "finishSurfaceHandoff()" in store
122 + assert 'id: "files"' in register
123 + assert "fileBrowserStore.openSurface" in register
124 + assert 'data-surface-id="files"' in panel
125 + assert 'path="modals/file-browser/file-browser.html" mode="canvas"' in panel
126 + assert 'openLatestSurface("files"' in input_store
127 +
128 +
129 def test_file_browser_reports_missing_directory(tmp_path: Path) -> None:
130 missing_directory = tmp_path / "missing"
131
tests/test_office_canvas_setup.py
+30
@@ -201,6 +201,36 @@ def test_browser_surface_restores_focus_mode_chrome():
201 assert ".modal-inner.browser-modal.is-focus-mode" in browser_panel
202
203
204 +def test_files_and_editor_surface_modals_have_draggable_focus_chrome():
205 + surfaces_js = read("webui", "js", "surfaces.js")
206 + surfaces_css = read("webui", "css", "surfaces.css")
207 + file_store = read("webui", "components", "modals", "file-browser", "file-browser-store.js")
208 + file_modal = read("webui", "components", "modals", "file-browser", "file-browser.html")
209 + editor_store = read("plugins", "_editor", "webui", "editor-store.js")
210 + editor_panel = read("plugins", "_editor", "webui", "editor-panel.html")
211 +
212 + assert "setupFloatingSurfaceModalChrome" in surfaces_js
213 + assert "is-draggable-surface-modal" in surfaces_js
214 + assert "surface-modal-focus-button" in surfaces_js
215 + assert "fullscreen_exit" in surfaces_js
216 + assert "Focus mode" in surfaces_js
217 + assert "Restore size" in surfaces_js
218 + assert ".modal-inner.surface-modal.is-draggable-surface-modal .modal-header" in surfaces_css
219 + assert "cursor: move" in surfaces_css
220 + assert ".surface-modal-focus-button.is-active" in surfaces_css
221 +
222 + assert "setupFloatingSurfaceModalChrome" in file_store
223 + assert 'focusButtonClass: "file-browser-modal-focus-button"' in file_store
224 + assert ".modal-inner.file-browser-modal" in file_modal
225 + assert "resize: both" in file_modal
226 +
227 + assert "setupFloatingSurfaceModalChrome" in editor_store
228 + assert 'focusButtonClass: "editor-modal-focus-button"' in editor_store
229 + assert "onBoundsChange: () => this.refreshSourceEditorLayout()" in editor_store
230 + assert "editor.resize?.(true)" in editor_store
231 + assert ".modal-inner.editor-modal.is-focus-mode" in editor_panel
232 +
233 +
234 def test_office_frontend_is_document_only_and_does_not_import_browser_or_desktop_runtime_code():
235 office_store = read("plugins", "_office", "webui", "office-store.js")
236 office_panel = read("plugins", "_office", "webui", "office-panel.html")
webui/components/chat/input/input-store.js
+8 -1
@@ -1,6 +1,7 @@
1 import { createStore } from "/js/AlpineStore.js";
2 import * as shortcuts from "/js/shortcuts.js";
3 import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
4 +import { openLatest as openLatestSurface } from "/js/surfaces.js";
5 import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
6 import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
7 import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
@@ -330,7 +331,13 @@ const model = {
331 }
332 }
333 }
333 - await fileBrowserStore.open(path);
334 + let opened = false;
335 + try {
336 + opened = await openLatestSurface("files", { path, source: "sidebar" });
337 + } catch (error) {
338 + console.error("Error opening Files surface", error);
339 + }
340 + if (!opened) await fileBrowserStore.open(path);
341 },
342
343 focus() {
webui/components/modals/AGENTS.md
+5 -1
@@ -27,4 +27,8 @@
27
28 ## Child DOX Index
29
30 -No child DOX files.
30 +Direct child DOX files:
31 +
32 +| Child | Scope |
33 +| --- | --- |
34 +| [file-browser/AGENTS.md](file-browser/AGENTS.md) | File browser modal and right-canvas Files surface workflow. |
webui/components/modals/file-browser/AGENTS.md new
+36
@@ -0,0 +1,36 @@
1 +# File Browser Modal DOX
2 +
3 +## Purpose
4 +
5 +- Own the WebUI file browser workflow for modal and right-canvas Files surface entry points.
6 +
7 +## Ownership
8 +
9 +- `file-browser.html` owns file list markup, path/search controls, scoped styles, and modal/canvas footer behavior.
10 +- `file-browser-store.js` owns directory loading, remembered-location state, selection, upload/download/delete actions, and surface handoff state.
11 +- `rename-modal.html` owns rename and create-folder prompts that reuse the file-browser store.
12 +
13 +## Local Contracts
14 +
15 +- Keep `open(path)` as the modal entry point for workflows that await browser close.
16 +- Keep `openSurface(path)` as the right-canvas entry point; it must load files without opening or awaiting a modal.
17 +- The floating file-browser modal must use the shared surface modal chrome so it remains draggable/resizable and exposes Focus mode.
18 +- Preserve remembered-directory behavior: explicit paths win, then remembered path, then `$WORK_DIR`.
19 +- Empty mounted startup states must self-heal to the `$WORK_DIR` default instead of rendering a blank path and empty list.
20 +- Keep the file list readable in narrow canvas/modal containers by hiding the Modified date column before sacrificing the Name or Size columns.
21 +- Keep New file and New folder controls icon-only across canvas and modal modes while preserving accessible labels.
22 +- Preserve surface actions that route supported files to Browser, Desktop, or Editor.
23 +
24 +## Work Guidance
25 +
26 +- Share markup and store behavior between modal and canvas modes; branch only on explicit component `mode`.
27 +- Keep modal footer relocation compatible with `data-modal-footer` while allowing canvas mode to render inline controls.
28 +
29 +## Verification
30 +
31 +- Smoke-test opening Files as a modal and from the right-canvas rail.
32 +- Run targeted file-browser tests after behavior changes.
33 +
34 +## Child DOX Index
35 +
36 +No child DOX files.
webui/components/modals/file-browser/file-browser-store.js
+151 -38
@@ -2,8 +2,12 @@ import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi, 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";
5 +import {
6 + openLatest as openLatestSurface,
7 + setupFloatingSurfaceModalChrome,
8 +} from "/js/surfaces.js";
9
10 +const FILE_BROWSER_MODAL_PATH = "modals/file-browser/file-browser.html";
11 const FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY = "fileBrowser.lastDirectory";
12 const DEFAULT_REMEMBER_LAST_DIRECTORY = true;
13 const MARKDOWN_EXTENSIONS = new Set(["md", "markdown", "mdown"]);
@@ -61,6 +65,8 @@ const model = {
65 history: [], // navigation stack
66 initialPath: "", // Store path for open() call
67 closePromise: null,
68 + isSurfaceHandoff: false,
69 + surfaceHandoffPath: "",
70 error: null,
71 pathInput: "",
72 pathError: "",
@@ -68,6 +74,8 @@ const model = {
74 rememberLastDirectory: DEFAULT_REMEMBER_LAST_DIRECTORY,
75 settingsLoadPromise: null,
76 settingsUpdatedHandler: null,
77 + _floatingCleanup: null,
78 + _mountedDefaultLoadTimer: null,
79 renameTarget: null,
80 renameName: "",
81 renameMode: "rename",
@@ -92,43 +100,36 @@ const model = {
100 document.addEventListener("settings-updated", this.settingsUpdatedHandler);
101 },
102
103 + onMount(element = null, options = {}) {
104 + this._floatingCleanup?.();
105 + this._floatingCleanup = null;
106 + const mode = options?.mode === "canvas" ? "canvas" : "modal";
107 + if (mode === "modal") {
108 + this.setupFloatingModal(element);
109 + } else {
110 + this.scheduleMountedDefaultLoad();
111 + }
112 + },
113 +
114 + onUnmount() {
115 + this._floatingCleanup?.();
116 + this._floatingCleanup = null;
117 + this.cancelMountedDefaultLoad();
118 + },
119 +
120 // --- Public API (called from button/link) --------------------------------
121 async open(path = "") {
122 if (this.isLoading) return; // Prevent double-open
98 - this.isLoading = true;
99 - this.error = null;
100 - this.history = [];
101 - this.searchQuery = "";
102 - this.isBulkBusy = false;
103 - this.pathError = "";
104 - this.isPathSubmitting = false;
123 + this.resetOpenState();
124
125 try {
126 // Open modal FIRST (immediate UI feedback)
108 - this.closePromise = window.openModal(
109 - "modals/file-browser/file-browser.html"
110 - );
111 -
112 - await this.loadDirectoryPreference();
113 - const explicitPath = this.normalizeOpeningPath(path || this.initialPath);
114 - const rememberedPath = !explicitPath ? this.getRememberedDirectory() : "";
115 - path = explicitPath || rememberedPath || "$WORK_DIR";
116 - this.browser.currentPath = path;
117 - this.syncPathInput();
118 -
119 - // Fetch files
120 - const loaded = await this.fetchFiles(this.browser.currentPath, {
121 - preserveOnError: Boolean(rememberedPath && path === rememberedPath),
122 - suppressErrorToast: Boolean(rememberedPath && path === rememberedPath),
123 - });
124 - if (!loaded && rememberedPath && path === rememberedPath) {
125 - this.clearRememberedDirectory();
126 - await this.fetchFiles("$WORK_DIR");
127 - }
127 + this.closePromise = window.openModal(FILE_BROWSER_MODAL_PATH);
128 + await this.loadOpeningPath(path);
129
130 // await modal close
131 await this.closePromise;
131 - this.destroy();
132 + if (!this.isSurfaceHandoff) this.destroy();
133
134 } catch (error) {
135 console.error("File browser error:", error);
@@ -137,17 +138,45 @@ const model = {
138 }
139 },
140
141 + async openSurface(path = "") {
142 + if (this.isLoading) return false;
143 + this.resetOpenState();
144 +
145 + try {
146 + const retainedPath = this.normalizeOpeningPath(
147 + path
148 + || this.surfaceHandoffPath
149 + || this.browser.currentPath
150 + || this.initialPath
151 + );
152 + return await this.loadOpeningPath(retainedPath);
153 + } catch (error) {
154 + console.error("File browser surface error:", error);
155 + this.error = error?.message || "Failed to load files";
156 + this.isLoading = false;
157 + return false;
158 + }
159 + },
160 +
161 handleClose() {
162 // Close the modal manually
163 this.disposeScopedTooltips();
143 - window.closeModal();
164 + window.closeModal(FILE_BROWSER_MODAL_PATH);
165 },
166
167 destroy() {
168 + this._floatingCleanup?.();
169 + this._floatingCleanup = null;
170 + this.cancelMountedDefaultLoad();
171 // Reset state when modal closes
172 this.isLoading = false;
173 this.history = [];
174 this.initialPath = "";
175 + this.closePromise = null;
176 + this.isSurfaceHandoff = false;
177 + this.surfaceHandoffPath = "";
178 + this.browser.currentPath = "";
179 + this.browser.parentPath = "";
180 this.browser.entries = [];
181 this.openDropdownPath = null;
182 this.searchQuery = "";
@@ -158,7 +187,87 @@ const model = {
187 this.resetRenameState();
188 },
189
190 + setupFloatingModal(element = null) {
191 + this._floatingCleanup?.();
192 + this._floatingCleanup = setupFloatingSurfaceModalChrome({
193 + root: element,
194 + modalClass: "file-browser-modal",
195 + focusButtonClass: "file-browser-modal-focus-button",
196 + minWidth: 420,
197 + minHeight: 360,
198 + });
199 + },
200 +
201 + cancelMountedDefaultLoad() {
202 + if (!this._mountedDefaultLoadTimer) return;
203 + globalThis.clearTimeout(this._mountedDefaultLoadTimer);
204 + this._mountedDefaultLoadTimer = null;
205 + },
206 +
207 + scheduleMountedDefaultLoad() {
208 + this.cancelMountedDefaultLoad();
209 + this._mountedDefaultLoadTimer = globalThis.setTimeout(async () => {
210 + this._mountedDefaultLoadTimer = null;
211 + if (this.isLoading) return;
212 + const targetPath = this.browser.currentPath || "";
213 + if (targetPath && this.browser.entries.length) {
214 + this.syncPathInput();
215 + return;
216 + }
217 + try {
218 + await this.loadOpeningPath(targetPath);
219 + } catch (error) {
220 + console.error("File browser default path load failed:", error);
221 + }
222 + }, 120);
223 + },
224 +
225 // --- Helpers -------------------------------------------------------------
226 + resetOpenState() {
227 + this.cancelMountedDefaultLoad();
228 + this.isLoading = true;
229 + this.error = null;
230 + this.history = [];
231 + this.searchQuery = "";
232 + this.isBulkBusy = false;
233 + this.pathError = "";
234 + this.isPathSubmitting = false;
235 + },
236 +
237 + async loadOpeningPath(path = "") {
238 + await this.loadDirectoryPreference();
239 + const explicitPath = this.normalizeOpeningPath(path || this.initialPath);
240 + const rememberedPath = !explicitPath ? this.getRememberedDirectory() : "";
241 + const targetPath = explicitPath || rememberedPath || "$WORK_DIR";
242 + this.browser.currentPath = targetPath;
243 + this.syncPathInput();
244 +
245 + const loaded = await this.fetchFiles(this.browser.currentPath, {
246 + preserveOnError: Boolean(rememberedPath && targetPath === rememberedPath),
247 + suppressErrorToast: Boolean(rememberedPath && targetPath === rememberedPath),
248 + });
249 + if (!loaded && rememberedPath && targetPath === rememberedPath) {
250 + this.clearRememberedDirectory();
251 + return await this.fetchFiles("$WORK_DIR");
252 + }
253 + return loaded;
254 + },
255 +
256 + beginSurfaceHandoff() {
257 + this.isSurfaceHandoff = true;
258 + this.surfaceHandoffPath = this.browser.currentPath || this.pathInput || "";
259 + },
260 +
261 + finishSurfaceHandoff() {
262 + this.isSurfaceHandoff = false;
263 + this.surfaceHandoffPath = "";
264 + },
265 +
266 + cancelSurfaceHandoff() {
267 + this.isSurfaceHandoff = false;
268 + this.surfaceHandoffPath = "";
269 + },
270 +
271 isArchive(filename) {
272 const archiveExts = ["zip", "tar", "gz", "rar", "7z"];
273 const ext = filename.split(".").pop().toLowerCase();
@@ -493,11 +602,13 @@ const model = {
602 async fetchFiles(path = "", options = {}) {
603 const preserveOnError = options?.preserveOnError === true;
604 const suppressErrorToast = options?.suppressErrorToast === true;
605 + const requestedPath = this.normalizeOpeningPath(path) || "$WORK_DIR";
606 this.isLoading = true;
607
608 // Preserve scroll position if refreshing the same path
499 - const isSamePath = this.browser.currentPath === path ||
500 - (!path && !this.browser.currentPath);
609 + const isSamePath =
610 + this.browser.currentPath === requestedPath ||
611 + (requestedPath === "$WORK_DIR" && ["/a0", "$WORK_DIR", ""].includes(this.browser.currentPath));
612 const scrollPos = isSamePath ? this.saveScrollPosition() : null;
613 const selectedPaths = isSamePath
614 ? new Set(this.selectedFiles.map((file) => file.path))
@@ -505,12 +616,14 @@ const model = {
616
617 try {
618 const response = await fetchApi(
508 - `/get_work_dir_files?path=${encodeURIComponent(path)}`
619 + `/get_work_dir_files?path=${encodeURIComponent(requestedPath)}`
620 );
621 const data = await response.json().catch(() => ({}));
622
623 const result = data.data || {};
513 - const requestedPath = String(path || "");
624 + const entries = result.entries || [];
625 + const resolvedCurrentPath =
626 + result.current_path || (requestedPath === "$WORK_DIR" ? "/a0" : requestedPath);
627 const resultError =
628 data.error ||
629 result.error ||
@@ -518,7 +631,7 @@ const model = {
631 requestedPath &&
632 requestedPath !== "$WORK_DIR" &&
633 !result.current_path &&
521 - !(result.entries || []).length
634 + !entries.length
635 ? "Directory not found or not accessible"
636 : ""
637 );
@@ -526,10 +639,10 @@ const model = {
639 if (response.ok && !resultError) {
640 if (!isSamePath) this.searchQuery = "";
641 this.browser.entries = this.decorateEntries(
529 - result.entries || [],
642 + entries,
643 selectedPaths
644 );
532 - this.browser.currentPath = result.current_path;
645 + this.browser.currentPath = resolvedCurrentPath;
646 this.browser.parentPath = result.parent_path;
647 this.syncPathInput();
648 this.pathError = "";
@@ -1020,7 +1133,7 @@ const model = {
1133 }
1134
1135 this.disposeScopedTooltips();
1023 - await window.closeModal?.("modals/file-browser/file-browser.html");
1136 + await window.closeModal?.(FILE_BROWSER_MODAL_PATH);
1137 } catch (error) {
1138 window.toastFrontendError?.(
1139 error?.message || "Could not open file",
webui/components/modals/file-browser/file-browser.html
+230 -38
@@ -1,14 +1,30 @@
1 -<html>
1 +<html
2 + class="surface-modal file-browser-modal modal-no-backdrop"
3 + data-surface-id="files"
4 + data-surface-modal-path="modals/file-browser/file-browser.html"
5 + data-surface-dock-title="Open Files in canvas"
6 + data-surface-dock-icon="dock_to_right"
7 + data-canvas-surface="files"
8 + data-canvas-modal-path="modals/file-browser/file-browser.html"
9 + data-canvas-dock-title="Open Files in canvas"
10 + data-canvas-dock-icon="dock_to_right"
11 +>
12 <head>
13 <title>File Browser</title>
14 <script type="module">
15 import { store } from "/components/modals/file-browser/file-browser-store.js";
16 </script>
17 </head>
8 -<body>
9 - <div x-data>
18 +<body class="file-browser-modal-body">
19 + <div
20 + x-data
21 + class="file-browser-shell"
22 + :class="{ 'is-surface': xAttrs($el)?.mode === 'canvas' }"
23 + x-create="$store.fileBrowser.onMount($el, xAttrs($el) || {})"
24 + x-destroy="$store.fileBrowser.onUnmount(xAttrs($el) || {})"
25 + >
26 <template x-if="$store.fileBrowser">
11 - <div class="file-browser-root">
27 + <div class="file-browser-root" :class="{ 'is-surface': xAttrs($el)?.mode === 'canvas' }">
28
29 <!-- Loading State -->
30 <div x-show="$store.fileBrowser.isLoading" class="loading-state">
@@ -21,11 +37,16 @@
37 <!-- Path navigator -->
38 <div class="path-navigator-wrap">
39 <form class="path-navigator" @submit.prevent="$store.fileBrowser.submitPath()">
24 - <button type="button" class="text-button back-button" @click="$store.fileBrowser.navigateUp()" aria-label="Navigate Up">
25 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10.5 15">
26 - <path d="m.75,5.25L5.25.75m0,0l4.5,4.5M5.25.75v13.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
27 - </svg>
28 - Up
40 + <button
41 + type="button"
42 + class="nav-button back-button"
43 + @click="$store.fileBrowser.navigateUp()"
44 + :disabled="!$store.fileBrowser.browser.parentPath || $store.fileBrowser.isLoading"
45 + aria-label="Navigate up"
46 + title="Navigate up"
47 + >
48 + <span class="material-symbols-outlined" aria-hidden="true">arrow_upward</span>
49 + <span class="nav-button-label">Up</span>
50 </button>
51 <div class="path-input-shell" :class="{ 'has-error': $store.fileBrowser.pathError }">
52 <span class="material-symbols-outlined path-input-icon" aria-hidden="true">folder_open</span>
@@ -79,13 +100,23 @@
100 </div>
101
102 <div class="new-item-buttons">
82 - <button class="btn btn-ok btn-new-item" @click="$store.fileBrowser.openNewFile()">
83 - <span class="material-symbols-outlined">note_add</span>
84 - New File
103 + <button
104 + type="button"
105 + class="btn btn-ok btn-new-item"
106 + @click="$store.fileBrowser.openNewFile()"
107 + aria-label="New file"
108 + title="New file"
109 + >
110 + <span class="material-symbols-outlined" aria-hidden="true">note_add</span>
111 </button>
86 - <button class="btn btn-ok btn-new-item" @click="$store.fileBrowser.openNewFolderModal()">
87 - <span class="material-symbols-outlined">create_new_folder</span>
88 - New Folder
112 + <button
113 + type="button"
114 + class="btn btn-ok btn-new-item"
115 + @click="$store.fileBrowser.openNewFolderModal()"
116 + aria-label="New folder"
117 + title="New folder"
118 + >
119 + <span class="material-symbols-outlined" aria-hidden="true">create_new_folder</span>
120 </button>
121 </div>
122 </div>
@@ -297,20 +328,81 @@
328
329 <!-- Modal Footer (outside template x-if so it exists immediately) -->
330 <template x-if="$store.fileBrowser">
300 - <div class="modal-footer" data-modal-footer>
331 + <div class="modal-footer file-browser-footer" data-modal-footer :class="{ 'is-surface': xAttrs($el)?.mode === 'canvas' }">
332 <label class="btn btn-upload">
333 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"/></svg>
334 Upload Files
335 <input type="file" multiple accept="*" @change="$store.fileBrowser.handleFileUpload" style="display:none;" />
336 </label>
306 - <button class="btn btn-cancel" @click="$store.fileBrowser.handleClose()">Close Browser</button>
337 + <button class="btn btn-cancel" x-show="xAttrs($el)?.mode !== 'canvas'" @click="$store.fileBrowser.handleClose()">Close Browser</button>
338 </div>
339 </template>
340 </div>
341
342 <style>
343 + .file-browser-shell {
344 + width: 100%;
345 + min-width: 0;
346 + }
347 +
348 + .modal-inner.file-browser-modal {
349 + box-sizing: border-box;
350 + width: min(78vw, 1040px);
351 + height: min(82vh, 820px);
352 + min-width: min(420px, calc(100vw - 16px));
353 + min-height: min(360px, calc(100vh - 16px));
354 + max-width: calc(100vw - 16px);
355 + max-height: calc(100vh - 16px);
356 + resize: both;
357 + overflow: hidden;
358 + }
359 +
360 + .modal-inner.file-browser-modal.is-focus-mode {
361 + resize: none;
362 + border-radius: 6px;
363 + }
364 +
365 + .modal-inner.file-browser-modal .modal-header {
366 + min-height: 34px;
367 + padding: 0.35rem 0.75rem 0.35rem 1rem;
368 + background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
369 + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
370 + }
371 +
372 + .modal-inner.file-browser-modal .modal-scroll,
373 + .modal-inner.file-browser-modal .modal-bd.file-browser-modal-body {
374 + display: flex;
375 + flex: 1 1 auto;
376 + width: 100%;
377 + height: 100%;
378 + min-width: 0;
379 + min-height: 0;
380 + max-height: none;
381 + overflow: hidden;
382 + padding: 0;
383 + }
384 +
385 + .modal-inner.file-browser-modal .modal-bd.file-browser-modal-body > div[x-data],
386 + .modal-inner.file-browser-modal .file-browser-shell {
387 + display: flex;
388 + flex: 1 1 auto;
389 + flex-direction: column;
390 + height: 100%;
391 + min-width: 0;
392 + min-height: 0;
393 + }
394 +
395 + .file-browser-shell.is-surface {
396 + display: flex;
397 + flex: 1 1 auto;
398 + flex-direction: column;
399 + height: 100%;
400 + min-height: 0;
401 + }
402 +
403 /* File Browser Root */
404 .file-browser-root {
405 + container: file-browser / inline-size;
406 display: flex;
407 flex-direction: column;
408 width: 100%;
@@ -318,6 +410,16 @@
410 min-height: 400px;
411 }
412
413 + .file-browser-root.is-surface {
414 + flex: 1 1 auto;
415 + min-height: 0;
416 + }
417 +
418 + .modal-inner.file-browser-modal .file-browser-root {
419 + flex: 1 1 auto;
420 + min-height: 0;
421 + }
422 +
423 /* Loading State */
424 .loading-state {
425 display: flex;
@@ -354,6 +456,17 @@
456 flex-direction: column;
457 padding: var(--spacing-sm) var(--spacing-sm);
458 gap: var(--spacing-sm);
459 + min-height: 0;
460 + }
461 +
462 + .file-browser-shell.is-surface .file-browser-content {
463 + flex: 1 1 auto;
464 + overflow: hidden;
465 + }
466 +
467 + .modal-inner.file-browser-modal .file-browser-content {
468 + flex: 1 1 auto;
469 + overflow: hidden;
470 }
471
472 /* File Browser Styles */
@@ -363,6 +476,34 @@
476 /* Removed overflow: hidden to allow dropdown menus to be visible */
477 }
478
479 + .file-browser-shell.is-surface .files-list {
480 + flex: 1 1 auto;
481 + min-height: 0;
482 + overflow: auto;
483 + border: 1px solid var(--color-border);
484 + background: color-mix(in srgb, var(--color-panel) 82%, transparent);
485 + }
486 +
487 + .modal-inner.file-browser-modal .files-list {
488 + flex: 1 1 auto;
489 + min-height: 0;
490 + overflow: auto;
491 + border: 1px solid var(--color-border);
492 + background: color-mix(in srgb, var(--color-panel) 82%, transparent);
493 + }
494 +
495 + .file-browser-shell.is-surface .file-header {
496 + position: sticky;
497 + top: 0;
498 + z-index: 2;
499 + }
500 +
501 + .modal-inner.file-browser-modal .file-header {
502 + position: sticky;
503 + top: 0;
504 + z-index: 2;
505 + }
506 +
507 /* Header Styles */
508 .file-header {
509 width: 100%;
@@ -633,23 +774,51 @@
774 }
775
776 .nav-button {
636 - padding: 4px 12px;
777 + appearance: none;
778 + display: inline-flex;
779 + align-items: center;
780 + justify-content: center;
781 + gap: 0.1rem;
782 + min-height: 2.35rem;
783 + padding: 0.22rem 0.62rem 0.24rem;
784 border: 1px solid var(--color-border);
638 - border-radius: 4px;
639 - background: var(--color-background);
785 + border-radius: 6px;
786 + background: color-mix(in srgb, var(--color-input) 86%, var(--color-panel) 14%);
787 color: var(--color-text);
788 + font: inherit;
789 + line-height: 1;
790 + box-shadow: none;
791 cursor: pointer;
642 - transition: background-color 0.2s;
643 - }
644 - .nav-button:hover {
645 - background: var(--hover-bg);
792 + transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, opacity 0.15s ease;
793 }
794 .nav-button.back-button {
648 - background-color: var(--color-secondary);
795 + flex: 0 0 auto;
796 + flex-direction: column;
797 + min-width: 3.1rem;
798 + }
799 + .nav-button .material-symbols-outlined {
800 + font-size: 1.05rem;
801 + line-height: 1;
802 + }
803 + .nav-button-label {
804 + font-size: 0.66rem;
805 + font-weight: 600;
806 + letter-spacing: 0;
807 + line-height: 1;
808 + }
809 + .nav-button:hover:not(:disabled) {
810 + border-color: color-mix(in srgb, var(--color-primary) 34%, var(--color-border));
811 + background: color-mix(in srgb, var(--color-input-focus) 84%, var(--color-primary) 16%);
812 color: var(--color-text);
813 }
651 - .nav-button.back-button:hover {
652 - background-color: var(--color-secondary-dark);
814 + .nav-button:focus-visible {
815 + outline: none;
816 + border-color: var(--color-primary);
817 + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 22%, transparent);
818 + }
819 + .nav-button:disabled {
820 + cursor: not-allowed;
821 + opacity: 0.5;
822 }
823 /* Folder Specific Styles */
824 .file-item[data-is-dir="true"] {
@@ -681,6 +850,16 @@
850 background-color: #2b309c;
851 }
852
853 + .file-browser-footer.is-surface {
854 + display: flex;
855 + flex: 0 0 auto;
856 + justify-content: flex-end;
857 + gap: var(--spacing-sm);
858 + padding: var(--spacing-sm);
859 + border-top: 1px solid var(--color-border);
860 + background: color-mix(in srgb, var(--color-panel) 90%, var(--color-background) 10%);
861 + }
862 +
863 /* File Actions */
864 .file-actions {
865 display: flex;
@@ -697,8 +876,17 @@
876 .btn-new-item {
877 display: inline-flex;
878 align-items: center;
879 + justify-content: center;
880 gap: 0.4rem;
701 - padding: 8px 14px;
881 + width: 2.8rem;
882 + height: 2.8rem;
883 + min-width: 2.8rem;
884 + padding: 0;
885 + border-radius: 8px;
886 + }
887 + .btn-new-item .material-symbols-outlined {
888 + font-size: 1.35rem;
889 + line-height: 1;
890 }
891 .btn-secondary {
892 background: var(--color-secondary);
@@ -729,13 +917,6 @@
917 min-width: 0;
918 width: 100%;
919 }
732 - .new-item-buttons {
733 - width: 100%;
734 - }
735 - .new-item-buttons .btn-new-item {
736 - flex: 1;
737 - justify-content: center;
738 - }
920 .file-header {
921 grid-template-columns: 2.25rem minmax(0, 1fr) minmax(5rem, 0.52fr) 7.5rem;
922 }
@@ -750,15 +931,26 @@
931 @media (max-width: 540px) {
932 .file-header,
933 .file-item {
753 - grid-template-columns: 2.25rem minmax(0, 1fr) 7.5rem;
934 + grid-template-columns: 2.25rem minmax(0, 1fr) minmax(4.25rem, max-content) 5.25rem;
935 }
755 - .file-cell-size,
756 - .file-size,
936 .file-cell-date,
937 .file-date {
938 display: none;
939 }
940 }
941 + @container file-browser (max-width: 620px) {
942 + .file-header,
943 + .file-item {
944 + grid-template-columns: 2.25rem minmax(0, 1fr) minmax(4.25rem, max-content) 5.25rem;
945 + }
946 + .file-cell-date,
947 + .file-date {
948 + display: none;
949 + }
950 + .file-actions {
951 + padding-right: 0.35rem;
952 + }
953 + }
954 </style>
955 </body>
956 </html>
webui/css/AGENTS.md
+1
@@ -10,6 +10,7 @@
10 - Each CSS file owns a named surface or primitive family such as buttons, messages, modals, notifications, scheduler, settings, surfaces, tables, or toast.
11 - Component-specific styles should usually stay inside the component HTML unless they are intentionally shared.
12 - `modals.css` owns the shared stacked modal shell, backdrop, scroll area, footer slot, modal button classes, floating/no-backdrop modal behavior, and shared modal section primitives.
13 +- `surfaces.css` owns surface modal switchers, action rails, draggable header affordances, focus-button state, and right-canvas surface primitives.
14 - `index.css` defines global theme variables such as `--color-*`, `--spacing-*`, `--font-size-*`, and `--transition-speed`.
15
16 ## Local Contracts
webui/css/surfaces.css
+16
@@ -20,6 +20,22 @@
20 pointer-events: auto;
21 }
22
23 +.modal-inner.surface-modal.is-draggable-surface-modal .modal-header {
24 + cursor: move;
25 + user-select: none;
26 +}
27 +
28 +.modal-inner.surface-modal.is-focus-mode {
29 + resize: none;
30 +}
31 +
32 +.modal-inner.surface-modal.is-focus-mode .surface-modal-focus-button,
33 +.surface-modal-focus-button.is-active {
34 + color: var(--color-text);
35 + border-color: color-mix(in srgb, var(--color-primary) 42%, var(--color-border));
36 + background: color-mix(in srgb, var(--color-primary) 16%, var(--color-background));
37 +}
38 +
39 .surface-switcher,
40 .modal-surface-switcher {
41 display: grid;
webui/js/AGENTS.md
+1
@@ -13,6 +13,7 @@
13 - `extensions.js` owns frontend extension loading.
14 - `components.js` owns `<x-component>` loading, component caching, module injection, nested component processing, and `globalThis.xAttrs`.
15 - `modals.js` owns the stacked modal shell, `openModal`, `closeModal`, `scrollModal`, footer relocation, backdrop, and modal z-index behavior.
16 +- `surfaces.js` owns shared surface registration, right-canvas/modal mode routing, surface modal action rails, and reusable draggable/focus modal chrome.
17 - `initFw.js` owns Alpine bootstrap and custom lifecycle directives such as `x-create`, `x-destroy`, and periodic `x-every-*` hooks.
18 - Other modules own focused UI utilities such as modals, messages, safe markdown, shortcuts, TTS/STT, surfaces, and initialization.
19
webui/js/surfaces.js
+219
@@ -11,6 +11,29 @@ const urlHandlers = new Set();
11 const SURFACE_MODAL_ACTION_GROUPS = ["surfaces", "window", "new"];
12
13 export const CORE_SURFACES = [
14 + {
15 + id: "files",
16 + title: "Files",
17 + icon: "folder",
18 + order: 5,
19 + modalPath: "modals/file-browser/file-browser.html",
20 + async beginDockHandoff() {
21 + const { store } = await import("/components/modals/file-browser/file-browser-store.js");
22 + store.beginSurfaceHandoff?.();
23 + },
24 + async finishDockHandoff(payload = {}) {
25 + const { store } = await import("/components/modals/file-browser/file-browser-store.js");
26 + store.finishSurfaceHandoff?.(payload);
27 + },
28 + async cancelDockHandoff() {
29 + const { store } = await import("/components/modals/file-browser/file-browser-store.js");
30 + store.cancelSurfaceHandoff?.();
31 + },
32 + async open(payload = {}) {
33 + const { store } = await import("/components/modals/file-browser/file-browser-store.js");
34 + await store.openSurface(payload.path || payload.filePath || payload.directory || "");
35 + },
36 + },
37 {
38 id: "browser",
39 title: "Browser",
@@ -369,6 +392,202 @@ export function placeSurfaceModalHeaderAction(header, element, groupName = "wind
392 refreshSurfaceModalActionRail(header);
393 }
394
395 +export function setupFloatingSurfaceModalChrome(options = {}) {
396 + const root = options.root || null;
397 + const modal = options.modal || root?.closest?.(".modal") || null;
398 + const inner = options.inner || modal?.querySelector?.(".modal-inner") || root?.closest?.(".modal-inner") || null;
399 + const header = options.header || inner?.querySelector?.(".modal-header") || null;
400 + if (!modal || !inner || !header) return () => {};
401 +
402 + const viewportGap = Number.isFinite(Number(options.viewportGap)) ? Number(options.viewportGap) : 8;
403 + const minWidth = Number.isFinite(Number(options.minWidth)) ? Number(options.minWidth) : 320;
404 + const minHeight = Number.isFinite(Number(options.minHeight)) ? Number(options.minHeight) : 300;
405 + const modalClass = String(options.modalClass || "").trim();
406 + const focusButtonClass = String(options.focusButtonClass || "").trim();
407 + const focusEnabled = options.focus !== false;
408 + const focusLabel = options.focusLabel || "Focus mode";
409 + const restoreLabel = options.restoreLabel || "Restore size";
410 + const onBoundsChange = typeof options.onBoundsChange === "function" ? options.onBoundsChange : null;
411 + const onFocusChange = typeof options.onFocusChange === "function" ? options.onFocusChange : null;
412 +
413 + modal.classList.add("surface-floating", "modal-floating");
414 + inner.classList.add("surface-modal", "is-draggable-surface-modal");
415 + if (modalClass) inner.classList.add(modalClass);
416 +
417 + const viewportWidth = () => Math.max(document.documentElement.clientWidth || 0, globalThis.innerWidth || 0);
418 + const viewportHeight = () => Math.max(document.documentElement.clientHeight || 0, globalThis.innerHeight || 0);
419 + const currentBounds = () => {
420 + const bounds = inner.getBoundingClientRect();
421 + return {
422 + left: bounds.left,
423 + top: bounds.top,
424 + width: bounds.width,
425 + height: bounds.height,
426 + };
427 + };
428 + const normalizedBounds = (bounds = {}) => {
429 + const maxWidth = Math.max(minWidth, viewportWidth() - viewportGap * 2);
430 + const maxHeight = Math.max(minHeight, viewportHeight() - viewportGap * 2);
431 + const width = Math.min(Math.max(minWidth, Number(bounds.width || minWidth)), maxWidth);
432 + const height = Math.min(Math.max(minHeight, Number(bounds.height || minHeight)), maxHeight);
433 + return {
434 + left: Math.min(
435 + Math.max(viewportGap, Number(bounds.left || viewportGap)),
436 + Math.max(viewportGap, viewportWidth() - width - viewportGap),
437 + ),
438 + top: Math.min(
439 + Math.max(viewportGap, Number(bounds.top || viewportGap)),
440 + Math.max(viewportGap, viewportHeight() - height - viewportGap),
441 + ),
442 + width,
443 + height,
444 + };
445 + };
446 + const notifyBoundsChange = () => {
447 + try {
448 + onBoundsChange?.({
449 + ...currentBounds(),
450 + focus: inner.classList.contains("is-focus-mode"),
451 + });
452 + } catch (error) {
453 + console.error("Surface modal bounds callback failed", error);
454 + }
455 + };
456 + const setBounds = (bounds = {}) => {
457 + const next = normalizedBounds(bounds);
458 + inner.style.position = "fixed";
459 + inner.style.transform = "none";
460 + inner.style.left = `${Math.round(next.left)}px`;
461 + inner.style.top = `${Math.round(next.top)}px`;
462 + inner.style.width = `${Math.round(next.width)}px`;
463 + inner.style.height = `${Math.round(next.height)}px`;
464 + inner.style.maxWidth = `${Math.max(minWidth, viewportWidth() - viewportGap * 2)}px`;
465 + inner.style.maxHeight = `${Math.max(minHeight, viewportHeight() - viewportGap * 2)}px`;
466 + notifyBoundsChange();
467 + return next;
468 + };
469 + const focusBounds = () => ({
470 + left: viewportGap,
471 + top: viewportGap,
472 + width: viewportWidth() - viewportGap * 2,
473 + height: viewportHeight() - viewportGap * 2,
474 + });
475 + const clampGeometry = () => {
476 + if (inner.classList.contains("is-focus-mode")) {
477 + setBounds(focusBounds());
478 + return;
479 + }
480 + setBounds(currentBounds());
481 + };
482 +
483 + const initialBounds = currentBounds();
484 + inner.style.left = `${Math.max(viewportGap, initialBounds.left)}px`;
485 + inner.style.top = `${Math.max(viewportGap, initialBounds.top)}px`;
486 + inner.style.transform = "none";
487 + clampGeometry();
488 +
489 + let drag = null;
490 + let resizeObserver = null;
491 + let beforeFocusBounds = null;
492 + let focusButton = null;
493 +
494 + const updateFocusButton = (active) => {
495 + if (!focusButton) return;
496 + const label = active ? restoreLabel : focusLabel;
497 + focusButton.setAttribute("aria-label", label);
498 + focusButton.setAttribute("title", label);
499 + focusButton.classList.toggle("is-active", active);
500 + const icon = focusButton.querySelector(".material-symbols-outlined");
501 + if (icon) icon.textContent = active ? "fullscreen_exit" : "fullscreen";
502 + };
503 + const setFocusMode = (enabled) => {
504 + const active = Boolean(enabled);
505 + if (active === inner.classList.contains("is-focus-mode")) return;
506 + if (active) {
507 + beforeFocusBounds = currentBounds();
508 + inner.classList.add("is-focus-mode");
509 + setBounds(focusBounds());
510 + } else {
511 + inner.classList.remove("is-focus-mode");
512 + setBounds(beforeFocusBounds || currentBounds());
513 + beforeFocusBounds = null;
514 + }
515 + updateFocusButton(active);
516 + try {
517 + onFocusChange?.(active);
518 + } catch (error) {
519 + console.error("Surface modal focus callback failed", error);
520 + }
521 + };
522 +
523 + const onPointerMove = (event) => {
524 + if (!drag) return;
525 + setBounds({
526 + ...currentBounds(),
527 + left: drag.left + event.clientX - drag.x,
528 + top: drag.top + event.clientY - drag.y,
529 + });
530 + };
531 + const onPointerUp = () => {
532 + drag = null;
533 + globalThis.removeEventListener("pointermove", onPointerMove);
534 + globalThis.removeEventListener("pointerup", onPointerUp);
535 + try {
536 + header.releasePointerCapture?.(header.__surfaceModalPointerId || 0);
537 + } catch {}
538 + };
539 + const onPointerDown = (event) => {
540 + if (event.button !== 0) return;
541 + if (event.target?.closest?.("button, input, select, textarea, a, [data-no-modal-drag], .surface-modal-actions")) return;
542 + if (inner.classList.contains("is-focus-mode")) return;
543 + const bounds = currentBounds();
544 + drag = {
545 + x: event.clientX,
546 + y: event.clientY,
547 + left: bounds.left,
548 + top: bounds.top,
549 + };
550 + header.__surfaceModalPointerId = event.pointerId;
551 + header.setPointerCapture?.(event.pointerId);
552 + globalThis.addEventListener("pointermove", onPointerMove);
553 + globalThis.addEventListener("pointerup", onPointerUp);
554 + event.preventDefault();
555 + };
556 + header.addEventListener("pointerdown", onPointerDown);
557 +
558 + if (focusEnabled) {
559 + focusButton = globalThis.document.createElement("button");
560 + focusButton.type = "button";
561 + focusButton.className = ["surface-button", "surface-modal-focus-button", focusButtonClass]
562 + .filter(Boolean)
563 + .join(" ");
564 + focusButton.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">fullscreen</span>';
565 + const onFocusClick = () => setFocusMode(!inner.classList.contains("is-focus-mode"));
566 + updateFocusButton(false);
567 + focusButton.addEventListener("click", onFocusClick);
568 + focusButton.__surfaceModalFocusCleanup = () => focusButton.removeEventListener("click", onFocusClick);
569 + placeSurfaceModalHeaderAction(header, focusButton, "window");
570 + }
571 +
572 + globalThis.addEventListener("resize", clampGeometry);
573 + if (globalThis.ResizeObserver) {
574 + resizeObserver = new ResizeObserver(clampGeometry);
575 + resizeObserver.observe(inner);
576 + }
577 +
578 + return () => {
579 + focusButton?.__surfaceModalFocusCleanup?.();
580 + focusButton?.remove();
581 + refreshSurfaceModalActionRail(header);
582 + header.removeEventListener("pointerdown", onPointerDown);
583 + globalThis.removeEventListener("pointermove", onPointerMove);
584 + globalThis.removeEventListener("pointerup", onPointerUp);
585 + globalThis.removeEventListener("resize", clampGeometry);
586 + resizeObserver?.disconnect?.();
587 + inner.classList.remove("is-focus-mode", "is-draggable-surface-modal");
588 + };
589 +}
590 +
591 function markSurfaceModal(modal, metadata) {
592 const element = modal?.element;
593 const inner = modal?.inner || element?.querySelector?.(".modal-inner");