Split live surfaces out of modals
Introduce the shared surfaces frontend service and stylesheet so Browser and Desktop can register docked or floating live UI without special cases in modals.js. Update Browser and right-canvas integration to preserve active viewers across canvas/modal switches and avoid creating blank tabs unless explicitly requested.
Alessandro committed
May 7, 2026 at 00:14 UTC
022b6f031f890fb0b43bc4552adb15db34406fdb
20 files changed
+998
-424
plugins/_browser/api/ws_browser.py
+21
-8
@@ -62,10 +62,14 @@ class WsBrowser(WsHandler):
62
if not AgentContext.get(context_id):
63
return self._error("CONTEXT_NOT_FOUND", f"Context '{context_id}' was not found", data)
64
65
- runtime = await get_runtime(context_id)
66
- listing = await runtime.call("list")
67
- browsers = listing.get("browsers") or []
68
- if not browsers:
65
+ create_browser = self._bool(data.get("create_browser", data.get("createBrowser")))
66
+ runtime = await get_runtime(context_id, create=create_browser)
67
+ listing = {"browsers": [], "last_interacted_browser_id": None}
68
+ browsers: list[dict[str, Any]] = []
69
+ if runtime:
70
+ listing = await runtime.call("list")
71
+ browsers = listing.get("browsers") or []
72
+ if runtime and not browsers and create_browser:
73
opened = await runtime.call("open", "")
74
listing = await runtime.call("list")
75
browsers = listing.get("browsers") or []
@@ -73,7 +77,7 @@ class WsBrowser(WsHandler):
77
listing["last_interacted_browser_id"] = opened.get("id")
78
active_id = self._active_browser_id(listing, data.get("browser_id"))
79
initial_viewport = self._viewport_from_data(data)
76
- if active_id and initial_viewport:
80
+ if runtime and active_id and initial_viewport:
81
await runtime.call(
82
"set_viewport",
83
active_id,
@@ -88,9 +92,10 @@ class WsBrowser(WsHandler):
92
if existing:
93
existing.cancel()
94
viewer_id = str(data.get("viewer_id") or "")
91
- self._streams[stream_key] = asyncio.create_task(
92
- self._stream_frames(sid, context_id, active_id, viewer_id)
93
- )
95
+ if runtime:
96
+ self._streams[stream_key] = asyncio.create_task(
97
+ self._stream_frames(sid, context_id, active_id, viewer_id)
98
+ )
99
100
return {
101
"context_id": context_id,
@@ -498,6 +503,14 @@ class WsBrowser(WsHandler):
503
def _context_id(data: dict[str, Any]) -> str:
504
return str(data.get("context_id") or data.get("context") or "").strip()
505
506
+ @staticmethod
507
+ def _bool(value: Any) -> bool:
508
+ if isinstance(value, bool):
509
+ return value
510
+ if isinstance(value, (int, float)):
511
+ return bool(value)
512
+ return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
513
+
514
@staticmethod
515
def _error(code: str, message: str, data: dict[str, Any]) -> WsResult:
516
return WsResult.error(
plugins/_browser/default_config.yaml
+1
-1
@@ -5,7 +5,7 @@ extension_paths: []
5
# Page opened by new Browser sessions when no URL is provided.
6
default_homepage: "about:blank"
7
8
-# When the Browser canvas is already open, keep it synced to agent Browser tool results.
8
+# When the Browser surface is already open, keep it synced to agent Browser tool results.
9
autofocus_active_page: true
10
11
# Optional _model_config preset used by Browser-owned model helpers.
plugins/_browser/extensions/webui/chat-input-bottom-actions-start/browser-button.html
+1
-1
@@ -5,7 +5,7 @@
5
aria-label="Open Browser"
6
data-bs-placement="top"
7
data-bs-trigger="hover"
8
- @click="$store.rightCanvas ? $store.rightCanvas.open('browser') : (window.ensureModalOpen ? window.ensureModalOpen('/plugins/_browser/webui/main.html') : (window.openModal && window.openModal('/plugins/_browser/webui/main.html')))"
8
+ @click="import('/js/surfaces.js').then(({ open }) => open('browser'))"
9
>
10
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14" aria-hidden="true">
11
<rect x="3" y="4" width="18" height="16" rx="2"></rect>
plugins/_browser/extensions/webui/get_tool_message_handler/browser-tool-handler.js
+12
-27
@@ -4,16 +4,15 @@ import {
4
} from "/components/messages/action-buttons/simple-action-buttons.js";
5
import { store as stepDetailStore } from "/components/modals/process-step-detail/step-detail-store.js";
6
import { store as speechStore } from "/components/chat/speech/speech-store.js";
7
-import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";
7
import { store as browserStore } from "/plugins/_browser/webui/browser-store.js";
8
import { getNamespacedClient } from "/js/websocket.js";
9
+import { open as openSurface } from "/js/surfaces.js";
10
import {
11
buildDetailPayload,
12
cleanStepTitle,
13
drawProcessStep,
14
} from "/js/messages.js";
15
16
-const BROWSER_MODAL = "/plugins/_browser/webui/main.html";
16
const BROWSER_SCREENSHOT_KVP_KEY = "Screenshot";
17
const BROWSER_SCREENSHOT_STYLE_ID = "a0-browser-screenshot-kvp-style";
18
const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
@@ -34,19 +33,8 @@ export default async function registerBrowserToolHandler(extData) {
33
}
34
}
35
37
-async function openBrowserCanvas(payload = {}) {
38
- if (rightCanvasStore?.open) {
39
- await rightCanvasStore.open("browser", payload);
40
- return;
41
- }
42
-
43
- if (window.ensureModalOpen) {
44
- await window.ensureModalOpen(BROWSER_MODAL);
45
- return;
46
- }
47
- if (window.openModal) {
48
- await window.openModal(BROWSER_MODAL);
49
- }
36
+async function openBrowserSurface(payload = {}) {
37
+ await openSurface("browser", payload);
38
}
39
40
async function browserAllowsToolAutofocus() {
@@ -115,11 +103,7 @@ function isFreshToolMessage(timestamp) {
103
}
104
105
function isBrowserCanvasAlreadyOpen() {
118
- return Boolean(
119
- rightCanvasStore?.isOpen
120
- && rightCanvasStore?.activeSurfaceId === "browser"
121
- && !rightCanvasStore?.isMobileMode,
122
- );
106
+ return Boolean(document.querySelector('[data-surface-id="browser"].is-active .browser-panel'));
107
}
108
109
// Allowlist: only these actions sync an already-open viewer to the target tab.
@@ -146,15 +130,16 @@ function syncOpenBrowserCanvas(args, result) {
130
if (!shouldSyncOpenBrowserCanvas(args, result)) return;
131
const kvps = args?.kvps || {};
132
const browserId = browserIdFromResult(result, kvps);
149
- const key = `${args.id || ""}:${kvps.action || ""}:${browserId || ""}:${result.currentUrl || result.state?.currentUrl || kvps.url || ""}`;
133
+ const contextId = browserContextIdFromResult(result, kvps);
134
+ const key = `${args.id || ""}:${contextId || ""}:${kvps.action || ""}:${browserId || ""}:${result.currentUrl || result.state?.currentUrl || kvps.url || ""}`;
135
if (syncedBrowserCanvases.has(key)) return;
136
syncedBrowserCanvases.add(key);
137
requestAnimationFrame(async () => {
138
if (!isBrowserCanvasAlreadyOpen()) return;
139
if (!(await browserAllowsToolAutofocus())) return;
155
- void rightCanvasStore.open("browser", {
140
+ void openSurface("browser", {
141
browserId,
157
- contextId: browserContextIdFromResult(result, kvps),
142
+ contextId,
143
source: "tool-sync",
144
});
145
});
@@ -344,7 +329,7 @@ function renderBrowserScreenshotKvp(kvpsTable, resolveBrowserPayload, label) {
329
event.stopPropagation();
330
const canvasPayload = resolveBrowserPayload();
331
if (!canvasPayload) return;
347
- await openBrowserCanvas(canvasPayload);
332
+ await openBrowserSurface(canvasPayload);
333
});
334
335
cell.textContent = "";
@@ -465,15 +450,15 @@ function drawBrowserTool({
450
const browserId = browserIdFromResult(browserResult, kvps);
451
const browserCanvasPayload = buildBrowserCanvasPayload(browserResult, kvps);
452
const browserPreviewLabel = browserId
468
- ? `Open Browser canvas for Browser ${browserId}`
469
- : "Open Browser canvas from screenshot";
453
+ ? `Open Browser surface for Browser ${browserId}`
454
+ : "Open Browser surface from screenshot";
455
if (shouldRenderBrowserScreenshotKvp(browserResult, kvps)) {
456
displayKvps[BROWSER_SCREENSHOT_KVP_KEY] = "";
457
}
458
const browserButton = createActionButton(
459
"visibility",
460
"Browser",
476
- () => openBrowserCanvas(
461
+ () => openBrowserSurface(
462
buildBrowserCanvasPayload(browserResult, kvps, "tool")
463
|| {
464
browserId,
plugins/_browser/extensions/webui/set_messages_after_loop/auto-open-browser-results.js
+6
-7
@@ -1,5 +1,5 @@
1
-import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";
1
import { store as browserStore } from "/plugins/_browser/webui/browser-store.js";
2
+import { open as openSurface } from "/js/surfaces.js";
3
4
const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
5
const syncedBrowserCanvases = new Set();
@@ -19,6 +19,7 @@ export default async function syncBrowserResultsIntoOpenCanvas(context) {
19
const contextId = getBrowserContextId(payload, result);
20
const key = [
21
args?.id || "",
22
+ contextId || "",
23
browserId || "",
24
result.currentUrl || result.state?.currentUrl || payload.url || "",
25
].join(":");
@@ -53,6 +54,8 @@ function pickPayloadFields(args = {}) {
54
"action",
55
"browser_id",
56
"browserId",
57
+ "context_id",
58
+ "contextId",
59
"url",
60
"last_modified",
61
]) {
@@ -156,15 +159,11 @@ function hasOpened(key, persistedKey) {
159
160
async function syncOpenBrowserCanvas(payload = {}) {
161
if (!isBrowserCanvasAlreadyOpen()) return;
159
- await rightCanvasStore.open("browser", payload);
162
+ await openSurface("browser", payload);
163
}
164
165
function isBrowserCanvasAlreadyOpen() {
163
- return Boolean(
164
- rightCanvasStore?.isOpen
165
- && rightCanvasStore?.activeSurfaceId === "browser"
166
- && !rightCanvasStore?.isMobileMode,
167
- );
166
+ return Boolean(document.querySelector('[data-surface-id="browser"].is-active .browser-panel'));
167
}
168
169
async function browserAllowsToolAutofocus() {
plugins/_browser/extensions/webui/surfaces_register/register-browser.js
new
+104
@@ -0,0 +1,104 @@
1
+import { store as browserStore } from "/plugins/_browser/webui/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(null);
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
+function nextAnimationFrame() {
23
+ return new Promise((resolve) => {
24
+ const schedule = globalThis.requestAnimationFrame || ((callback) => globalThis.setTimeout(callback, 16));
25
+ schedule(() => resolve());
26
+ });
27
+}
28
+
29
+function isVisibleCanvasPanel(panel) {
30
+ if (!panel?.isConnected) return false;
31
+ const surface = panel.closest(".browser-canvas-surface");
32
+ const stage = panel.querySelector(".browser-stage") || panel;
33
+ const surfaceStyle = surface ? globalThis.getComputedStyle?.(surface) : null;
34
+ const panelStyle = globalThis.getComputedStyle?.(panel);
35
+ if (surfaceStyle?.display === "none" || surfaceStyle?.visibility === "hidden") return false;
36
+ if (panelStyle?.display === "none" || panelStyle?.visibility === "hidden") return false;
37
+ const rect = stage.getBoundingClientRect?.();
38
+ return Boolean(rect && Math.round(rect.width || 0) >= 80 && Math.round(rect.height || 0) >= 80);
39
+}
40
+
41
+async function waitForVisibleCanvasPanel(selector, timeoutMs = 3000) {
42
+ const deadline = Date.now() + timeoutMs;
43
+ let stableKey = "";
44
+ let stableCount = 0;
45
+
46
+ while (Date.now() <= deadline) {
47
+ const panel = document.querySelector(selector);
48
+ const visible = isVisibleCanvasPanel(panel);
49
+ if (visible) {
50
+ const stage = panel.querySelector(".browser-stage") || panel;
51
+ const rect = stage.getBoundingClientRect();
52
+ const key = `${Math.round(rect.width || 0)}x${Math.round(rect.height || 0)}`;
53
+ if (key === stableKey) {
54
+ stableCount += 1;
55
+ if (stableCount >= 2) {
56
+ return panel;
57
+ }
58
+ } else {
59
+ stableKey = key;
60
+ stableCount = 0;
61
+ }
62
+ } else {
63
+ stableKey = "";
64
+ stableCount = 0;
65
+ }
66
+ await nextAnimationFrame();
67
+ }
68
+
69
+ return document.querySelector(selector);
70
+}
71
+
72
+export default async function registerBrowserSurface(canvas) {
73
+ canvas.registerSurface({
74
+ id: "browser",
75
+ title: "Browser",
76
+ icon: "language",
77
+ order: 10,
78
+ modalPath: "/plugins/_browser/webui/main.html",
79
+ beginDockHandoff() {
80
+ browserStore.beginSurfaceHandoff?.();
81
+ },
82
+ finishDockHandoff() {
83
+ browserStore.finishSurfaceHandoff?.();
84
+ },
85
+ cancelDockHandoff() {
86
+ browserStore.cancelSurfaceHandoff?.();
87
+ },
88
+ async open(payload = {}) {
89
+ await waitForElement('[data-surface-id="browser"] .browser-panel');
90
+ const panel = await waitForVisibleCanvasPanel('[data-surface-id="browser"] .browser-panel');
91
+ const browser = browserStore;
92
+ if (panel && browser?.onOpen) {
93
+ await browser.onOpen(panel, {
94
+ mode: "canvas",
95
+ browserId: payload.browserId || payload.browser_id || null,
96
+ contextId: payload.contextId || payload.context_id || null,
97
+ });
98
+ }
99
+ },
100
+ async close() {
101
+ await browserStore.cleanup?.();
102
+ },
103
+ });
104
+}
plugins/_browser/helpers/extension_manager.py
+2
-4
@@ -18,7 +18,7 @@ from helpers import files, plugins
18
from plugins._browser.helpers.config import PLUGIN_NAME, get_browser_config
19
20
21
-EXTENSIONS_ROOT_DIR = ("usr", "plugins", PLUGIN_NAME, "extensions")
21
+EXTENSIONS_ROOT_DIR = ("usr", "_browser", "extensions")
22
EXTENSION_ID_RE = re.compile(r"^[a-p]{32}$")
23
WEB_STORE_ID_RE = re.compile(r"(?<![a-p])([a-p]{32})(?![a-p])")
24
CHROME_VERSION_RE = re.compile(r"(\d+(?:\.\d+){0,3})")
@@ -41,9 +41,7 @@ WEB_STORE_DOWNLOAD_URL = (
41
42
43
def get_extensions_root() -> Path:
44
- root = Path(files.get_abs_path(*EXTENSIONS_ROOT_DIR))
45
- root.mkdir(parents=True, exist_ok=True)
46
- return root
44
+ return Path(files.get_abs_path(*EXTENSIONS_ROOT_DIR))
45
46
47
def parse_chrome_web_store_extension_id(value: str) -> str:
plugins/_browser/prompts/agent.system.tool.browser.md
+3
-3
@@ -4,9 +4,9 @@ use for web browsing, page inspection, forms, downloads, and browser-only tasks
4
state stays open per chat context
5
refs come from content as typed markers: [link 3], [button 6], [image 1], [input text 8]
6
7
-Browser tool actions must not open the right canvas automatically. Use the tool headlessly unless the user opens the Browser canvas or explicitly asks for a visible browser view; if the Browser canvas is already open, it may reflect the active page.
7
+Browser tool actions must not open a Browser surface automatically. Use the tool headlessly unless the user opens the Browser surface or explicitly asks for a visible browser view; if the Browser surface is already open, it may reflect the active page.
8
9
-Browser does not automatically load screenshots or canvas images into model context. Screenshots are explicit only.
9
+Browser does not automatically load screenshots or surface images into model context. Screenshots are explicit only.
10
11
actions: open list state set_active navigate back forward reload content detail screenshot click hover double_click right_click drag type submit type_submit scroll evaluate key_chord mouse wheel keyboard clipboard set_viewport select_option set_checked upload_file multi close close_all
12
common args: action browser_id url ref target_ref text selector selectors script modifiers keys key include_content focus_popup event_type x y to_x to_y offset_x offset_y target_offset_x target_offset_y delta_x delta_y button quality full_page path paths value values checked width height calls
@@ -38,7 +38,7 @@ pointer and raw input:
38
- keyboard presses key or types text into the active page
39
- clipboard is copy, cut, or paste; for browser:clipboard pass action: "paste" and optional text
40
- set_viewport resizes the page viewport with width and height
41
-- coordinates are Chromium viewport CSS pixels and match screenshots/Browser canvas
41
+- coordinates are Chromium viewport CSS pixels and match screenshots/Browser surface
42
- ref offsets are relative to the target element top-left; refs default to element center
43
44
forms:
plugins/_browser/webui/browser-panel.html
+11
@@ -307,6 +307,17 @@
307
background: color-mix(in srgb, var(--color-background) 94%, #000 6%);
308
}
309
310
+ .modal-inner.browser-modal.is-focus-mode {
311
+ resize: none;
312
+ border-radius: 6px;
313
+ }
314
+
315
+ .modal-inner.browser-modal.is-focus-mode .browser-modal-focus-button {
316
+ color: var(--color-text);
317
+ border-color: color-mix(in srgb, var(--color-primary) 42%, var(--color-border));
318
+ background: color-mix(in srgb, var(--color-primary) 16%, var(--color-background));
319
+ }
320
+
321
.modal.modal-floating {
322
pointer-events: none;
323
}
plugins/_browser/webui/browser-store.js
+198
-27
@@ -7,11 +7,12 @@ import { store as chatInputStore } from "/components/chat/input/input-store.js";
7
import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
8
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
9
import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";
10
+import { openLatest as openLatestSurface, registerUrlHandler } from "/js/surfaces.js";
11
12
const websocket = getNamespacedClient("/ws");
13
websocket.addHandlers(["ws_webui"]);
14
14
-const EXTENSIONS_ROOT = "/a0/usr/plugins/_browser/extensions";
15
+const EXTENSIONS_ROOT = "/a0/usr/_browser/extensions";
16
const BROWSER_SUBSCRIBE_TIMEOUT_MS = 60000;
17
const BROWSER_FIRST_INSTALL_TIMEOUT_MS = 300000;
18
const BROWSER_CONFIG_REFRESH_MS = 15000;
@@ -167,6 +168,7 @@ const model = {
168
_contextCreatePromise: null,
169
_lastSelectedContextId: "",
170
_sessionRefreshPromise: null,
171
+ _sessionRefreshContextId: "",
172
extensionMenuOpen: false,
173
extensionInstallUrl: "",
174
extensionActionLoading: false,
@@ -264,18 +266,24 @@ const model = {
266
if (selectedContextId === this._lastSelectedContextId) return;
267
this._lastSelectedContextId = selectedContextId;
268
if (!this._surfaceMounted) return;
267
- void this.refreshBrowserSessions(selectedContextId);
269
+ void this.syncViewerToSelectedContext(selectedContextId);
270
},
271
272
async refreshBrowserSessions(contextId = "") {
273
+ const requestedContextId = this.normalizeContextId(contextId || this.resolveContextId());
274
if (this._sessionRefreshPromise) {
275
+ const inFlightContextId = this._sessionRefreshContextId;
276
await this._sessionRefreshPromise;
277
+ if (requestedContextId && requestedContextId !== inFlightContextId) {
278
+ return await this.refreshBrowserSessions(requestedContextId);
279
+ }
280
return;
281
}
282
+ this._sessionRefreshContextId = requestedContextId;
283
this._sessionRefreshPromise = (async () => {
284
const response = await websocket.request(
285
"browser_viewer_sessions",
278
- { context_id: this.normalizeContextId(contextId || this.resolveContextId()) },
286
+ { context_id: requestedContextId },
287
{ timeoutMs: 10000 },
288
);
289
const data = firstOk(response);
@@ -289,6 +297,45 @@ const model = {
297
console.warn("Browser session refresh failed", error);
298
} finally {
299
this._sessionRefreshPromise = null;
300
+ this._sessionRefreshContextId = "";
301
+ }
302
+ },
303
+
304
+ async syncViewerToSelectedContext(contextId = "") {
305
+ const selectedContextId = this.normalizeContextId(contextId || this.resolveContextId());
306
+ if (!selectedContextId) return;
307
+ await this.refreshBrowserSessions(selectedContextId);
308
+ if (!this._surfaceMounted || !this.isVisibleBrowserSurface()) return;
309
+
310
+ const targetBrowserId = this.firstBrowserInContext(selectedContextId)?.id || null;
311
+ if (
312
+ this.normalizeContextId(this.contextId) === selectedContextId
313
+ && (
314
+ !targetBrowserId
315
+ || this.sameBrowserTab(targetBrowserId, selectedContextId, this.activeBrowserId, this.activeBrowserContextId)
316
+ )
317
+ ) {
318
+ return;
319
+ }
320
+
321
+ this.loading = true;
322
+ this.error = "";
323
+ this.resetRenderedFrame();
324
+ this.resetViewportTracking();
325
+ this._surfaceSwitching = Boolean(targetBrowserId);
326
+ this.switchingBrowserId = targetBrowserId;
327
+ try {
328
+ await this.connectViewer({
329
+ browserId: targetBrowserId,
330
+ contextId: selectedContextId,
331
+ initialViewport: this.currentViewportSize(),
332
+ });
333
+ await this.syncViewportAfterSurfaceOpen(this._surfaceOpenSequence);
334
+ } catch (error) {
335
+ this.error = error instanceof Error ? error.message : String(error);
336
+ } finally {
337
+ this.loading = false;
338
+ this._surfaceSwitching = false;
339
}
340
},
341
@@ -605,7 +652,9 @@ const model = {
652
const requestedContextId = this.normalizeContextId(
653
options.requestedContextId ?? options.contextId ?? options.context_id,
654
);
608
- let targetContextId = requestedContextId;
655
+ let targetContextId = requestedContextId
656
+ || this.contextIdForBrowserId(requestedBrowserId)
657
+ || this.resolveContextId();
658
const nextMode = options?.nextMode || (options?.mode === "modal" ? "modal" : "canvas");
659
if (nextMode === "canvas" && !this.isCanvasSurfaceVisible(element)) {
660
this.loading = false;
@@ -719,15 +768,49 @@ const model = {
768
return Boolean(rect && Math.round(rect.width || 0) >= 80 && Math.round(rect.height || 0) >= 80);
769
},
770
771
+ isVisibleBrowserSurface() {
772
+ if (!this._surfaceMounted) return false;
773
+ if (this._mode === "canvas") {
774
+ return Boolean(rightCanvasStore?.isSurfaceVisible?.("browser"))
775
+ && this.isCanvasSurfaceVisible(globalThis.document?.querySelector?.(".browser-canvas-surface .browser-panel"));
776
+ }
777
+
778
+ const panel = globalThis.document?.querySelector?.(".modal .browser-panel");
779
+ const modal = panel?.closest?.(".modal");
780
+ if (!panel || !modal) return false;
781
+ if (modal.classList.contains("modal-surface-parked") || modal.classList.contains("surface-modal-parked")) {
782
+ return false;
783
+ }
784
+ const panelStyle = globalThis.getComputedStyle?.(panel);
785
+ if (panelStyle?.display === "none" || panelStyle?.visibility === "hidden") return false;
786
+ const rect = panel.getBoundingClientRect?.();
787
+ return Boolean(rect && Math.round(rect.width || 0) >= 80 && Math.round(rect.height || 0) >= 80);
788
+ },
789
+
790
prepareSurfaceOpen(nextMode, requestedBrowserId = null, requestedContextId = "") {
723
- const previousMode = this._mode;
724
- const modeChanged = this._surfaceMounted && previousMode && previousMode !== nextMode;
791
const targetBrowserId = requestedBrowserId || this.activeBrowserId || this.firstBrowserId(requestedContextId);
792
+ const targetContextId = this.normalizeContextId(
793
+ requestedContextId
794
+ || this.contextIdForBrowserId(targetBrowserId)
795
+ || this.resolveContextId()
796
+ || this.activeBrowserContextId
797
+ || this.contextId,
798
+ );
799
+ const targetChanged = Boolean(
800
+ targetBrowserId
801
+ && this.activeBrowserId
802
+ && !this.sameBrowserTab(targetBrowserId, targetContextId, this.activeBrowserId, this.activeBrowserContextId),
803
+ );
804
this._mode = nextMode;
805
this._surfaceMounted = true;
806
this._surfaceOpenedAt = Date.now();
807
this._lastViewportKey = "";
730
- if (!modeChanged && (this.frameSrc || !targetBrowserId)) return;
808
+ if (this.frameSrc && !targetChanged) {
809
+ this._surfaceSwitching = false;
810
+ this.switchingBrowserId = null;
811
+ return;
812
+ }
813
+ if (!targetBrowserId) return;
814
815
this.resetRenderedFrame();
816
this.resetViewportTracking();
@@ -756,7 +839,7 @@ const model = {
839
|| Math.abs(this._lastViewport.height - viewport.height) > VIEWPORT_SYNC_SIZE_TOLERANCE;
840
if (!changed) return;
841
759
- this.resetRenderedFrame();
842
+ this.cancelFrameRender();
843
this.resetViewportTracking();
844
this._surfaceSwitching = true;
845
this.switchingBrowserId = targetBrowserId;
@@ -863,6 +946,7 @@ const model = {
946
context_id: contextId,
947
browser_id: requestedBrowserId,
948
viewer_id: viewerToken,
949
+ create_browser: Boolean(options.createBrowser || options.create_browser),
950
viewport_width: initialViewport?.width,
951
viewport_height: initialViewport?.height,
952
},
@@ -1108,7 +1192,8 @@ const model = {
1192
const viewport = this.currentViewportSize();
1193
if (!this.frameSrc || !this._lastFrameDimensions || !viewport) return;
1194
if (this.frameMatchesViewport(this._lastFrameDimensions, viewport)) return;
1111
- this.resetRenderedFrame();
1195
+ this.cancelFrameRender();
1196
+ this.resetViewportTracking();
1197
if (this.activeBrowserId) {
1198
this._surfaceSwitching = true;
1199
this.switchingBrowserId = this.activeBrowserId;
@@ -1400,6 +1485,12 @@ const model = {
1485
return browsers[0] || null;
1486
},
1487
1488
+ firstBrowserInContext(contextId = "") {
1489
+ const normalizedContextId = this.normalizeContextId(contextId);
1490
+ if (!normalizedContextId || !Array.isArray(this.browsers)) return null;
1491
+ return this.browsers.find((browser) => this.normalizeContextId(browser?.context_id) === normalizedContextId) || null;
1492
+ },
1493
+
1494
firstBrowserId(contextId = "") {
1495
return this.firstBrowser(contextId)?.id || null;
1496
},
@@ -2456,8 +2547,8 @@ const model = {
2547
const header = modal?.querySelector?.(".modal-header");
2548
const stage = root?.querySelector?.(".browser-stage");
2549
if (!modal || !inner || !header) return;
2459
- modal.classList.add("modal-floating");
2460
- inner.classList.add("browser-modal");
2550
+ modal.classList.add("surface-floating", "modal-floating");
2551
+ inner.classList.add("surface-modal", "browser-modal");
2552
body?.classList?.add("browser-modal-body");
2553
this._stageElement = stage || null;
2554
@@ -2468,7 +2559,54 @@ const model = {
2559
2560
let drag = null;
2561
let resizeObserver = null;
2562
+ let beforeFocusBounds = null;
2563
const viewportGap = 8;
2564
+ const currentBounds = () => {
2565
+ const bounds = inner.getBoundingClientRect();
2566
+ return {
2567
+ left: bounds.left,
2568
+ top: bounds.top,
2569
+ width: bounds.width,
2570
+ height: bounds.height,
2571
+ };
2572
+ };
2573
+ const normalizedBounds = (bounds = {}) => {
2574
+ const maxWidth = Math.max(320, globalThis.innerWidth - viewportGap * 2);
2575
+ const maxHeight = Math.max(300, globalThis.innerHeight - viewportGap * 2);
2576
+ const width = Math.min(Math.max(320, Number(bounds.width || 320)), maxWidth);
2577
+ const height = Math.min(Math.max(300, Number(bounds.height || 300)), maxHeight);
2578
+ return {
2579
+ left: Math.min(
2580
+ Math.max(viewportGap, Number(bounds.left || viewportGap)),
2581
+ Math.max(viewportGap, globalThis.innerWidth - width - viewportGap),
2582
+ ),
2583
+ top: Math.min(
2584
+ Math.max(viewportGap, Number(bounds.top || viewportGap)),
2585
+ Math.max(viewportGap, globalThis.innerHeight - height - viewportGap),
2586
+ ),
2587
+ width,
2588
+ height,
2589
+ };
2590
+ };
2591
+ const setBounds = (bounds = {}) => {
2592
+ const next = normalizedBounds(bounds);
2593
+ inner.style.position = "fixed";
2594
+ inner.style.transform = "none";
2595
+ inner.style.left = `${Math.round(next.left)}px`;
2596
+ inner.style.top = `${Math.round(next.top)}px`;
2597
+ inner.style.width = `${Math.round(next.width)}px`;
2598
+ inner.style.height = `${Math.round(next.height)}px`;
2599
+ inner.style.maxWidth = `${Math.max(320, globalThis.innerWidth - viewportGap * 2)}px`;
2600
+ inner.style.maxHeight = `${Math.max(300, globalThis.innerHeight - viewportGap * 2)}px`;
2601
+ this.queueViewportSync();
2602
+ return next;
2603
+ };
2604
+ const focusBounds = () => ({
2605
+ left: viewportGap,
2606
+ top: viewportGap,
2607
+ width: globalThis.innerWidth - viewportGap * 2,
2608
+ height: globalThis.innerHeight - viewportGap * 2,
2609
+ });
2610
const clampPosition = (left, top) => {
2611
const bounds = inner.getBoundingClientRect();
2612
const maxLeft = Math.max(viewportGap, globalThis.innerWidth - bounds.width - viewportGap);
@@ -2479,25 +2617,47 @@ const model = {
2617
};
2618
};
2619
const clampGeometry = () => {
2482
- const bounds = inner.getBoundingClientRect();
2483
- const left = Math.max(viewportGap, bounds.left);
2484
- const top = Math.max(viewportGap, bounds.top);
2485
- const maxWidth = Math.max(320, globalThis.innerWidth - viewportGap * 2);
2486
- const maxHeight = Math.max(300, globalThis.innerHeight - viewportGap * 2);
2487
- if (bounds.width > maxWidth) {
2488
- inner.style.width = `${maxWidth}px`;
2489
- }
2490
- if (bounds.height > maxHeight) {
2491
- inner.style.height = `${maxHeight}px`;
2620
+ if (inner.classList.contains("is-focus-mode")) {
2621
+ setBounds(focusBounds());
2622
+ return;
2623
}
2493
- const next = clampPosition(left, top);
2494
- inner.style.left = `${next.left}px`;
2495
- inner.style.top = `${next.top}px`;
2496
- inner.style.maxWidth = `${Math.max(320, globalThis.innerWidth - next.left - viewportGap)}px`;
2497
- inner.style.maxHeight = `${Math.max(300, globalThis.innerHeight - next.top - viewportGap)}px`;
2498
- this.queueViewportSync();
2624
+ setBounds(currentBounds());
2625
};
2626
clampGeometry();
2627
+
2628
+ const focusButton = globalThis.document.createElement("button");
2629
+ focusButton.type = "button";
2630
+ focusButton.className = "surface-button browser-modal-focus-button";
2631
+ focusButton.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">fullscreen</span>';
2632
+ const updateFocusButton = (active) => {
2633
+ const label = active ? "Restore size" : "Focus mode";
2634
+ focusButton.setAttribute("aria-label", label);
2635
+ focusButton.setAttribute("title", label);
2636
+ focusButton.querySelector(".material-symbols-outlined").textContent = active ? "fullscreen_exit" : "fullscreen";
2637
+ };
2638
+ const setFocusMode = (enabled) => {
2639
+ if (enabled) {
2640
+ beforeFocusBounds = currentBounds();
2641
+ inner.classList.add("is-focus-mode");
2642
+ setBounds(focusBounds());
2643
+ updateFocusButton(true);
2644
+ return;
2645
+ }
2646
+ inner.classList.remove("is-focus-mode");
2647
+ setBounds(beforeFocusBounds || currentBounds());
2648
+ beforeFocusBounds = null;
2649
+ updateFocusButton(false);
2650
+ };
2651
+ updateFocusButton(false);
2652
+ const closeButton = inner.querySelector(".modal-close");
2653
+ if (closeButton) {
2654
+ closeButton.insertAdjacentElement("beforebegin", focusButton);
2655
+ } else {
2656
+ header.appendChild(focusButton);
2657
+ }
2658
+ const onFocusClick = () => setFocusMode(!inner.classList.contains("is-focus-mode"));
2659
+ focusButton.addEventListener("click", onFocusClick);
2660
+
2661
globalThis.addEventListener("resize", clampGeometry);
2662
if (globalThis.ResizeObserver) {
2663
resizeObserver = new ResizeObserver(clampGeometry);
@@ -2537,6 +2697,7 @@ const model = {
2697
const onPointerDown = (event) => {
2698
if (event.button !== 0) return;
2699
if (event.target?.closest?.("button, input, select, textarea, a")) return;
2700
+ if (inner.classList.contains("is-focus-mode")) return;
2701
const current = inner.getBoundingClientRect();
2702
drag = {
2703
x: event.clientX,
@@ -2553,6 +2714,8 @@ const model = {
2714
header.addEventListener("pointerdown", onPointerDown);
2715
2716
this._floatingCleanup = () => {
2717
+ focusButton.removeEventListener("click", onFocusClick);
2718
+ focusButton.remove();
2719
header.removeEventListener("pointerdown", onPointerDown);
2720
globalThis.removeEventListener("pointermove", onPointerMove);
2721
globalThis.removeEventListener("pointerup", onPointerUp);
@@ -2560,6 +2723,7 @@ const model = {
2723
resizeObserver?.disconnect?.();
2724
this._stageResizeObserver?.disconnect?.();
2725
this._stageResizeObserver = null;
2726
+ inner.classList.remove("is-focus-mode");
2727
};
2728
},
2729
@@ -2601,3 +2765,10 @@ const model = {
2765
};
2766
2767
export const store = createStore("browserPage", model);
2768
+
2769
+registerUrlHandler(async (intent = {}) => {
2770
+ const url = String(intent.url || "").trim();
2771
+ const payload = { url, source: intent.source || "surface-url-intent" };
2772
+ await openLatestSurface("browser", payload);
2773
+ return await store.openUrlIntent(url, { source: payload.source });
2774
+});
plugins/_browser/webui/config.html
+2
-2
@@ -18,7 +18,7 @@
18
<div class="browser-config-card">
19
<div class="section-title">Browsing</div>
20
<div class="section-description">
21
- Set how new Browser sessions start and how an already-open Browser canvas follows agent activity.
21
+ Set how new Browser sessions start and how an already-open Browser surface follows agent activity.
22
</div>
23
24
<label class="browser-config-field">
@@ -34,7 +34,7 @@
34
<label class="browser-config-switch-row">
35
<span class="browser-config-switch-copy">
36
<span class="browser-config-field-label">Autofocus active page</span>
37
- <span class="browser-config-field-help">Update the visible Browser canvas for pages opened or changed by Browser tool results.</span>
37
+ <span class="browser-config-field-help">Update the visible Browser surface for pages opened or changed by Browser tool results.</span>
38
</span>
39
<span class="browser-config-toggle-with-label">
40
<span class="browser-config-toggle-label" x-text="$store.browserConfig.autofocusLabel()"></span>
plugins/_browser/webui/main.html
+4
@@ -1,5 +1,9 @@
1
<html
2
class="browser-modal"
3
+ data-surface-id="browser"
4
+ data-surface-modal-path="/plugins/_browser/webui/main.html"
5
+ data-surface-dock-title="Open Browser in surface"
6
+ data-surface-dock-icon="dock_to_right"
7
data-canvas-surface="browser"
8
data-canvas-modal-path="/plugins/_browser/webui/main.html"
9
data-canvas-dock-title="Open Browser in canvas"
webui/components/canvas/right-canvas-store.js
+45
-59
@@ -1,13 +1,21 @@
1
import { createStore } from "/js/AlpineStore.js";
2
import { callJsExtensions } from "/js/extensions.js";
3
+import {
4
+ SURFACE_MODE_DOCKED,
5
+ SURFACE_MODE_FLOATING,
6
+ closeSurfaceGroupModals,
7
+ getRegisteredSurfaces,
8
+ migratePersistedSurfaceState,
9
+ normalizeSurfaceId,
10
+ normalizeSurfaceMode,
11
+ registerSurface as registerSurfaceDefinition,
12
+} from "/js/surfaces.js";
13
14
const STORAGE_KEY = "a0.rightCanvas";
15
const DEFAULT_WIDTH = 720;
16
const MIN_WIDTH = 0;
17
const DESKTOP_BREAKPOINT = 1200;
18
const MOBILE_BREAKPOINT = 768;
9
-const SURFACE_MODE_CANVAS = "canvas";
10
-const SURFACE_MODE_MODAL = "modal";
19
20
function clamp(value, min, max) {
21
return Math.min(Math.max(value, min), max);
@@ -23,10 +31,6 @@ function normalizeWidth(value, fallback = DEFAULT_WIDTH) {
31
return Number.isFinite(width) ? Math.max(MIN_WIDTH, Math.round(width)) : fallback;
32
}
33
26
-function normalizeSurfaceMode(mode = "") {
27
- return mode === SURFACE_MODE_MODAL ? SURFACE_MODE_MODAL : SURFACE_MODE_CANVAS;
28
-}
29
-
34
const model = {
35
surfaces: [],
36
activeSurfaceId: "",
@@ -61,6 +65,7 @@ const model = {
65
66
if (!this._registering) {
67
this._registering = true;
68
+ await callJsExtensions("surfaces_register", this);
69
await callJsExtensions("right_canvas_register_surfaces", this);
70
this._registering = false;
71
this.ensureActiveSurface();
@@ -69,6 +74,7 @@ const model = {
74
75
registerSurface(surface) {
76
if (!surface?.id) return;
77
+ const surfaceId = normalizeSurfaceId(surface.id);
78
const normalized = {
79
title: surface.id,
80
icon: "web_asset",
@@ -80,6 +86,7 @@ const model = {
86
modalPath: "",
87
actionOnly: false,
88
...surface,
89
+ id: surfaceId,
90
};
91
92
const index = this.surfaces.findIndex((item) => item.id === normalized.id);
@@ -89,8 +96,9 @@ const model = {
96
this.surfaces.push(normalized);
97
}
98
if (!this.surfaceModes[normalized.id]) {
92
- this.surfaceModes[normalized.id] = SURFACE_MODE_CANVAS;
99
+ this.surfaceModes[normalized.id] = SURFACE_MODE_DOCKED;
100
}
101
+ registerSurfaceDefinition(normalized);
102
this.surfaces.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
103
if (!this._registering) {
104
this.ensureActiveSurface();
@@ -109,7 +117,7 @@ const model = {
117
},
118
119
async open(surfaceId = "", payload = {}) {
112
- const targetId = surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "";
120
+ const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "");
121
const surface = this.getSurface(targetId);
122
if (!surface) {
123
return false;
@@ -133,7 +141,7 @@ const model = {
141
this.activeSurfaceId = targetId;
142
this.markSurfaceMounted(targetId);
143
this.isOpen = true;
136
- this.recordSurfaceMode(targetId, SURFACE_MODE_CANVAS, { persist: false });
144
+ this.recordSurfaceMode(targetId, SURFACE_MODE_DOCKED, { persist: false });
145
this._lastPayloadBySurface[targetId] = payload || {};
146
this.persist();
147
this.applyLayoutState();
@@ -147,7 +155,7 @@ const model = {
155
},
156
157
markSurfaceMounted(surfaceId) {
150
- const targetId = String(surfaceId || "").trim();
158
+ const targetId = normalizeSurfaceId(surfaceId);
159
if (!targetId) return;
160
this.mountedSurfaces = {
161
...this.mountedSurfaces,
@@ -156,7 +164,7 @@ const model = {
164
},
165
166
markSurfaceUnmounted(surfaceId) {
159
- const targetId = String(surfaceId || "").trim();
167
+ const targetId = normalizeSurfaceId(surfaceId);
168
if (!targetId || !this.mountedSurfaces[targetId]) return;
169
const next = { ...this.mountedSurfaces };
170
delete next[targetId];
@@ -170,7 +178,7 @@ const model = {
178
},
179
180
isSurfaceMounted(id) {
173
- return Boolean(this.mountedSurfaces[String(id || "").trim()]);
181
+ return Boolean(this.mountedSurfaces[normalizeSurfaceId(id)]);
182
},
183
184
isSurfaceRendered(id) {
@@ -178,11 +186,12 @@ const model = {
186
},
187
188
isSurfaceVisible(id) {
181
- return Boolean(this.isOpen && this.activeSurfaceId === id && this.isSurfaceMounted(id));
189
+ const targetId = normalizeSurfaceId(id);
190
+ return Boolean(this.isOpen && this.activeSurfaceId === targetId && this.isSurfaceMounted(targetId));
191
},
192
184
- recordSurfaceMode(surfaceId, mode = SURFACE_MODE_CANVAS, options = {}) {
185
- const targetId = String(surfaceId || "").trim();
193
+ recordSurfaceMode(surfaceId, mode = SURFACE_MODE_DOCKED, options = {}) {
194
+ const targetId = normalizeSurfaceId(surfaceId);
195
if (!targetId) return;
196
this.surfaceModes = {
197
...this.surfaceModes,
@@ -192,37 +201,28 @@ const model = {
201
},
202
203
latestSurfaceMode(surfaceId) {
195
- const targetId = String(surfaceId || "").trim();
204
+ const targetId = normalizeSurfaceId(surfaceId);
205
return normalizeSurfaceMode(this.surfaceModes[targetId]);
206
},
207
208
async openLatest(surfaceId = "", payload = {}) {
200
- const targetId = surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "";
209
+ const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "");
210
if (!targetId) return false;
202
- if (this.latestSurfaceMode(targetId) === SURFACE_MODE_MODAL) {
211
+ if (this.latestSurfaceMode(targetId) === SURFACE_MODE_FLOATING) {
212
return await this.openModalSurface(targetId, payload);
213
}
214
return await this.open(targetId, payload);
215
},
216
217
async close() {
209
- const mountedIds = this.mountedSurfaceIds();
218
this.isOpen = false;
211
- this.mountedSurfaces = {};
219
this.persist();
220
this.applyLayoutState();
214
-
215
- for (const surfaceId of mountedIds) {
216
- const surface = this.getSurface(surfaceId);
217
- try {
218
- await surface?.close?.(this._lastPayloadBySurface[surfaceId] || {});
219
- } catch (error) {
220
- console.error(`Canvas surface ${surfaceId} failed to close`, error);
221
- }
222
- }
221
+ return true;
222
},
223
224
async dockSurface(surfaceId, payload = {}) {
225
+ surfaceId = normalizeSurfaceId(surfaceId);
226
if (this.isMobileMode) {
227
return false;
228
}
@@ -262,6 +262,11 @@ const model = {
262
}
263
264
const sourceModalPath = payload.sourceModalPath || modalPath;
265
+ if (sourceModalPath || modalPath) {
266
+ const closed = await closeSurfaceGroupModals();
267
+ if (closed === false) return false;
268
+ if (!sourceModalPath || !globalThis.isModalOpen?.(sourceModalPath)) return true;
269
+ }
270
if (sourceModalPath && globalThis.isModalOpen?.(sourceModalPath)) {
271
return (await globalThis.closeModal?.(sourceModalPath)) !== false;
272
}
@@ -272,29 +277,18 @@ const model = {
277
},
278
279
async undockSurface(surfaceId = "", payload = {}) {
275
- const targetId = surfaceId || this.activeSurfaceId;
280
+ const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId);
281
const surface = this.getSurface(targetId);
282
const modalPath = payload.modalPath || surface?.modalPath || "";
283
if (!surface || !modalPath) return false;
284
const openModal = globalThis.ensureModalOpen || globalThis.openModal;
285
if (!openModal) return false;
286
if (this.activeSurfaceId === targetId) {
282
- const mountedIds = this.mountedSurfaceIds();
287
this.isOpen = false;
284
- this.mountedSurfaces = {};
288
this.persist();
289
this.applyLayoutState();
287
-
288
- for (const mountedId of mountedIds) {
289
- const mountedSurface = this.getSurface(mountedId);
290
- try {
291
- await mountedSurface?.close?.(this._lastPayloadBySurface[mountedId] || {});
292
- } catch (error) {
293
- console.error(`Canvas surface ${mountedId} failed to close while undocking`, error);
294
- }
295
- }
290
}
297
- this.recordSurfaceMode(targetId, SURFACE_MODE_MODAL);
291
+ this.recordSurfaceMode(targetId, SURFACE_MODE_FLOATING);
292
const modalPromise = openModal(modalPath);
293
if (modalPromise?.catch) {
294
modalPromise.catch((error) => console.error(`Canvas surface ${targetId} failed to undock`, error));
@@ -303,7 +297,7 @@ const model = {
297
},
298
299
async openModalSurface(surfaceId = "", payload = {}) {
306
- const targetId = surfaceId || this.activeSurfaceId;
300
+ const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId);
301
const surface = this.getSurface(targetId);
302
const modalPath = payload.modalPath || surface?.modalPath || "";
303
if (!surface || !modalPath) return false;
@@ -311,23 +305,12 @@ const model = {
305
if (!openModal) return false;
306
307
if (this.isOpen && this.activeSurfaceId === targetId) {
314
- const mountedIds = this.mountedSurfaceIds();
308
this.isOpen = false;
316
- this.mountedSurfaces = {};
309
this.persist();
310
this.applyLayoutState();
319
-
320
- for (const mountedId of mountedIds) {
321
- const mountedSurface = this.getSurface(mountedId);
322
- try {
323
- await mountedSurface?.close?.(this._lastPayloadBySurface[mountedId] || {});
324
- } catch (error) {
325
- console.error(`Canvas surface ${mountedId} failed to close before modal open`, error);
326
- }
327
- }
311
}
312
330
- this.recordSurfaceMode(targetId, SURFACE_MODE_MODAL);
313
+ this.recordSurfaceMode(targetId, SURFACE_MODE_FLOATING);
314
const modalPromise = openModal(modalPath);
315
if (modalPromise?.catch) {
316
modalPromise.catch((error) => console.error(`Canvas surface ${targetId} failed to open as modal`, error));
@@ -344,7 +327,7 @@ const model = {
327
},
328
329
async toggle(surfaceId = "", payload = {}) {
347
- const targetId = surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "";
330
+ const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "");
331
if (this.isOpen && targetId === this.activeSurfaceId) {
332
await this.close();
333
return false;
@@ -444,7 +427,7 @@ const model = {
427
restore() {
428
this.width = this.defaultWidth();
429
try {
447
- const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
430
+ const saved = migratePersistedSurfaceState(JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"));
431
this.isOpen = false;
432
this.activeSurfaceId = String(saved.activeSurfaceId || "");
433
this.surfaceModes = Object.fromEntries(
@@ -502,7 +485,10 @@ const model = {
485
},
486
487
getSurface(id) {
505
- return this.surfaces.find((surface) => surface.id === id) || null;
488
+ const targetId = normalizeSurfaceId(id);
489
+ return this.surfaces.find((surface) => surface.id === targetId)
490
+ || getRegisteredSurfaces().find((surface) => surface.id === targetId)
491
+ || null;
492
},
493
494
get railSurfaces() {
@@ -518,7 +504,7 @@ const model = {
504
},
505
506
isSurfaceActive(id) {
521
- return this.activeSurfaceId === id;
507
+ return this.activeSurfaceId === normalizeSurfaceId(id);
508
},
509
510
activeTitle() {
webui/components/canvas/right-canvas.css
+7
@@ -142,6 +142,8 @@ body.right-canvas-resizing {
142
}
143
144
.right-canvas-header {
145
+ position: relative;
146
+ z-index: 10;
147
display: grid;
148
grid-template-columns: minmax(0, 1fr) auto;
149
align-items: center;
@@ -150,6 +152,7 @@ body.right-canvas-resizing {
152
padding: 7px 8px 0 10px;
153
border-bottom: 1px solid var(--right-canvas-border);
154
background: color-mix(in srgb, var(--color-background) 91%, #000 9%);
155
+ overflow: visible;
156
}
157
158
.right-canvas-tabs {
@@ -214,14 +217,18 @@ body.right-canvas-resizing {
217
}
218
219
.right-canvas-toolbar {
220
+ position: relative;
221
+ z-index: 11;
222
display: flex;
223
align-items: center;
224
gap: 5px;
225
padding-bottom: 6px;
226
+ overflow: visible;
227
}
228
229
.right-canvas-panels {
230
position: relative;
231
+ z-index: 1;
232
display: flex;
233
flex: 1 1 auto;
234
min-width: 0;
webui/css/modals.css
-66
@@ -18,24 +18,6 @@ the old and the new system. */
18
display: block;
19
}
20
21
-.modal.modal-surface-parked {
22
- display: block;
23
- opacity: 0;
24
- pointer-events: none;
25
-}
26
-
27
-.modal.modal-surface-parked .modal-inner {
28
- pointer-events: none;
29
-}
30
-
31
-.modal.modal-floating {
32
- pointer-events: none;
33
-}
34
-
35
-.modal.modal-floating .modal-inner {
36
- pointer-events: auto;
37
-}
38
-
21
.modal-inner {
22
display: flex;
23
flex-direction: column;
@@ -162,54 +144,6 @@ the old and the new system. */
144
background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
145
}
146
165
-.modal-surface-switcher {
166
- display: grid;
167
- grid-auto-flow: column;
168
- grid-auto-columns: 34px;
169
- align-items: center;
170
- gap: 5px;
171
-}
172
-
173
-.modal-dock-button,
174
-.modal-surface-button {
175
- display: inline-flex;
176
- align-items: center;
177
- justify-content: center;
178
- width: 34px;
179
- height: 34px;
180
- min-width: 34px;
181
- min-height: 34px;
182
- padding: 0;
183
- border: 1px solid transparent;
184
- border-radius: 7px;
185
- background: transparent;
186
- color: var(--color-text);
187
- cursor: pointer;
188
- opacity: 0.72;
189
- transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
190
-}
191
-
192
-.modal-dock-button:hover,
193
-.modal-surface-button:hover,
194
-.modal-surface-button.is-active {
195
- opacity: 1;
196
- border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
197
- background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
198
-}
199
-
200
-.modal-dock-button .material-symbols-outlined,
201
-.modal-surface-button .material-symbols-outlined {
202
- font-size: 19px;
203
-}
204
-
205
-.modal-surface-image {
206
- display: block;
207
- width: 22px;
208
- height: 22px;
209
- border-radius: 6px;
210
- object-fit: cover;
211
-}
212
-
147
/* Modal Description */
148
.modal-description {
149
padding: 0.8rem 1rem 0 1rem;
webui/css/surfaces.css
new
+83
@@ -0,0 +1,83 @@
1
+.modal.surface-modal-parked,
2
+.modal.modal-surface-parked {
3
+ display: block;
4
+ opacity: 0;
5
+ pointer-events: none;
6
+}
7
+
8
+.modal.surface-modal-parked .modal-inner,
9
+.modal.modal-surface-parked .modal-inner {
10
+ pointer-events: none;
11
+}
12
+
13
+.modal.surface-floating,
14
+.modal.modal-floating {
15
+ pointer-events: none;
16
+}
17
+
18
+.modal.surface-floating .modal-inner,
19
+.modal.modal-floating .modal-inner {
20
+ pointer-events: auto;
21
+}
22
+
23
+.surface-switcher,
24
+.modal-surface-switcher {
25
+ display: grid;
26
+ grid-auto-flow: column;
27
+ grid-auto-columns: 34px;
28
+ align-items: center;
29
+ gap: 5px;
30
+}
31
+
32
+.surface-dock-button,
33
+.surface-button,
34
+.modal-dock-button,
35
+.modal-surface-button {
36
+ display: inline-flex;
37
+ align-items: center;
38
+ justify-content: center;
39
+ width: 34px;
40
+ height: 34px;
41
+ min-width: 34px;
42
+ min-height: 34px;
43
+ padding: 0;
44
+ border: 1px solid transparent;
45
+ border-radius: 7px;
46
+ background: transparent;
47
+ color: var(--color-text);
48
+ cursor: pointer;
49
+ opacity: 0.72;
50
+ transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
51
+}
52
+
53
+.surface-dock-button:hover,
54
+.surface-button:hover,
55
+.surface-button.is-active,
56
+.modal-dock-button:hover,
57
+.modal-surface-button:hover,
58
+.modal-surface-button.is-active {
59
+ opacity: 1;
60
+ border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
61
+ background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
62
+}
63
+
64
+.surface-dock-button .material-symbols-outlined,
65
+.surface-button .material-symbols-outlined,
66
+.modal-dock-button .material-symbols-outlined,
67
+.modal-surface-button .material-symbols-outlined {
68
+ font-size: 19px;
69
+}
70
+
71
+.surface-image,
72
+.modal-surface-image {
73
+ display: block;
74
+ width: 22px;
75
+ height: 22px;
76
+ border-radius: 6px;
77
+ object-fit: cover;
78
+}
79
+
80
+.surface-resize-handle {
81
+ position: absolute;
82
+ touch-action: none;
83
+}
webui/index.html
+1
@@ -13,6 +13,7 @@
13
<link rel="stylesheet" href="css/toast.css">
14
<link rel="stylesheet" href="css/settings.css">
15
<link rel="stylesheet" href="css/modals.css">
16
+ <link rel="stylesheet" href="css/surfaces.css">
17
<link rel="stylesheet" href="css/speech.css">
18
<link rel="stylesheet" href="css/scheduler-datepicker.css">
19
<link rel="stylesheet" href="css/scheduler.css">
webui/js/initFw.js
+2
-1
@@ -1,5 +1,6 @@
1
import * as initializer from "./initializer.js";
2
import * as _modals from "./modals.js";
3
+import "./surfaces.js";
4
import * as _components from "./components.js";
5
import * as extensions from "./extensions.js";
6
import { registerAlpineMagic } from "./confirmClick.js";
@@ -182,4 +183,4 @@ Alpine.directive(
183
});
184
185
// process extensions
185
-await extensions.callJsExtensions("initFw_end")
\ No newline at end of file
186
+await extensions.callJsExtensions("initFw_end")
webui/js/modals.js
+50
-218
@@ -1,86 +1,57 @@
1
// Import the component loader and page utilities
2
import { importComponent } from "/js/components.js";
3
import { callJsExtensions } from "/js/extensions.js";
4
-import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";
4
5
// Modal functionality
6
const modalStack = [];
8
-const EXPLICIT_CLOSE_MODAL_PATHS = new Set([
9
- "plugins/_browser/webui/main.html",
10
- "plugins/_office/webui/main.html",
11
-]);
12
-const SINGLE_VISIBLE_MODAL_SURFACE_PATHS = new Set([
13
- "plugins/_browser/webui/main.html",
14
- "plugins/_office/webui/main.html",
15
-]);
16
-const CANVAS_SURFACE_MODAL_GROUP = "canvas-surfaces";
17
-const DEFAULT_MODAL_SURFACES = [
18
- {
19
- id: "browser",
20
- title: "Browser",
21
- icon: "language",
22
- modalPath: "/plugins/_browser/webui/main.html",
23
- },
24
- {
25
- id: "office",
26
- title: "Desktop",
27
- icon: "desktop_windows",
28
- modalPath: "/plugins/_office/webui/main.html",
29
- },
30
-];
31
-
32
-function normalizeModalPath(modalPath = "") {
33
- return String(modalPath || "").replace(/^\/+/, "");
34
-}
7
8
function sameModalPath(left = "", right = "") {
37
- return normalizeModalPath(left) === normalizeModalPath(right);
9
+ return String(left || "").replace(/^\/+/, "") === String(right || "").replace(/^\/+/, "");
10
}
11
40
-function modalRequiresExplicitClose(modalOrElement) {
12
+function modalHasClass(modalOrElement, className) {
13
const element = modalOrElement?.element || modalOrElement;
42
- const path = normalizeModalPath(modalOrElement?.path || element?.path || "");
43
- return EXPLICIT_CLOSE_MODAL_PATHS.has(path)
44
- || element?.classList?.contains("modal-explicit-close")
45
- || element?.querySelector?.(".modal-inner")?.classList?.contains("modal-explicit-close");
14
+ return Boolean(
15
+ element?.classList?.contains(className)
16
+ || element?.querySelector?.(".modal-inner")?.classList?.contains(className)
17
+ );
18
}
19
48
-function modalSurfaceGroup(modalOrElement) {
20
+function modalDatasetFlag(modalOrElement, name) {
21
const element = modalOrElement?.element || modalOrElement;
50
- const path = normalizeModalPath(modalOrElement?.path || element?.path || "");
51
- return SINGLE_VISIBLE_MODAL_SURFACE_PATHS.has(path) ? CANVAS_SURFACE_MODAL_GROUP : "";
22
+ const inner = element?.querySelector?.(".modal-inner");
23
+ const value = element?.dataset?.[name] ?? inner?.dataset?.[name] ?? "";
24
+ return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
25
}
26
54
-function setModalParked(modal, parked = false) {
55
- const element = modal?.element;
56
- if (!element) return;
57
- element.classList.toggle("modal-surface-parked", parked);
58
- if (parked) {
59
- element.classList.remove("show");
60
- element.setAttribute("aria-hidden", "true");
61
- } else {
62
- element.classList.add("show");
63
- element.removeAttribute("aria-hidden");
64
- }
27
+function modalRequiresExplicitClose(modalOrElement) {
28
+ return modalHasClass(modalOrElement, "modal-explicit-close")
29
+ || modalDatasetFlag(modalOrElement, "modalExplicitClose");
30
}
31
67
-function parkSiblingSurfaceModals(activeModal) {
68
- const group = modalSurfaceGroup(activeModal);
69
- if (!group) {
70
- setModalParked(activeModal, false);
71
- return;
72
- }
32
+function modalSuppressesBackdrop(modalOrElement) {
33
+ return modalHasClass(modalOrElement, "modal-no-backdrop")
34
+ || modalDatasetFlag(modalOrElement, "modalNoBackdrop");
35
+}
36
74
- for (const modal of modalStack) {
75
- setModalParked(modal, modal !== activeModal && modalSurfaceGroup(modal) === group);
76
- }
37
+function dispatchModalEvent(name, modal, detail = {}) {
38
+ document.dispatchEvent(
39
+ new CustomEvent(name, {
40
+ detail: {
41
+ modalPath: modal?.path ?? null,
42
+ modal,
43
+ modalStack: getModalStack(),
44
+ ...detail,
45
+ },
46
+ }),
47
+ );
48
}
49
50
function activateModal(modal) {
51
if (!modal) return;
81
- parkSiblingSurfaceModals(modal);
52
updateModalZIndexes();
53
restoreModalScrollSnapshot(modal);
54
+ dispatchModalEvent("modal-activated", modal);
55
}
56
57
function findModalIndexByPath(modalPath) {
@@ -133,17 +104,6 @@ backdrop.style.display = "none";
104
backdrop.style.backdropFilter = "blur(8px) saturate(112%)";
105
document.body.appendChild(backdrop);
106
136
-function modalSuppressesBackdrop(modal) {
137
- const path = String(modal?.path || "");
138
- return path === "/plugins/_browser/webui/main.html"
139
- || path === "plugins/_browser/webui/main.html"
140
- || path === "/plugins/_office/webui/main.html"
141
- || path === "plugins/_office/webui/main.html"
142
- || modal?.element?.classList?.contains("modal-floating")
143
- || modal?.element?.classList?.contains("modal-no-backdrop")
144
- || modal?.inner?.classList?.contains("modal-no-backdrop");
145
-}
146
-
107
// Function to update z-index for all modals and backdrop
108
function updateModalZIndexes() {
109
// Base z-index for modals
@@ -186,6 +146,7 @@ function createModalElement(path) {
146
const newModal = document.createElement("div");
147
newModal.className = "modal";
148
newModal.path = path; // save name to the object
149
+ newModal.dataset.modalPath = path;
150
151
// Add click handlers to only close modal if both mousedown and mouseup are on the modal container
152
let mouseDownTarget = null;
@@ -250,152 +211,6 @@ function createModalElement(path) {
211
};
212
}
213
253
-function getDockMetadata(doc, modalPath) {
254
- const htmlDataset = doc?.documentElement?.dataset || {};
255
- const bodyDataset = doc?.body?.dataset || {};
256
- const surfaceId = htmlDataset.canvasSurface || bodyDataset.canvasSurface || "";
257
- if (!surfaceId) return null;
258
- return {
259
- surfaceId,
260
- modalPath: htmlDataset.canvasModalPath || bodyDataset.canvasModalPath || modalPath,
261
- title: htmlDataset.canvasDockTitle || bodyDataset.canvasDockTitle || "Open in canvas",
262
- icon: htmlDataset.canvasDockIcon || bodyDataset.canvasDockIcon || "dock_to_right",
263
- };
264
-}
265
-
266
-function getModalSwitchSurfaces(metadata) {
267
- const surfacesById = new Map(DEFAULT_MODAL_SURFACES.map((surface) => [surface.id, surface]));
268
- const surfaces = Array.isArray(rightCanvasStore.panelSurfaces)
269
- ? rightCanvasStore.panelSurfaces
270
- : [];
271
-
272
- for (const surface of surfaces) {
273
- if (!surface?.id || !surface.modalPath || surface.actionOnly) continue;
274
- surfacesById.set(surface.id, {
275
- ...surface,
276
- modalPath: surface.modalPath,
277
- });
278
- }
279
-
280
- if (metadata?.surfaceId && !surfacesById.has(metadata.surfaceId)) {
281
- surfacesById.set(metadata.surfaceId, {
282
- id: metadata.surfaceId,
283
- title: metadata.title,
284
- icon: metadata.icon,
285
- modalPath: metadata.modalPath,
286
- });
287
- }
288
-
289
- return Array.from(surfacesById.values())
290
- .filter((surface) => surface?.id && surface.modalPath && !surface.actionOnly)
291
- .sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
292
-}
293
-
294
-function createModalSurfaceButton(surface, metadata, modal) {
295
- const title = surface.title || surface.id;
296
- const targetModalPath = surface.modalPath || "";
297
- const isActive = surface.id === metadata.surfaceId || sameModalPath(targetModalPath, modal.path);
298
- const button = document.createElement("button");
299
- button.type = "button";
300
- button.className = "modal-surface-button";
301
- button.dataset.canvasSurface = surface.id;
302
- button.setAttribute("aria-label", title);
303
- button.setAttribute("aria-pressed", isActive.toString());
304
- if (isActive) button.classList.add("is-active");
305
-
306
- if (surface.image) {
307
- const image = document.createElement("img");
308
- image.className = "modal-surface-image";
309
- image.src = surface.image;
310
- image.alt = "";
311
- image.setAttribute("aria-hidden", "true");
312
- button.appendChild(image);
313
- } else {
314
- const icon = document.createElement("span");
315
- icon.className = "material-symbols-outlined";
316
- icon.setAttribute("aria-hidden", "true");
317
- icon.textContent = surface.icon || "web_asset";
318
- button.appendChild(icon);
319
- }
320
-
321
- button.addEventListener("click", () => {
322
- if (button.disabled || isActive || !targetModalPath) return;
323
- button.disabled = true;
324
- try {
325
- rightCanvasStore.recordSurfaceMode?.(surface.id, "modal");
326
- const openPromise = ensureModalOpen(targetModalPath);
327
- if (openPromise?.catch) {
328
- openPromise.catch((error) => console.error(`Modal surface ${surface.id} failed to open`, error));
329
- }
330
- } finally {
331
- if (document.contains(button)) button.disabled = false;
332
- }
333
- });
334
-
335
- return button;
336
-}
337
-
338
-function configureModalSurfaceSwitcher(modal, doc) {
339
- const metadata = getDockMetadata(doc, modal.path);
340
- if (!metadata || !modal.header || modal.header.querySelector(".modal-surface-switcher")) {
341
- return metadata;
342
- }
343
-
344
- const surfaces = getModalSwitchSurfaces(metadata);
345
- if (surfaces.length <= 1) return metadata;
346
-
347
- const switcher = document.createElement("div");
348
- switcher.className = "modal-surface-switcher";
349
- switcher.setAttribute("role", "group");
350
- switcher.setAttribute("aria-label", "Modal surfaces");
351
-
352
- for (const surface of surfaces) {
353
- switcher.appendChild(createModalSurfaceButton(surface, metadata, modal));
354
- }
355
-
356
- modal.close?.insertAdjacentElement("beforebegin", switcher);
357
- return metadata;
358
-}
359
-
360
-function configureModalDockButton(modal, doc) {
361
- const metadata = configureModalSurfaceSwitcher(modal, doc);
362
- if (!metadata || !modal.header || modal.header.querySelector(".modal-dock-button")) {
363
- return;
364
- }
365
-
366
- rightCanvasStore.recordSurfaceMode?.(metadata.surfaceId, "modal");
367
-
368
- const button = document.createElement("button");
369
- button.type = "button";
370
- button.className = "modal-dock-button";
371
- button.setAttribute("aria-label", metadata.title);
372
- button.innerHTML = `<span class="material-symbols-outlined" aria-hidden="true">${metadata.icon}</span>`;
373
- button.addEventListener("click", async () => {
374
- if (button.disabled) return;
375
- button.disabled = true;
376
- try {
377
- await rightCanvasStore.dockSurface?.(metadata.surfaceId, {
378
- modalPath: metadata.modalPath,
379
- sourceModalPath: modal.path,
380
- source: "modal",
381
- closeSourceModal: async () => {
382
- const closed = await closeModal(modal.path);
383
- if (closed === false) return false;
384
- if (document.contains(modal.element)) {
385
- const fallbackClosed = await closeModal();
386
- if (fallbackClosed === false) return false;
387
- }
388
- return !document.contains(modal.element);
389
- },
390
- });
391
- } finally {
392
- if (document.contains(button)) button.disabled = false;
393
- }
394
- });
395
-
396
- modal.close?.insertAdjacentElement("beforebegin", button);
397
-}
398
-
214
// Function to open modal with content from URL
215
export async function openModal(modalPath, beforeClose = null) {
216
const openCtx = { modalPath, modal: null, cancel: false };
@@ -432,7 +247,7 @@ export async function openModal(modalPath, beforeClose = null) {
247
248
// Use importComponent which now returns the parsed document
249
importComponent(componentPath, modal.body)
435
- .then((doc) => {
250
+ .then(async (doc) => {
251
// Set the title from the document
252
modal.title.innerHTML = doc.title || modalPath;
253
if (doc.html && doc.html.classList) {
@@ -442,8 +257,13 @@ export async function openModal(modalPath, beforeClose = null) {
257
if (doc.body && doc.body.classList) {
258
modal.body.classList.add(...doc.body.classList);
259
}
445
- configureModalDockButton(modal, doc);
446
- updateModalZIndexes();
260
+ await callJsExtensions("modal_content_loaded", {
261
+ modalPath,
262
+ modal,
263
+ doc,
264
+ });
265
+ dispatchModalEvent("modal-content-loaded", modal, { doc });
266
+ refreshModalStack();
267
268
// Some modals have a footer. Check if it exists and move it to footer slot
269
// Use requestAnimationFrame to let Alpine mount the component first
@@ -480,6 +300,18 @@ export function isModalOpen(modalPath) {
300
return findModalIndexByPath(modalPath) !== -1;
301
}
302
303
+export function getModalStack() {
304
+ return modalStack.slice();
305
+}
306
+
307
+export function refreshModalStack() {
308
+ if (modalStack.length === 0) {
309
+ updateModalZIndexes();
310
+ return;
311
+ }
312
+ activateModal(modalStack[modalStack.length - 1]);
313
+}
314
+
315
export async function ensureModalOpen(modalPath, beforeClose = null) {
316
if (focusModal(modalPath)) return null;
317
return openModal(modalPath, beforeClose);
webui/js/surfaces.js
new
+445
@@ -0,0 +1,445 @@
1
+export const SURFACE_MODE_DOCKED = "canvas";
2
+export const SURFACE_MODE_FLOATING = "modal";
3
+export const SURFACE_MODAL_GROUP = "surfaces";
4
+
5
+const LEGACY_SURFACE_IDS = new Map([
6
+ ["office", "desktop"],
7
+]);
8
+
9
+const registeredSurfaces = new Map();
10
+const urlHandlers = new Set();
11
+
12
+export const CORE_SURFACES = [
13
+ {
14
+ id: "browser",
15
+ title: "Browser",
16
+ icon: "language",
17
+ order: 10,
18
+ modalPath: "/plugins/_browser/webui/main.html",
19
+ },
20
+ {
21
+ id: "desktop",
22
+ title: "Desktop",
23
+ icon: "desktop_windows",
24
+ order: 20,
25
+ modalPath: "/plugins/_desktop/webui/main.html",
26
+ },
27
+];
28
+
29
+export function normalizeSurfaceId(surfaceId = "") {
30
+ const normalized = String(surfaceId || "").trim();
31
+ return LEGACY_SURFACE_IDS.get(normalized) || normalized;
32
+}
33
+
34
+export function normalizeSurfaceMode(mode = "") {
35
+ return mode === SURFACE_MODE_FLOATING ? SURFACE_MODE_FLOATING : SURFACE_MODE_DOCKED;
36
+}
37
+
38
+export function normalizeModalPath(modalPath = "") {
39
+ return String(modalPath || "").replace(/^\/+/, "");
40
+}
41
+
42
+export function sameModalPath(left = "", right = "") {
43
+ return normalizeModalPath(left) === normalizeModalPath(right);
44
+}
45
+
46
+export function migratePersistedSurfaceState(saved = {}) {
47
+ const result = { ...(saved || {}) };
48
+ result.activeSurfaceId = normalizeSurfaceId(result.activeSurfaceId || "");
49
+ result.surfaceModes = migrateSurfaceModeMap(result.surfaceModes || {});
50
+ return result;
51
+}
52
+
53
+function migrateSurfaceModeMap(surfaceModes = {}) {
54
+ const result = {};
55
+ for (const [surfaceId, mode] of Object.entries(surfaceModes || {})) {
56
+ const normalizedId = normalizeSurfaceId(surfaceId);
57
+ if (!normalizedId) continue;
58
+ if (result[normalizedId] && normalizedId !== surfaceId) continue;
59
+ result[normalizedId] = normalizeSurfaceMode(mode);
60
+ }
61
+ return result;
62
+}
63
+
64
+export function registerSurface(surface = {}) {
65
+ const id = normalizeSurfaceId(surface.id || "");
66
+ if (!id) return null;
67
+ const normalized = {
68
+ title: id,
69
+ icon: "web_asset",
70
+ image: "",
71
+ order: 100,
72
+ canOpen: () => true,
73
+ open: () => {},
74
+ close: () => {},
75
+ modalPath: "",
76
+ actionOnly: false,
77
+ ...surface,
78
+ id,
79
+ };
80
+ registeredSurfaces.set(id, normalized);
81
+ return normalized;
82
+}
83
+
84
+export function getRegisteredSurfaces() {
85
+ const surfacesById = new Map(CORE_SURFACES.map((surface) => [surface.id, surface]));
86
+ for (const surface of registeredSurfaces.values()) {
87
+ surfacesById.set(surface.id, surface);
88
+ }
89
+ return Array.from(surfacesById.values())
90
+ .filter((surface) => surface?.id)
91
+ .sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
92
+}
93
+
94
+export function getSurface(surfaceId = "") {
95
+ const targetId = normalizeSurfaceId(surfaceId);
96
+ return getRegisteredSurfaces().find((surface) => surface.id === targetId) || null;
97
+}
98
+
99
+export function modalSurfaceMetadata(doc, modalPath = "") {
100
+ const htmlDataset = doc?.documentElement?.dataset || {};
101
+ const bodyDataset = doc?.body?.dataset || {};
102
+ const surfaceId = normalizeSurfaceId(
103
+ htmlDataset.surfaceId
104
+ || bodyDataset.surfaceId
105
+ || htmlDataset.canvasSurface
106
+ || bodyDataset.canvasSurface
107
+ || "",
108
+ );
109
+ if (!surfaceId) return null;
110
+ return {
111
+ surfaceId,
112
+ modalPath: (
113
+ htmlDataset.surfaceModalPath
114
+ || bodyDataset.surfaceModalPath
115
+ || htmlDataset.canvasModalPath
116
+ || bodyDataset.canvasModalPath
117
+ || modalPath
118
+ ),
119
+ title: (
120
+ htmlDataset.surfaceDockTitle
121
+ || bodyDataset.surfaceDockTitle
122
+ || htmlDataset.canvasDockTitle
123
+ || bodyDataset.canvasDockTitle
124
+ || "Open in surface"
125
+ ),
126
+ icon: (
127
+ htmlDataset.surfaceDockIcon
128
+ || bodyDataset.surfaceDockIcon
129
+ || htmlDataset.canvasDockIcon
130
+ || bodyDataset.canvasDockIcon
131
+ || "dock_to_right"
132
+ ),
133
+ };
134
+}
135
+
136
+export function modalHasSurfaceMetadata(modalOrElement) {
137
+ const element = modalOrElement?.element || modalOrElement;
138
+ return Boolean(
139
+ element?.dataset?.surfaceId
140
+ || element?.dataset?.canvasSurface
141
+ || element?.querySelector?.(".modal-inner")?.dataset?.surfaceId
142
+ || element?.querySelector?.(".modal-inner")?.dataset?.canvasSurface
143
+ || modalPathMatchesSurface(modalOrElement?.path || element?.path || ""),
144
+ );
145
+}
146
+
147
+export function modalPathMatchesSurface(path = "") {
148
+ return getRegisteredSurfaces().some((surface) => sameModalPath(surface.modalPath || "", path));
149
+}
150
+
151
+function modalSurfaceDefinition(modalOrElement) {
152
+ const element = modalOrElement?.element || modalOrElement;
153
+ const path = typeof modalOrElement === "string"
154
+ ? modalOrElement
155
+ : modalOrElement?.path || element?.path || element?.dataset?.modalPath || "";
156
+ return getRegisteredSurfaces().find((surface) => sameModalPath(surface.modalPath || "", path)) || null;
157
+}
158
+
159
+function modalSurfaceGroup(modalOrElement) {
160
+ return modalSurfaceDefinition(modalOrElement) ? SURFACE_MODAL_GROUP : "";
161
+}
162
+
163
+export function shouldSuppressBackdrop(modal) {
164
+ return Boolean(
165
+ modalHasSurfaceMetadata(modal)
166
+ || modal?.element?.classList?.contains("surface-floating")
167
+ || modal?.element?.classList?.contains("modal-floating")
168
+ || modal?.element?.classList?.contains("modal-no-backdrop")
169
+ || modal?.inner?.classList?.contains("surface-modal")
170
+ || modal?.inner?.classList?.contains("modal-no-backdrop")
171
+ );
172
+}
173
+
174
+function setModalParked(modal, parked = false) {
175
+ const element = modal?.element;
176
+ if (!element) return;
177
+ element.classList.toggle("modal-surface-parked", parked);
178
+ element.classList.toggle("surface-modal-parked", parked);
179
+ if (parked) {
180
+ element.classList.remove("show");
181
+ element.setAttribute("aria-hidden", "true");
182
+ } else {
183
+ element.classList.add("show");
184
+ element.removeAttribute("aria-hidden");
185
+ }
186
+}
187
+
188
+async function modalApi() {
189
+ return await import("/js/modals.js");
190
+}
191
+
192
+async function parkSiblingSurfaceModals(activeModal) {
193
+ const group = modalSurfaceGroup(activeModal);
194
+ if (!group) {
195
+ setModalParked(activeModal, false);
196
+ return;
197
+ }
198
+
199
+ const { getModalStack } = await modalApi();
200
+ for (const modal of getModalStack()) {
201
+ setModalParked(modal, modal !== activeModal && modalSurfaceGroup(modal) === group);
202
+ }
203
+}
204
+
205
+export async function closeSurfaceGroupModals(options = {}) {
206
+ const { closeModal, getModalStack, isModalOpen } = await modalApi();
207
+ const exceptPath = normalizeModalPath(options?.exceptPath || "");
208
+ const targets = getModalStack()
209
+ .filter((modal) => modalSurfaceGroup(modal) === SURFACE_MODAL_GROUP)
210
+ .map((modal) => ({
211
+ path: modal.path,
212
+ surface: modalSurfaceDefinition(modal),
213
+ }))
214
+ .filter((target) => !exceptPath || normalizeModalPath(target.path) !== exceptPath)
215
+ .reverse();
216
+ const handoffPayload = { source: "modal-group-close" };
217
+ const handoffs = [];
218
+ let closedAll = false;
219
+
220
+ try {
221
+ for (const target of targets) {
222
+ if (!target.surface?.beginDockHandoff) continue;
223
+ await target.surface.beginDockHandoff({ ...handoffPayload, modalPath: target.path });
224
+ handoffs.push(target.surface);
225
+ }
226
+
227
+ for (const target of targets) {
228
+ if (!isModalOpen(target.path)) continue;
229
+ const closed = await closeModal(target.path);
230
+ if (closed === false) return false;
231
+ }
232
+ closedAll = true;
233
+ return true;
234
+ } finally {
235
+ for (const surface of handoffs) {
236
+ try {
237
+ if (closedAll) {
238
+ await surface.finishDockHandoff?.({ ...handoffPayload, opened: false });
239
+ } else {
240
+ await surface.cancelDockHandoff?.(handoffPayload);
241
+ }
242
+ } catch (error) {
243
+ console.error("Surface modal group handoff cleanup failed", error);
244
+ }
245
+ }
246
+ }
247
+}
248
+
249
+function getModalSwitchSurfaces(metadata) {
250
+ const surfacesById = new Map(CORE_SURFACES.map((surface) => [surface.id, surface]));
251
+ for (const surface of getRegisteredSurfaces()) {
252
+ if (!surface?.id || !surface.modalPath || surface.actionOnly) continue;
253
+ surfacesById.set(surface.id, {
254
+ ...surface,
255
+ modalPath: surface.modalPath,
256
+ });
257
+ }
258
+
259
+ if (metadata?.surfaceId && !surfacesById.has(metadata.surfaceId)) {
260
+ surfacesById.set(metadata.surfaceId, {
261
+ id: metadata.surfaceId,
262
+ title: metadata.title,
263
+ icon: metadata.icon,
264
+ modalPath: metadata.modalPath,
265
+ });
266
+ }
267
+
268
+ return Array.from(surfacesById.values())
269
+ .filter((surface) => surface?.id && surface.modalPath && !surface.actionOnly)
270
+ .sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
271
+}
272
+
273
+function markSurfaceModal(modal, metadata) {
274
+ const element = modal?.element;
275
+ const inner = modal?.inner || element?.querySelector?.(".modal-inner");
276
+ if (!element || !inner) return;
277
+ element.dataset.surfaceId = metadata.surfaceId;
278
+ element.classList.add("surface-floating", "modal-floating", "modal-no-backdrop", "modal-explicit-close");
279
+ inner.classList.add("surface-modal", "modal-no-backdrop", "modal-explicit-close");
280
+}
281
+
282
+function createModalSurfaceButton(surface, metadata, modal) {
283
+ const title = surface.title || surface.id;
284
+ const targetModalPath = surface.modalPath || "";
285
+ const normalizedId = normalizeSurfaceId(surface.id);
286
+ const isActive = normalizedId === metadata.surfaceId || sameModalPath(targetModalPath, modal.path);
287
+ const button = document.createElement("button");
288
+ button.type = "button";
289
+ button.className = "surface-button modal-surface-button";
290
+ button.dataset.surfaceId = normalizedId;
291
+ button.dataset.canvasSurface = normalizedId;
292
+ button.setAttribute("aria-label", title);
293
+ button.setAttribute("aria-pressed", isActive.toString());
294
+ if (isActive) button.classList.add("is-active");
295
+
296
+ if (surface.image) {
297
+ const image = document.createElement("img");
298
+ image.className = "modal-surface-image";
299
+ image.src = surface.image;
300
+ image.alt = "";
301
+ image.setAttribute("aria-hidden", "true");
302
+ button.appendChild(image);
303
+ } else {
304
+ const icon = document.createElement("span");
305
+ icon.className = "material-symbols-outlined";
306
+ icon.setAttribute("aria-hidden", "true");
307
+ icon.textContent = surface.icon || "web_asset";
308
+ button.appendChild(icon);
309
+ }
310
+
311
+ button.addEventListener("click", async () => {
312
+ if (button.disabled || isActive || !targetModalPath) return;
313
+ button.disabled = true;
314
+ try {
315
+ await recordMode(normalizedId, SURFACE_MODE_FLOATING);
316
+ const { ensureModalOpen } = await modalApi();
317
+ const openPromise = ensureModalOpen(targetModalPath);
318
+ if (openPromise?.catch) {
319
+ openPromise.catch((error) => console.error(`Modal surface ${surface.id} failed to open`, error));
320
+ }
321
+ } finally {
322
+ if (document.contains(button)) button.disabled = false;
323
+ }
324
+ });
325
+
326
+ return button;
327
+}
328
+
329
+function configureModalSurfaceSwitcher(modal, metadata) {
330
+ if (!metadata || !modal?.header || modal.header.querySelector(".surface-switcher, .modal-surface-switcher")) {
331
+ return;
332
+ }
333
+
334
+ const surfaces = getModalSwitchSurfaces(metadata);
335
+ if (surfaces.length <= 1) return;
336
+
337
+ const switcher = document.createElement("div");
338
+ switcher.className = "surface-switcher modal-surface-switcher";
339
+ switcher.setAttribute("role", "group");
340
+ switcher.setAttribute("aria-label", "Modal surfaces");
341
+
342
+ for (const surface of surfaces) {
343
+ switcher.appendChild(createModalSurfaceButton(surface, metadata, modal));
344
+ }
345
+
346
+ modal.close?.insertAdjacentElement("beforebegin", switcher);
347
+}
348
+
349
+function configureModalDockButton(modal, metadata) {
350
+ if (!metadata || !modal?.header || modal.header.querySelector(".surface-dock-button, .modal-dock-button")) {
351
+ return;
352
+ }
353
+
354
+ void recordMode(metadata.surfaceId, SURFACE_MODE_FLOATING);
355
+
356
+ const button = document.createElement("button");
357
+ button.type = "button";
358
+ button.className = "surface-dock-button modal-dock-button";
359
+ button.setAttribute("aria-label", metadata.title);
360
+ button.innerHTML = `<span class="material-symbols-outlined" aria-hidden="true">${metadata.icon}</span>`;
361
+ button.addEventListener("click", async () => {
362
+ if (button.disabled) return;
363
+ button.disabled = true;
364
+ try {
365
+ await dock(metadata.surfaceId, {
366
+ modalPath: metadata.modalPath,
367
+ sourceModalPath: modal.path,
368
+ source: "modal",
369
+ closeSourceModal: async () => {
370
+ const closed = await closeSurfaceGroupModals();
371
+ if (closed === false) return false;
372
+ return !document.contains(modal.element);
373
+ },
374
+ });
375
+ } finally {
376
+ if (document.contains(button)) button.disabled = false;
377
+ }
378
+ });
379
+
380
+ modal.close?.insertAdjacentElement("beforebegin", button);
381
+}
382
+
383
+async function configureSurfaceModal(event) {
384
+ const { modal, doc } = event?.detail || {};
385
+ const metadata = modalSurfaceMetadata(doc, modal?.path || "");
386
+ if (!metadata) return;
387
+ markSurfaceModal(modal, metadata);
388
+ configureModalSurfaceSwitcher(modal, metadata);
389
+ configureModalDockButton(modal, metadata);
390
+ const { refreshModalStack } = await modalApi();
391
+ refreshModalStack();
392
+}
393
+
394
+export async function open(surfaceId = "", payload = {}) {
395
+ const { store } = await import("/components/canvas/right-canvas-store.js");
396
+ return await store.open(normalizeSurfaceId(surfaceId), payload);
397
+}
398
+
399
+export async function openLatest(surfaceId = "", payload = {}) {
400
+ const { store } = await import("/components/canvas/right-canvas-store.js");
401
+ return await store.openLatest(normalizeSurfaceId(surfaceId), payload);
402
+}
403
+
404
+export async function dock(surfaceId = "", payload = {}) {
405
+ const { store } = await import("/components/canvas/right-canvas-store.js");
406
+ return await store.dockSurface(normalizeSurfaceId(surfaceId), payload);
407
+}
408
+
409
+export async function recordMode(surfaceId = "", mode = SURFACE_MODE_DOCKED, options = {}) {
410
+ const { store } = await import("/components/canvas/right-canvas-store.js");
411
+ return store.recordSurfaceMode?.(normalizeSurfaceId(surfaceId), normalizeSurfaceMode(mode), options);
412
+}
413
+
414
+export function registerUrlHandler(handler) {
415
+ if (typeof handler !== "function") return () => {};
416
+ urlHandlers.add(handler);
417
+ return () => urlHandlers.delete(handler);
418
+}
419
+
420
+export async function handleUrlIntent(intent = {}) {
421
+ for (const handler of Array.from(urlHandlers)) {
422
+ const handled = await handler(intent);
423
+ if (handled) return true;
424
+ }
425
+ globalThis.dispatchEvent?.(new CustomEvent("surface-url-intent", { detail: intent }));
426
+ return false;
427
+}
428
+
429
+document.addEventListener("modal-content-loaded", (event) => {
430
+ void configureSurfaceModal(event);
431
+});
432
+
433
+document.addEventListener("modal-activated", (event) => {
434
+ void parkSiblingSurfaceModals(event?.detail?.modal);
435
+});
436
+
437
+document.addEventListener("modal-closed", async () => {
438
+ const { getModalStack, refreshModalStack } = await modalApi();
439
+ const stack = getModalStack();
440
+ if (stack.length > 0) {
441
+ refreshModalStack();
442
+ }
443
+});
444
+
445
+globalThis.closeSurfaceGroupModals = closeSurfaceGroupModals;