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;