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");