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.
This commit is contained in:
Alessandro 2026-05-07 00:14:31 +02:00
parent 2613fac05f
commit 022b6f031f
20 changed files with 1002 additions and 428 deletions

View file

@ -62,10 +62,14 @@ class WsBrowser(WsHandler):
if not AgentContext.get(context_id):
return self._error("CONTEXT_NOT_FOUND", f"Context '{context_id}' was not found", data)
runtime = await get_runtime(context_id)
listing = await runtime.call("list")
browsers = listing.get("browsers") or []
if not browsers:
create_browser = self._bool(data.get("create_browser", data.get("createBrowser")))
runtime = await get_runtime(context_id, create=create_browser)
listing = {"browsers": [], "last_interacted_browser_id": None}
browsers: list[dict[str, Any]] = []
if runtime:
listing = await runtime.call("list")
browsers = listing.get("browsers") or []
if runtime and not browsers and create_browser:
opened = await runtime.call("open", "")
listing = await runtime.call("list")
browsers = listing.get("browsers") or []
@ -73,7 +77,7 @@ class WsBrowser(WsHandler):
listing["last_interacted_browser_id"] = opened.get("id")
active_id = self._active_browser_id(listing, data.get("browser_id"))
initial_viewport = self._viewport_from_data(data)
if active_id and initial_viewport:
if runtime and active_id and initial_viewport:
await runtime.call(
"set_viewport",
active_id,
@ -88,9 +92,10 @@ class WsBrowser(WsHandler):
if existing:
existing.cancel()
viewer_id = str(data.get("viewer_id") or "")
self._streams[stream_key] = asyncio.create_task(
self._stream_frames(sid, context_id, active_id, viewer_id)
)
if runtime:
self._streams[stream_key] = asyncio.create_task(
self._stream_frames(sid, context_id, active_id, viewer_id)
)
return {
"context_id": context_id,
@ -498,6 +503,14 @@ class WsBrowser(WsHandler):
def _context_id(data: dict[str, Any]) -> str:
return str(data.get("context_id") or data.get("context") or "").strip()
@staticmethod
def _bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
@staticmethod
def _error(code: str, message: str, data: dict[str, Any]) -> WsResult:
return WsResult.error(

View file

@ -5,7 +5,7 @@ extension_paths: []
# Page opened by new Browser sessions when no URL is provided.
default_homepage: "about:blank"
# When the Browser canvas is already open, keep it synced to agent Browser tool results.
# When the Browser surface is already open, keep it synced to agent Browser tool results.
autofocus_active_page: true
# Optional _model_config preset used by Browser-owned model helpers.

View file

@ -5,7 +5,7 @@
aria-label="Open Browser"
data-bs-placement="top"
data-bs-trigger="hover"
@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')))"
@click="import('/js/surfaces.js').then(({ open }) => open('browser'))"
>
<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">
<rect x="3" y="4" width="18" height="16" rx="2"></rect>

View file

@ -4,16 +4,15 @@ import {
} from "/components/messages/action-buttons/simple-action-buttons.js";
import { store as stepDetailStore } from "/components/modals/process-step-detail/step-detail-store.js";
import { store as speechStore } from "/components/chat/speech/speech-store.js";
import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";
import { store as browserStore } from "/plugins/_browser/webui/browser-store.js";
import { getNamespacedClient } from "/js/websocket.js";
import { open as openSurface } from "/js/surfaces.js";
import {
buildDetailPayload,
cleanStepTitle,
drawProcessStep,
} from "/js/messages.js";
const BROWSER_MODAL = "/plugins/_browser/webui/main.html";
const BROWSER_SCREENSHOT_KVP_KEY = "Screenshot";
const BROWSER_SCREENSHOT_STYLE_ID = "a0-browser-screenshot-kvp-style";
const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
@ -34,19 +33,8 @@ export default async function registerBrowserToolHandler(extData) {
}
}
async function openBrowserCanvas(payload = {}) {
if (rightCanvasStore?.open) {
await rightCanvasStore.open("browser", payload);
return;
}
if (window.ensureModalOpen) {
await window.ensureModalOpen(BROWSER_MODAL);
return;
}
if (window.openModal) {
await window.openModal(BROWSER_MODAL);
}
async function openBrowserSurface(payload = {}) {
await openSurface("browser", payload);
}
async function browserAllowsToolAutofocus() {
@ -115,11 +103,7 @@ function isFreshToolMessage(timestamp) {
}
function isBrowserCanvasAlreadyOpen() {
return Boolean(
rightCanvasStore?.isOpen
&& rightCanvasStore?.activeSurfaceId === "browser"
&& !rightCanvasStore?.isMobileMode,
);
return Boolean(document.querySelector('[data-surface-id="browser"].is-active .browser-panel'));
}
// Allowlist: only these actions sync an already-open viewer to the target tab.
@ -146,15 +130,16 @@ function syncOpenBrowserCanvas(args, result) {
if (!shouldSyncOpenBrowserCanvas(args, result)) return;
const kvps = args?.kvps || {};
const browserId = browserIdFromResult(result, kvps);
const key = `${args.id || ""}:${kvps.action || ""}:${browserId || ""}:${result.currentUrl || result.state?.currentUrl || kvps.url || ""}`;
const contextId = browserContextIdFromResult(result, kvps);
const key = `${args.id || ""}:${contextId || ""}:${kvps.action || ""}:${browserId || ""}:${result.currentUrl || result.state?.currentUrl || kvps.url || ""}`;
if (syncedBrowserCanvases.has(key)) return;
syncedBrowserCanvases.add(key);
requestAnimationFrame(async () => {
if (!isBrowserCanvasAlreadyOpen()) return;
if (!(await browserAllowsToolAutofocus())) return;
void rightCanvasStore.open("browser", {
void openSurface("browser", {
browserId,
contextId: browserContextIdFromResult(result, kvps),
contextId,
source: "tool-sync",
});
});
@ -344,7 +329,7 @@ function renderBrowserScreenshotKvp(kvpsTable, resolveBrowserPayload, label) {
event.stopPropagation();
const canvasPayload = resolveBrowserPayload();
if (!canvasPayload) return;
await openBrowserCanvas(canvasPayload);
await openBrowserSurface(canvasPayload);
});
cell.textContent = "";
@ -465,15 +450,15 @@ function drawBrowserTool({
const browserId = browserIdFromResult(browserResult, kvps);
const browserCanvasPayload = buildBrowserCanvasPayload(browserResult, kvps);
const browserPreviewLabel = browserId
? `Open Browser canvas for Browser ${browserId}`
: "Open Browser canvas from screenshot";
? `Open Browser surface for Browser ${browserId}`
: "Open Browser surface from screenshot";
if (shouldRenderBrowserScreenshotKvp(browserResult, kvps)) {
displayKvps[BROWSER_SCREENSHOT_KVP_KEY] = "";
}
const browserButton = createActionButton(
"visibility",
"Browser",
() => openBrowserCanvas(
() => openBrowserSurface(
buildBrowserCanvasPayload(browserResult, kvps, "tool")
|| {
browserId,

View file

@ -1,5 +1,5 @@
import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";
import { store as browserStore } from "/plugins/_browser/webui/browser-store.js";
import { open as openSurface } from "/js/surfaces.js";
const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
const syncedBrowserCanvases = new Set();
@ -19,6 +19,7 @@ export default async function syncBrowserResultsIntoOpenCanvas(context) {
const contextId = getBrowserContextId(payload, result);
const key = [
args?.id || "",
contextId || "",
browserId || "",
result.currentUrl || result.state?.currentUrl || payload.url || "",
].join(":");
@ -53,6 +54,8 @@ function pickPayloadFields(args = {}) {
"action",
"browser_id",
"browserId",
"context_id",
"contextId",
"url",
"last_modified",
]) {
@ -156,15 +159,11 @@ function hasOpened(key, persistedKey) {
async function syncOpenBrowserCanvas(payload = {}) {
if (!isBrowserCanvasAlreadyOpen()) return;
await rightCanvasStore.open("browser", payload);
await openSurface("browser", payload);
}
function isBrowserCanvasAlreadyOpen() {
return Boolean(
rightCanvasStore?.isOpen
&& rightCanvasStore?.activeSurfaceId === "browser"
&& !rightCanvasStore?.isMobileMode,
);
return Boolean(document.querySelector('[data-surface-id="browser"].is-active .browser-panel'));
}
async function browserAllowsToolAutofocus() {

View file

@ -0,0 +1,104 @@
import { store as browserStore } from "/plugins/_browser/webui/browser-store.js";
function waitForElement(selector, timeoutMs = 3000) {
const found = document.querySelector(selector);
if (found) return Promise.resolve(found);
return new Promise((resolve) => {
const timeout = globalThis.setTimeout(() => {
observer.disconnect();
resolve(null);
}, timeoutMs);
const observer = new MutationObserver(() => {
const element = document.querySelector(selector);
if (!element) return;
globalThis.clearTimeout(timeout);
observer.disconnect();
resolve(element);
});
observer.observe(document.body, { childList: true, subtree: true });
});
}
function nextAnimationFrame() {
return new Promise((resolve) => {
const schedule = globalThis.requestAnimationFrame || ((callback) => globalThis.setTimeout(callback, 16));
schedule(() => resolve());
});
}
function isVisibleCanvasPanel(panel) {
if (!panel?.isConnected) return false;
const surface = panel.closest(".browser-canvas-surface");
const stage = panel.querySelector(".browser-stage") || panel;
const surfaceStyle = surface ? globalThis.getComputedStyle?.(surface) : null;
const panelStyle = globalThis.getComputedStyle?.(panel);
if (surfaceStyle?.display === "none" || surfaceStyle?.visibility === "hidden") return false;
if (panelStyle?.display === "none" || panelStyle?.visibility === "hidden") return false;
const rect = stage.getBoundingClientRect?.();
return Boolean(rect && Math.round(rect.width || 0) >= 80 && Math.round(rect.height || 0) >= 80);
}
async function waitForVisibleCanvasPanel(selector, timeoutMs = 3000) {
const deadline = Date.now() + timeoutMs;
let stableKey = "";
let stableCount = 0;
while (Date.now() <= deadline) {
const panel = document.querySelector(selector);
const visible = isVisibleCanvasPanel(panel);
if (visible) {
const stage = panel.querySelector(".browser-stage") || panel;
const rect = stage.getBoundingClientRect();
const key = `${Math.round(rect.width || 0)}x${Math.round(rect.height || 0)}`;
if (key === stableKey) {
stableCount += 1;
if (stableCount >= 2) {
return panel;
}
} else {
stableKey = key;
stableCount = 0;
}
} else {
stableKey = "";
stableCount = 0;
}
await nextAnimationFrame();
}
return document.querySelector(selector);
}
export default async function registerBrowserSurface(canvas) {
canvas.registerSurface({
id: "browser",
title: "Browser",
icon: "language",
order: 10,
modalPath: "/plugins/_browser/webui/main.html",
beginDockHandoff() {
browserStore.beginSurfaceHandoff?.();
},
finishDockHandoff() {
browserStore.finishSurfaceHandoff?.();
},
cancelDockHandoff() {
browserStore.cancelSurfaceHandoff?.();
},
async open(payload = {}) {
await waitForElement('[data-surface-id="browser"] .browser-panel');
const panel = await waitForVisibleCanvasPanel('[data-surface-id="browser"] .browser-panel');
const browser = browserStore;
if (panel && browser?.onOpen) {
await browser.onOpen(panel, {
mode: "canvas",
browserId: payload.browserId || payload.browser_id || null,
contextId: payload.contextId || payload.context_id || null,
});
}
},
async close() {
await browserStore.cleanup?.();
},
});
}

View file

@ -18,7 +18,7 @@ from helpers import files, plugins
from plugins._browser.helpers.config import PLUGIN_NAME, get_browser_config
EXTENSIONS_ROOT_DIR = ("usr", "plugins", PLUGIN_NAME, "extensions")
EXTENSIONS_ROOT_DIR = ("usr", "_browser", "extensions")
EXTENSION_ID_RE = re.compile(r"^[a-p]{32}$")
WEB_STORE_ID_RE = re.compile(r"(?<![a-p])([a-p]{32})(?![a-p])")
CHROME_VERSION_RE = re.compile(r"(\d+(?:\.\d+){0,3})")
@ -41,9 +41,7 @@ WEB_STORE_DOWNLOAD_URL = (
def get_extensions_root() -> Path:
root = Path(files.get_abs_path(*EXTENSIONS_ROOT_DIR))
root.mkdir(parents=True, exist_ok=True)
return root
return Path(files.get_abs_path(*EXTENSIONS_ROOT_DIR))
def parse_chrome_web_store_extension_id(value: str) -> str:

View file

@ -4,9 +4,9 @@ use for web browsing, page inspection, forms, downloads, and browser-only tasks
state stays open per chat context
refs come from content as typed markers: [link 3], [button 6], [image 1], [input text 8]
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.
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.
Browser does not automatically load screenshots or canvas images into model context. Screenshots are explicit only.
Browser does not automatically load screenshots or surface images into model context. Screenshots are explicit only.
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
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:
- keyboard presses key or types text into the active page
- clipboard is copy, cut, or paste; for browser:clipboard pass action: "paste" and optional text
- set_viewport resizes the page viewport with width and height
- coordinates are Chromium viewport CSS pixels and match screenshots/Browser canvas
- coordinates are Chromium viewport CSS pixels and match screenshots/Browser surface
- ref offsets are relative to the target element top-left; refs default to element center
forms:

View file

@ -307,6 +307,17 @@
background: color-mix(in srgb, var(--color-background) 94%, #000 6%);
}
.modal-inner.browser-modal.is-focus-mode {
resize: none;
border-radius: 6px;
}
.modal-inner.browser-modal.is-focus-mode .browser-modal-focus-button {
color: var(--color-text);
border-color: color-mix(in srgb, var(--color-primary) 42%, var(--color-border));
background: color-mix(in srgb, var(--color-primary) 16%, var(--color-background));
}
.modal.modal-floating {
pointer-events: none;
}

View file

@ -7,11 +7,12 @@ import { store as chatInputStore } from "/components/chat/input/input-store.js";
import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";
import { openLatest as openLatestSurface, registerUrlHandler } from "/js/surfaces.js";
const websocket = getNamespacedClient("/ws");
websocket.addHandlers(["ws_webui"]);
const EXTENSIONS_ROOT = "/a0/usr/plugins/_browser/extensions";
const EXTENSIONS_ROOT = "/a0/usr/_browser/extensions";
const BROWSER_SUBSCRIBE_TIMEOUT_MS = 60000;
const BROWSER_FIRST_INSTALL_TIMEOUT_MS = 300000;
const BROWSER_CONFIG_REFRESH_MS = 15000;
@ -167,6 +168,7 @@ const model = {
_contextCreatePromise: null,
_lastSelectedContextId: "",
_sessionRefreshPromise: null,
_sessionRefreshContextId: "",
extensionMenuOpen: false,
extensionInstallUrl: "",
extensionActionLoading: false,
@ -264,18 +266,24 @@ const model = {
if (selectedContextId === this._lastSelectedContextId) return;
this._lastSelectedContextId = selectedContextId;
if (!this._surfaceMounted) return;
void this.refreshBrowserSessions(selectedContextId);
void this.syncViewerToSelectedContext(selectedContextId);
},
async refreshBrowserSessions(contextId = "") {
const requestedContextId = this.normalizeContextId(contextId || this.resolveContextId());
if (this._sessionRefreshPromise) {
const inFlightContextId = this._sessionRefreshContextId;
await this._sessionRefreshPromise;
if (requestedContextId && requestedContextId !== inFlightContextId) {
return await this.refreshBrowserSessions(requestedContextId);
}
return;
}
this._sessionRefreshContextId = requestedContextId;
this._sessionRefreshPromise = (async () => {
const response = await websocket.request(
"browser_viewer_sessions",
{ context_id: this.normalizeContextId(contextId || this.resolveContextId()) },
{ context_id: requestedContextId },
{ timeoutMs: 10000 },
);
const data = firstOk(response);
@ -289,6 +297,45 @@ const model = {
console.warn("Browser session refresh failed", error);
} finally {
this._sessionRefreshPromise = null;
this._sessionRefreshContextId = "";
}
},
async syncViewerToSelectedContext(contextId = "") {
const selectedContextId = this.normalizeContextId(contextId || this.resolveContextId());
if (!selectedContextId) return;
await this.refreshBrowserSessions(selectedContextId);
if (!this._surfaceMounted || !this.isVisibleBrowserSurface()) return;
const targetBrowserId = this.firstBrowserInContext(selectedContextId)?.id || null;
if (
this.normalizeContextId(this.contextId) === selectedContextId
&& (
!targetBrowserId
|| this.sameBrowserTab(targetBrowserId, selectedContextId, this.activeBrowserId, this.activeBrowserContextId)
)
) {
return;
}
this.loading = true;
this.error = "";
this.resetRenderedFrame();
this.resetViewportTracking();
this._surfaceSwitching = Boolean(targetBrowserId);
this.switchingBrowserId = targetBrowserId;
try {
await this.connectViewer({
browserId: targetBrowserId,
contextId: selectedContextId,
initialViewport: this.currentViewportSize(),
});
await this.syncViewportAfterSurfaceOpen(this._surfaceOpenSequence);
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
} finally {
this.loading = false;
this._surfaceSwitching = false;
}
},
@ -605,7 +652,9 @@ const model = {
const requestedContextId = this.normalizeContextId(
options.requestedContextId ?? options.contextId ?? options.context_id,
);
let targetContextId = requestedContextId;
let targetContextId = requestedContextId
|| this.contextIdForBrowserId(requestedBrowserId)
|| this.resolveContextId();
const nextMode = options?.nextMode || (options?.mode === "modal" ? "modal" : "canvas");
if (nextMode === "canvas" && !this.isCanvasSurfaceVisible(element)) {
this.loading = false;
@ -719,15 +768,49 @@ const model = {
return Boolean(rect && Math.round(rect.width || 0) >= 80 && Math.round(rect.height || 0) >= 80);
},
isVisibleBrowserSurface() {
if (!this._surfaceMounted) return false;
if (this._mode === "canvas") {
return Boolean(rightCanvasStore?.isSurfaceVisible?.("browser"))
&& this.isCanvasSurfaceVisible(globalThis.document?.querySelector?.(".browser-canvas-surface .browser-panel"));
}
const panel = globalThis.document?.querySelector?.(".modal .browser-panel");
const modal = panel?.closest?.(".modal");
if (!panel || !modal) return false;
if (modal.classList.contains("modal-surface-parked") || modal.classList.contains("surface-modal-parked")) {
return false;
}
const panelStyle = globalThis.getComputedStyle?.(panel);
if (panelStyle?.display === "none" || panelStyle?.visibility === "hidden") return false;
const rect = panel.getBoundingClientRect?.();
return Boolean(rect && Math.round(rect.width || 0) >= 80 && Math.round(rect.height || 0) >= 80);
},
prepareSurfaceOpen(nextMode, requestedBrowserId = null, requestedContextId = "") {
const previousMode = this._mode;
const modeChanged = this._surfaceMounted && previousMode && previousMode !== nextMode;
const targetBrowserId = requestedBrowserId || this.activeBrowserId || this.firstBrowserId(requestedContextId);
const targetContextId = this.normalizeContextId(
requestedContextId
|| this.contextIdForBrowserId(targetBrowserId)
|| this.resolveContextId()
|| this.activeBrowserContextId
|| this.contextId,
);
const targetChanged = Boolean(
targetBrowserId
&& this.activeBrowserId
&& !this.sameBrowserTab(targetBrowserId, targetContextId, this.activeBrowserId, this.activeBrowserContextId),
);
this._mode = nextMode;
this._surfaceMounted = true;
this._surfaceOpenedAt = Date.now();
this._lastViewportKey = "";
if (!modeChanged && (this.frameSrc || !targetBrowserId)) return;
if (this.frameSrc && !targetChanged) {
this._surfaceSwitching = false;
this.switchingBrowserId = null;
return;
}
if (!targetBrowserId) return;
this.resetRenderedFrame();
this.resetViewportTracking();
@ -756,7 +839,7 @@ const model = {
|| Math.abs(this._lastViewport.height - viewport.height) > VIEWPORT_SYNC_SIZE_TOLERANCE;
if (!changed) return;
this.resetRenderedFrame();
this.cancelFrameRender();
this.resetViewportTracking();
this._surfaceSwitching = true;
this.switchingBrowserId = targetBrowserId;
@ -863,6 +946,7 @@ const model = {
context_id: contextId,
browser_id: requestedBrowserId,
viewer_id: viewerToken,
create_browser: Boolean(options.createBrowser || options.create_browser),
viewport_width: initialViewport?.width,
viewport_height: initialViewport?.height,
},
@ -1108,7 +1192,8 @@ const model = {
const viewport = this.currentViewportSize();
if (!this.frameSrc || !this._lastFrameDimensions || !viewport) return;
if (this.frameMatchesViewport(this._lastFrameDimensions, viewport)) return;
this.resetRenderedFrame();
this.cancelFrameRender();
this.resetViewportTracking();
if (this.activeBrowserId) {
this._surfaceSwitching = true;
this.switchingBrowserId = this.activeBrowserId;
@ -1400,6 +1485,12 @@ const model = {
return browsers[0] || null;
},
firstBrowserInContext(contextId = "") {
const normalizedContextId = this.normalizeContextId(contextId);
if (!normalizedContextId || !Array.isArray(this.browsers)) return null;
return this.browsers.find((browser) => this.normalizeContextId(browser?.context_id) === normalizedContextId) || null;
},
firstBrowserId(contextId = "") {
return this.firstBrowser(contextId)?.id || null;
},
@ -2456,8 +2547,8 @@ const model = {
const header = modal?.querySelector?.(".modal-header");
const stage = root?.querySelector?.(".browser-stage");
if (!modal || !inner || !header) return;
modal.classList.add("modal-floating");
inner.classList.add("browser-modal");
modal.classList.add("surface-floating", "modal-floating");
inner.classList.add("surface-modal", "browser-modal");
body?.classList?.add("browser-modal-body");
this._stageElement = stage || null;
@ -2468,7 +2559,54 @@ const model = {
let drag = null;
let resizeObserver = null;
let beforeFocusBounds = null;
const viewportGap = 8;
const currentBounds = () => {
const bounds = inner.getBoundingClientRect();
return {
left: bounds.left,
top: bounds.top,
width: bounds.width,
height: bounds.height,
};
};
const normalizedBounds = (bounds = {}) => {
const maxWidth = Math.max(320, globalThis.innerWidth - viewportGap * 2);
const maxHeight = Math.max(300, globalThis.innerHeight - viewportGap * 2);
const width = Math.min(Math.max(320, Number(bounds.width || 320)), maxWidth);
const height = Math.min(Math.max(300, Number(bounds.height || 300)), maxHeight);
return {
left: Math.min(
Math.max(viewportGap, Number(bounds.left || viewportGap)),
Math.max(viewportGap, globalThis.innerWidth - width - viewportGap),
),
top: Math.min(
Math.max(viewportGap, Number(bounds.top || viewportGap)),
Math.max(viewportGap, globalThis.innerHeight - height - viewportGap),
),
width,
height,
};
};
const setBounds = (bounds = {}) => {
const next = normalizedBounds(bounds);
inner.style.position = "fixed";
inner.style.transform = "none";
inner.style.left = `${Math.round(next.left)}px`;
inner.style.top = `${Math.round(next.top)}px`;
inner.style.width = `${Math.round(next.width)}px`;
inner.style.height = `${Math.round(next.height)}px`;
inner.style.maxWidth = `${Math.max(320, globalThis.innerWidth - viewportGap * 2)}px`;
inner.style.maxHeight = `${Math.max(300, globalThis.innerHeight - viewportGap * 2)}px`;
this.queueViewportSync();
return next;
};
const focusBounds = () => ({
left: viewportGap,
top: viewportGap,
width: globalThis.innerWidth - viewportGap * 2,
height: globalThis.innerHeight - viewportGap * 2,
});
const clampPosition = (left, top) => {
const bounds = inner.getBoundingClientRect();
const maxLeft = Math.max(viewportGap, globalThis.innerWidth - bounds.width - viewportGap);
@ -2479,25 +2617,47 @@ const model = {
};
};
const clampGeometry = () => {
const bounds = inner.getBoundingClientRect();
const left = Math.max(viewportGap, bounds.left);
const top = Math.max(viewportGap, bounds.top);
const maxWidth = Math.max(320, globalThis.innerWidth - viewportGap * 2);
const maxHeight = Math.max(300, globalThis.innerHeight - viewportGap * 2);
if (bounds.width > maxWidth) {
inner.style.width = `${maxWidth}px`;
if (inner.classList.contains("is-focus-mode")) {
setBounds(focusBounds());
return;
}
if (bounds.height > maxHeight) {
inner.style.height = `${maxHeight}px`;
}
const next = clampPosition(left, top);
inner.style.left = `${next.left}px`;
inner.style.top = `${next.top}px`;
inner.style.maxWidth = `${Math.max(320, globalThis.innerWidth - next.left - viewportGap)}px`;
inner.style.maxHeight = `${Math.max(300, globalThis.innerHeight - next.top - viewportGap)}px`;
this.queueViewportSync();
setBounds(currentBounds());
};
clampGeometry();
const focusButton = globalThis.document.createElement("button");
focusButton.type = "button";
focusButton.className = "surface-button browser-modal-focus-button";
focusButton.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">fullscreen</span>';
const updateFocusButton = (active) => {
const label = active ? "Restore size" : "Focus mode";
focusButton.setAttribute("aria-label", label);
focusButton.setAttribute("title", label);
focusButton.querySelector(".material-symbols-outlined").textContent = active ? "fullscreen_exit" : "fullscreen";
};
const setFocusMode = (enabled) => {
if (enabled) {
beforeFocusBounds = currentBounds();
inner.classList.add("is-focus-mode");
setBounds(focusBounds());
updateFocusButton(true);
return;
}
inner.classList.remove("is-focus-mode");
setBounds(beforeFocusBounds || currentBounds());
beforeFocusBounds = null;
updateFocusButton(false);
};
updateFocusButton(false);
const closeButton = inner.querySelector(".modal-close");
if (closeButton) {
closeButton.insertAdjacentElement("beforebegin", focusButton);
} else {
header.appendChild(focusButton);
}
const onFocusClick = () => setFocusMode(!inner.classList.contains("is-focus-mode"));
focusButton.addEventListener("click", onFocusClick);
globalThis.addEventListener("resize", clampGeometry);
if (globalThis.ResizeObserver) {
resizeObserver = new ResizeObserver(clampGeometry);
@ -2537,6 +2697,7 @@ const model = {
const onPointerDown = (event) => {
if (event.button !== 0) return;
if (event.target?.closest?.("button, input, select, textarea, a")) return;
if (inner.classList.contains("is-focus-mode")) return;
const current = inner.getBoundingClientRect();
drag = {
x: event.clientX,
@ -2553,6 +2714,8 @@ const model = {
header.addEventListener("pointerdown", onPointerDown);
this._floatingCleanup = () => {
focusButton.removeEventListener("click", onFocusClick);
focusButton.remove();
header.removeEventListener("pointerdown", onPointerDown);
globalThis.removeEventListener("pointermove", onPointerMove);
globalThis.removeEventListener("pointerup", onPointerUp);
@ -2560,6 +2723,7 @@ const model = {
resizeObserver?.disconnect?.();
this._stageResizeObserver?.disconnect?.();
this._stageResizeObserver = null;
inner.classList.remove("is-focus-mode");
};
},
@ -2601,3 +2765,10 @@ const model = {
};
export const store = createStore("browserPage", model);
registerUrlHandler(async (intent = {}) => {
const url = String(intent.url || "").trim();
const payload = { url, source: intent.source || "surface-url-intent" };
await openLatestSurface("browser", payload);
return await store.openUrlIntent(url, { source: payload.source });
});

View file

@ -18,7 +18,7 @@
<div class="browser-config-card">
<div class="section-title">Browsing</div>
<div class="section-description">
Set how new Browser sessions start and how an already-open Browser canvas follows agent activity.
Set how new Browser sessions start and how an already-open Browser surface follows agent activity.
</div>
<label class="browser-config-field">
@ -34,7 +34,7 @@
<label class="browser-config-switch-row">
<span class="browser-config-switch-copy">
<span class="browser-config-field-label">Autofocus active page</span>
<span class="browser-config-field-help">Update the visible Browser canvas for pages opened or changed by Browser tool results.</span>
<span class="browser-config-field-help">Update the visible Browser surface for pages opened or changed by Browser tool results.</span>
</span>
<span class="browser-config-toggle-with-label">
<span class="browser-config-toggle-label" x-text="$store.browserConfig.autofocusLabel()"></span>

View file

@ -1,5 +1,9 @@
<html
class="browser-modal"
data-surface-id="browser"
data-surface-modal-path="/plugins/_browser/webui/main.html"
data-surface-dock-title="Open Browser in surface"
data-surface-dock-icon="dock_to_right"
data-canvas-surface="browser"
data-canvas-modal-path="/plugins/_browser/webui/main.html"
data-canvas-dock-title="Open Browser in canvas"

View file

@ -1,13 +1,21 @@
import { createStore } from "/js/AlpineStore.js";
import { callJsExtensions } from "/js/extensions.js";
import {
SURFACE_MODE_DOCKED,
SURFACE_MODE_FLOATING,
closeSurfaceGroupModals,
getRegisteredSurfaces,
migratePersistedSurfaceState,
normalizeSurfaceId,
normalizeSurfaceMode,
registerSurface as registerSurfaceDefinition,
} from "/js/surfaces.js";
const STORAGE_KEY = "a0.rightCanvas";
const DEFAULT_WIDTH = 720;
const MIN_WIDTH = 0;
const DESKTOP_BREAKPOINT = 1200;
const MOBILE_BREAKPOINT = 768;
const SURFACE_MODE_CANVAS = "canvas";
const SURFACE_MODE_MODAL = "modal";
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
@ -23,10 +31,6 @@ function normalizeWidth(value, fallback = DEFAULT_WIDTH) {
return Number.isFinite(width) ? Math.max(MIN_WIDTH, Math.round(width)) : fallback;
}
function normalizeSurfaceMode(mode = "") {
return mode === SURFACE_MODE_MODAL ? SURFACE_MODE_MODAL : SURFACE_MODE_CANVAS;
}
const model = {
surfaces: [],
activeSurfaceId: "",
@ -61,6 +65,7 @@ const model = {
if (!this._registering) {
this._registering = true;
await callJsExtensions("surfaces_register", this);
await callJsExtensions("right_canvas_register_surfaces", this);
this._registering = false;
this.ensureActiveSurface();
@ -69,6 +74,7 @@ const model = {
registerSurface(surface) {
if (!surface?.id) return;
const surfaceId = normalizeSurfaceId(surface.id);
const normalized = {
title: surface.id,
icon: "web_asset",
@ -80,6 +86,7 @@ const model = {
modalPath: "",
actionOnly: false,
...surface,
id: surfaceId,
};
const index = this.surfaces.findIndex((item) => item.id === normalized.id);
@ -89,8 +96,9 @@ const model = {
this.surfaces.push(normalized);
}
if (!this.surfaceModes[normalized.id]) {
this.surfaceModes[normalized.id] = SURFACE_MODE_CANVAS;
this.surfaceModes[normalized.id] = SURFACE_MODE_DOCKED;
}
registerSurfaceDefinition(normalized);
this.surfaces.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
if (!this._registering) {
this.ensureActiveSurface();
@ -109,7 +117,7 @@ const model = {
},
async open(surfaceId = "", payload = {}) {
const targetId = surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "";
const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "");
const surface = this.getSurface(targetId);
if (!surface) {
return false;
@ -133,7 +141,7 @@ const model = {
this.activeSurfaceId = targetId;
this.markSurfaceMounted(targetId);
this.isOpen = true;
this.recordSurfaceMode(targetId, SURFACE_MODE_CANVAS, { persist: false });
this.recordSurfaceMode(targetId, SURFACE_MODE_DOCKED, { persist: false });
this._lastPayloadBySurface[targetId] = payload || {};
this.persist();
this.applyLayoutState();
@ -147,7 +155,7 @@ const model = {
},
markSurfaceMounted(surfaceId) {
const targetId = String(surfaceId || "").trim();
const targetId = normalizeSurfaceId(surfaceId);
if (!targetId) return;
this.mountedSurfaces = {
...this.mountedSurfaces,
@ -156,7 +164,7 @@ const model = {
},
markSurfaceUnmounted(surfaceId) {
const targetId = String(surfaceId || "").trim();
const targetId = normalizeSurfaceId(surfaceId);
if (!targetId || !this.mountedSurfaces[targetId]) return;
const next = { ...this.mountedSurfaces };
delete next[targetId];
@ -170,7 +178,7 @@ const model = {
},
isSurfaceMounted(id) {
return Boolean(this.mountedSurfaces[String(id || "").trim()]);
return Boolean(this.mountedSurfaces[normalizeSurfaceId(id)]);
},
isSurfaceRendered(id) {
@ -178,11 +186,12 @@ const model = {
},
isSurfaceVisible(id) {
return Boolean(this.isOpen && this.activeSurfaceId === id && this.isSurfaceMounted(id));
const targetId = normalizeSurfaceId(id);
return Boolean(this.isOpen && this.activeSurfaceId === targetId && this.isSurfaceMounted(targetId));
},
recordSurfaceMode(surfaceId, mode = SURFACE_MODE_CANVAS, options = {}) {
const targetId = String(surfaceId || "").trim();
recordSurfaceMode(surfaceId, mode = SURFACE_MODE_DOCKED, options = {}) {
const targetId = normalizeSurfaceId(surfaceId);
if (!targetId) return;
this.surfaceModes = {
...this.surfaceModes,
@ -192,37 +201,28 @@ const model = {
},
latestSurfaceMode(surfaceId) {
const targetId = String(surfaceId || "").trim();
const targetId = normalizeSurfaceId(surfaceId);
return normalizeSurfaceMode(this.surfaceModes[targetId]);
},
async openLatest(surfaceId = "", payload = {}) {
const targetId = surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "";
const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "");
if (!targetId) return false;
if (this.latestSurfaceMode(targetId) === SURFACE_MODE_MODAL) {
if (this.latestSurfaceMode(targetId) === SURFACE_MODE_FLOATING) {
return await this.openModalSurface(targetId, payload);
}
return await this.open(targetId, payload);
},
async close() {
const mountedIds = this.mountedSurfaceIds();
this.isOpen = false;
this.mountedSurfaces = {};
this.persist();
this.applyLayoutState();
for (const surfaceId of mountedIds) {
const surface = this.getSurface(surfaceId);
try {
await surface?.close?.(this._lastPayloadBySurface[surfaceId] || {});
} catch (error) {
console.error(`Canvas surface ${surfaceId} failed to close`, error);
}
}
return true;
},
async dockSurface(surfaceId, payload = {}) {
surfaceId = normalizeSurfaceId(surfaceId);
if (this.isMobileMode) {
return false;
}
@ -262,6 +262,11 @@ const model = {
}
const sourceModalPath = payload.sourceModalPath || modalPath;
if (sourceModalPath || modalPath) {
const closed = await closeSurfaceGroupModals();
if (closed === false) return false;
if (!sourceModalPath || !globalThis.isModalOpen?.(sourceModalPath)) return true;
}
if (sourceModalPath && globalThis.isModalOpen?.(sourceModalPath)) {
return (await globalThis.closeModal?.(sourceModalPath)) !== false;
}
@ -272,29 +277,18 @@ const model = {
},
async undockSurface(surfaceId = "", payload = {}) {
const targetId = surfaceId || this.activeSurfaceId;
const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId);
const surface = this.getSurface(targetId);
const modalPath = payload.modalPath || surface?.modalPath || "";
if (!surface || !modalPath) return false;
const openModal = globalThis.ensureModalOpen || globalThis.openModal;
if (!openModal) return false;
if (this.activeSurfaceId === targetId) {
const mountedIds = this.mountedSurfaceIds();
this.isOpen = false;
this.mountedSurfaces = {};
this.persist();
this.applyLayoutState();
for (const mountedId of mountedIds) {
const mountedSurface = this.getSurface(mountedId);
try {
await mountedSurface?.close?.(this._lastPayloadBySurface[mountedId] || {});
} catch (error) {
console.error(`Canvas surface ${mountedId} failed to close while undocking`, error);
}
}
}
this.recordSurfaceMode(targetId, SURFACE_MODE_MODAL);
this.recordSurfaceMode(targetId, SURFACE_MODE_FLOATING);
const modalPromise = openModal(modalPath);
if (modalPromise?.catch) {
modalPromise.catch((error) => console.error(`Canvas surface ${targetId} failed to undock`, error));
@ -303,7 +297,7 @@ const model = {
},
async openModalSurface(surfaceId = "", payload = {}) {
const targetId = surfaceId || this.activeSurfaceId;
const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId);
const surface = this.getSurface(targetId);
const modalPath = payload.modalPath || surface?.modalPath || "";
if (!surface || !modalPath) return false;
@ -311,23 +305,12 @@ const model = {
if (!openModal) return false;
if (this.isOpen && this.activeSurfaceId === targetId) {
const mountedIds = this.mountedSurfaceIds();
this.isOpen = false;
this.mountedSurfaces = {};
this.persist();
this.applyLayoutState();
for (const mountedId of mountedIds) {
const mountedSurface = this.getSurface(mountedId);
try {
await mountedSurface?.close?.(this._lastPayloadBySurface[mountedId] || {});
} catch (error) {
console.error(`Canvas surface ${mountedId} failed to close before modal open`, error);
}
}
}
this.recordSurfaceMode(targetId, SURFACE_MODE_MODAL);
this.recordSurfaceMode(targetId, SURFACE_MODE_FLOATING);
const modalPromise = openModal(modalPath);
if (modalPromise?.catch) {
modalPromise.catch((error) => console.error(`Canvas surface ${targetId} failed to open as modal`, error));
@ -344,7 +327,7 @@ const model = {
},
async toggle(surfaceId = "", payload = {}) {
const targetId = surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "";
const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "");
if (this.isOpen && targetId === this.activeSurfaceId) {
await this.close();
return false;
@ -444,7 +427,7 @@ const model = {
restore() {
this.width = this.defaultWidth();
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
const saved = migratePersistedSurfaceState(JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"));
this.isOpen = false;
this.activeSurfaceId = String(saved.activeSurfaceId || "");
this.surfaceModes = Object.fromEntries(
@ -502,7 +485,10 @@ const model = {
},
getSurface(id) {
return this.surfaces.find((surface) => surface.id === id) || null;
const targetId = normalizeSurfaceId(id);
return this.surfaces.find((surface) => surface.id === targetId)
|| getRegisteredSurfaces().find((surface) => surface.id === targetId)
|| null;
},
get railSurfaces() {
@ -518,7 +504,7 @@ const model = {
},
isSurfaceActive(id) {
return this.activeSurfaceId === id;
return this.activeSurfaceId === normalizeSurfaceId(id);
},
activeTitle() {

View file

@ -142,6 +142,8 @@ body.right-canvas-resizing {
}
.right-canvas-header {
position: relative;
z-index: 10;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
@ -150,6 +152,7 @@ body.right-canvas-resizing {
padding: 7px 8px 0 10px;
border-bottom: 1px solid var(--right-canvas-border);
background: color-mix(in srgb, var(--color-background) 91%, #000 9%);
overflow: visible;
}
.right-canvas-tabs {
@ -214,14 +217,18 @@ body.right-canvas-resizing {
}
.right-canvas-toolbar {
position: relative;
z-index: 11;
display: flex;
align-items: center;
gap: 5px;
padding-bottom: 6px;
overflow: visible;
}
.right-canvas-panels {
position: relative;
z-index: 1;
display: flex;
flex: 1 1 auto;
min-width: 0;

View file

@ -18,24 +18,6 @@ the old and the new system. */
display: block;
}
.modal.modal-surface-parked {
display: block;
opacity: 0;
pointer-events: none;
}
.modal.modal-surface-parked .modal-inner {
pointer-events: none;
}
.modal.modal-floating {
pointer-events: none;
}
.modal.modal-floating .modal-inner {
pointer-events: auto;
}
.modal-inner {
display: flex;
flex-direction: column;
@ -162,54 +144,6 @@ the old and the new system. */
background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
}
.modal-surface-switcher {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 34px;
align-items: center;
gap: 5px;
}
.modal-dock-button,
.modal-surface-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
min-width: 34px;
min-height: 34px;
padding: 0;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: var(--color-text);
cursor: pointer;
opacity: 0.72;
transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
}
.modal-dock-button:hover,
.modal-surface-button:hover,
.modal-surface-button.is-active {
opacity: 1;
border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
}
.modal-dock-button .material-symbols-outlined,
.modal-surface-button .material-symbols-outlined {
font-size: 19px;
}
.modal-surface-image {
display: block;
width: 22px;
height: 22px;
border-radius: 6px;
object-fit: cover;
}
/* Modal Description */
.modal-description {
padding: 0.8rem 1rem 0 1rem;

83
webui/css/surfaces.css Normal file
View file

@ -0,0 +1,83 @@
.modal.surface-modal-parked,
.modal.modal-surface-parked {
display: block;
opacity: 0;
pointer-events: none;
}
.modal.surface-modal-parked .modal-inner,
.modal.modal-surface-parked .modal-inner {
pointer-events: none;
}
.modal.surface-floating,
.modal.modal-floating {
pointer-events: none;
}
.modal.surface-floating .modal-inner,
.modal.modal-floating .modal-inner {
pointer-events: auto;
}
.surface-switcher,
.modal-surface-switcher {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 34px;
align-items: center;
gap: 5px;
}
.surface-dock-button,
.surface-button,
.modal-dock-button,
.modal-surface-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
min-width: 34px;
min-height: 34px;
padding: 0;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: var(--color-text);
cursor: pointer;
opacity: 0.72;
transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
}
.surface-dock-button:hover,
.surface-button:hover,
.surface-button.is-active,
.modal-dock-button:hover,
.modal-surface-button:hover,
.modal-surface-button.is-active {
opacity: 1;
border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
}
.surface-dock-button .material-symbols-outlined,
.surface-button .material-symbols-outlined,
.modal-dock-button .material-symbols-outlined,
.modal-surface-button .material-symbols-outlined {
font-size: 19px;
}
.surface-image,
.modal-surface-image {
display: block;
width: 22px;
height: 22px;
border-radius: 6px;
object-fit: cover;
}
.surface-resize-handle {
position: absolute;
touch-action: none;
}

View file

@ -13,6 +13,7 @@
<link rel="stylesheet" href="css/toast.css">
<link rel="stylesheet" href="css/settings.css">
<link rel="stylesheet" href="css/modals.css">
<link rel="stylesheet" href="css/surfaces.css">
<link rel="stylesheet" href="css/speech.css">
<link rel="stylesheet" href="css/scheduler-datepicker.css">
<link rel="stylesheet" href="css/scheduler.css">

View file

@ -1,5 +1,6 @@
import * as initializer from "./initializer.js";
import * as _modals from "./modals.js";
import "./surfaces.js";
import * as _components from "./components.js";
import * as extensions from "./extensions.js";
import { registerAlpineMagic } from "./confirmClick.js";
@ -182,4 +183,4 @@ Alpine.directive(
});
// process extensions
await extensions.callJsExtensions("initFw_end")
await extensions.callJsExtensions("initFw_end")

View file

@ -1,86 +1,57 @@
// Import the component loader and page utilities
import { importComponent } from "/js/components.js";
import { callJsExtensions } from "/js/extensions.js";
import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";
// Modal functionality
const modalStack = [];
const EXPLICIT_CLOSE_MODAL_PATHS = new Set([
"plugins/_browser/webui/main.html",
"plugins/_office/webui/main.html",
]);
const SINGLE_VISIBLE_MODAL_SURFACE_PATHS = new Set([
"plugins/_browser/webui/main.html",
"plugins/_office/webui/main.html",
]);
const CANVAS_SURFACE_MODAL_GROUP = "canvas-surfaces";
const DEFAULT_MODAL_SURFACES = [
{
id: "browser",
title: "Browser",
icon: "language",
modalPath: "/plugins/_browser/webui/main.html",
},
{
id: "office",
title: "Desktop",
icon: "desktop_windows",
modalPath: "/plugins/_office/webui/main.html",
},
];
function normalizeModalPath(modalPath = "") {
return String(modalPath || "").replace(/^\/+/, "");
}
function sameModalPath(left = "", right = "") {
return normalizeModalPath(left) === normalizeModalPath(right);
return String(left || "").replace(/^\/+/, "") === String(right || "").replace(/^\/+/, "");
}
function modalHasClass(modalOrElement, className) {
const element = modalOrElement?.element || modalOrElement;
return Boolean(
element?.classList?.contains(className)
|| element?.querySelector?.(".modal-inner")?.classList?.contains(className)
);
}
function modalDatasetFlag(modalOrElement, name) {
const element = modalOrElement?.element || modalOrElement;
const inner = element?.querySelector?.(".modal-inner");
const value = element?.dataset?.[name] ?? inner?.dataset?.[name] ?? "";
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
}
function modalRequiresExplicitClose(modalOrElement) {
const element = modalOrElement?.element || modalOrElement;
const path = normalizeModalPath(modalOrElement?.path || element?.path || "");
return EXPLICIT_CLOSE_MODAL_PATHS.has(path)
|| element?.classList?.contains("modal-explicit-close")
|| element?.querySelector?.(".modal-inner")?.classList?.contains("modal-explicit-close");
return modalHasClass(modalOrElement, "modal-explicit-close")
|| modalDatasetFlag(modalOrElement, "modalExplicitClose");
}
function modalSurfaceGroup(modalOrElement) {
const element = modalOrElement?.element || modalOrElement;
const path = normalizeModalPath(modalOrElement?.path || element?.path || "");
return SINGLE_VISIBLE_MODAL_SURFACE_PATHS.has(path) ? CANVAS_SURFACE_MODAL_GROUP : "";
function modalSuppressesBackdrop(modalOrElement) {
return modalHasClass(modalOrElement, "modal-no-backdrop")
|| modalDatasetFlag(modalOrElement, "modalNoBackdrop");
}
function setModalParked(modal, parked = false) {
const element = modal?.element;
if (!element) return;
element.classList.toggle("modal-surface-parked", parked);
if (parked) {
element.classList.remove("show");
element.setAttribute("aria-hidden", "true");
} else {
element.classList.add("show");
element.removeAttribute("aria-hidden");
}
}
function parkSiblingSurfaceModals(activeModal) {
const group = modalSurfaceGroup(activeModal);
if (!group) {
setModalParked(activeModal, false);
return;
}
for (const modal of modalStack) {
setModalParked(modal, modal !== activeModal && modalSurfaceGroup(modal) === group);
}
function dispatchModalEvent(name, modal, detail = {}) {
document.dispatchEvent(
new CustomEvent(name, {
detail: {
modalPath: modal?.path ?? null,
modal,
modalStack: getModalStack(),
...detail,
},
}),
);
}
function activateModal(modal) {
if (!modal) return;
parkSiblingSurfaceModals(modal);
updateModalZIndexes();
restoreModalScrollSnapshot(modal);
dispatchModalEvent("modal-activated", modal);
}
function findModalIndexByPath(modalPath) {
@ -133,17 +104,6 @@ backdrop.style.display = "none";
backdrop.style.backdropFilter = "blur(8px) saturate(112%)";
document.body.appendChild(backdrop);
function modalSuppressesBackdrop(modal) {
const path = String(modal?.path || "");
return path === "/plugins/_browser/webui/main.html"
|| path === "plugins/_browser/webui/main.html"
|| path === "/plugins/_office/webui/main.html"
|| path === "plugins/_office/webui/main.html"
|| modal?.element?.classList?.contains("modal-floating")
|| modal?.element?.classList?.contains("modal-no-backdrop")
|| modal?.inner?.classList?.contains("modal-no-backdrop");
}
// Function to update z-index for all modals and backdrop
function updateModalZIndexes() {
// Base z-index for modals
@ -186,6 +146,7 @@ function createModalElement(path) {
const newModal = document.createElement("div");
newModal.className = "modal";
newModal.path = path; // save name to the object
newModal.dataset.modalPath = path;
// Add click handlers to only close modal if both mousedown and mouseup are on the modal container
let mouseDownTarget = null;
@ -250,152 +211,6 @@ function createModalElement(path) {
};
}
function getDockMetadata(doc, modalPath) {
const htmlDataset = doc?.documentElement?.dataset || {};
const bodyDataset = doc?.body?.dataset || {};
const surfaceId = htmlDataset.canvasSurface || bodyDataset.canvasSurface || "";
if (!surfaceId) return null;
return {
surfaceId,
modalPath: htmlDataset.canvasModalPath || bodyDataset.canvasModalPath || modalPath,
title: htmlDataset.canvasDockTitle || bodyDataset.canvasDockTitle || "Open in canvas",
icon: htmlDataset.canvasDockIcon || bodyDataset.canvasDockIcon || "dock_to_right",
};
}
function getModalSwitchSurfaces(metadata) {
const surfacesById = new Map(DEFAULT_MODAL_SURFACES.map((surface) => [surface.id, surface]));
const surfaces = Array.isArray(rightCanvasStore.panelSurfaces)
? rightCanvasStore.panelSurfaces
: [];
for (const surface of surfaces) {
if (!surface?.id || !surface.modalPath || surface.actionOnly) continue;
surfacesById.set(surface.id, {
...surface,
modalPath: surface.modalPath,
});
}
if (metadata?.surfaceId && !surfacesById.has(metadata.surfaceId)) {
surfacesById.set(metadata.surfaceId, {
id: metadata.surfaceId,
title: metadata.title,
icon: metadata.icon,
modalPath: metadata.modalPath,
});
}
return Array.from(surfacesById.values())
.filter((surface) => surface?.id && surface.modalPath && !surface.actionOnly)
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
}
function createModalSurfaceButton(surface, metadata, modal) {
const title = surface.title || surface.id;
const targetModalPath = surface.modalPath || "";
const isActive = surface.id === metadata.surfaceId || sameModalPath(targetModalPath, modal.path);
const button = document.createElement("button");
button.type = "button";
button.className = "modal-surface-button";
button.dataset.canvasSurface = surface.id;
button.setAttribute("aria-label", title);
button.setAttribute("aria-pressed", isActive.toString());
if (isActive) button.classList.add("is-active");
if (surface.image) {
const image = document.createElement("img");
image.className = "modal-surface-image";
image.src = surface.image;
image.alt = "";
image.setAttribute("aria-hidden", "true");
button.appendChild(image);
} else {
const icon = document.createElement("span");
icon.className = "material-symbols-outlined";
icon.setAttribute("aria-hidden", "true");
icon.textContent = surface.icon || "web_asset";
button.appendChild(icon);
}
button.addEventListener("click", () => {
if (button.disabled || isActive || !targetModalPath) return;
button.disabled = true;
try {
rightCanvasStore.recordSurfaceMode?.(surface.id, "modal");
const openPromise = ensureModalOpen(targetModalPath);
if (openPromise?.catch) {
openPromise.catch((error) => console.error(`Modal surface ${surface.id} failed to open`, error));
}
} finally {
if (document.contains(button)) button.disabled = false;
}
});
return button;
}
function configureModalSurfaceSwitcher(modal, doc) {
const metadata = getDockMetadata(doc, modal.path);
if (!metadata || !modal.header || modal.header.querySelector(".modal-surface-switcher")) {
return metadata;
}
const surfaces = getModalSwitchSurfaces(metadata);
if (surfaces.length <= 1) return metadata;
const switcher = document.createElement("div");
switcher.className = "modal-surface-switcher";
switcher.setAttribute("role", "group");
switcher.setAttribute("aria-label", "Modal surfaces");
for (const surface of surfaces) {
switcher.appendChild(createModalSurfaceButton(surface, metadata, modal));
}
modal.close?.insertAdjacentElement("beforebegin", switcher);
return metadata;
}
function configureModalDockButton(modal, doc) {
const metadata = configureModalSurfaceSwitcher(modal, doc);
if (!metadata || !modal.header || modal.header.querySelector(".modal-dock-button")) {
return;
}
rightCanvasStore.recordSurfaceMode?.(metadata.surfaceId, "modal");
const button = document.createElement("button");
button.type = "button";
button.className = "modal-dock-button";
button.setAttribute("aria-label", metadata.title);
button.innerHTML = `<span class="material-symbols-outlined" aria-hidden="true">${metadata.icon}</span>`;
button.addEventListener("click", async () => {
if (button.disabled) return;
button.disabled = true;
try {
await rightCanvasStore.dockSurface?.(metadata.surfaceId, {
modalPath: metadata.modalPath,
sourceModalPath: modal.path,
source: "modal",
closeSourceModal: async () => {
const closed = await closeModal(modal.path);
if (closed === false) return false;
if (document.contains(modal.element)) {
const fallbackClosed = await closeModal();
if (fallbackClosed === false) return false;
}
return !document.contains(modal.element);
},
});
} finally {
if (document.contains(button)) button.disabled = false;
}
});
modal.close?.insertAdjacentElement("beforebegin", button);
}
// Function to open modal with content from URL
export async function openModal(modalPath, beforeClose = null) {
const openCtx = { modalPath, modal: null, cancel: false };
@ -432,7 +247,7 @@ export async function openModal(modalPath, beforeClose = null) {
// Use importComponent which now returns the parsed document
importComponent(componentPath, modal.body)
.then((doc) => {
.then(async (doc) => {
// Set the title from the document
modal.title.innerHTML = doc.title || modalPath;
if (doc.html && doc.html.classList) {
@ -442,8 +257,13 @@ export async function openModal(modalPath, beforeClose = null) {
if (doc.body && doc.body.classList) {
modal.body.classList.add(...doc.body.classList);
}
configureModalDockButton(modal, doc);
updateModalZIndexes();
await callJsExtensions("modal_content_loaded", {
modalPath,
modal,
doc,
});
dispatchModalEvent("modal-content-loaded", modal, { doc });
refreshModalStack();
// Some modals have a footer. Check if it exists and move it to footer slot
// Use requestAnimationFrame to let Alpine mount the component first
@ -480,6 +300,18 @@ export function isModalOpen(modalPath) {
return findModalIndexByPath(modalPath) !== -1;
}
export function getModalStack() {
return modalStack.slice();
}
export function refreshModalStack() {
if (modalStack.length === 0) {
updateModalZIndexes();
return;
}
activateModal(modalStack[modalStack.length - 1]);
}
export async function ensureModalOpen(modalPath, beforeClose = null) {
if (focusModal(modalPath)) return null;
return openModal(modalPath, beforeClose);

445
webui/js/surfaces.js Normal file
View file

@ -0,0 +1,445 @@
export const SURFACE_MODE_DOCKED = "canvas";
export const SURFACE_MODE_FLOATING = "modal";
export const SURFACE_MODAL_GROUP = "surfaces";
const LEGACY_SURFACE_IDS = new Map([
["office", "desktop"],
]);
const registeredSurfaces = new Map();
const urlHandlers = new Set();
export const CORE_SURFACES = [
{
id: "browser",
title: "Browser",
icon: "language",
order: 10,
modalPath: "/plugins/_browser/webui/main.html",
},
{
id: "desktop",
title: "Desktop",
icon: "desktop_windows",
order: 20,
modalPath: "/plugins/_desktop/webui/main.html",
},
];
export function normalizeSurfaceId(surfaceId = "") {
const normalized = String(surfaceId || "").trim();
return LEGACY_SURFACE_IDS.get(normalized) || normalized;
}
export function normalizeSurfaceMode(mode = "") {
return mode === SURFACE_MODE_FLOATING ? SURFACE_MODE_FLOATING : SURFACE_MODE_DOCKED;
}
export function normalizeModalPath(modalPath = "") {
return String(modalPath || "").replace(/^\/+/, "");
}
export function sameModalPath(left = "", right = "") {
return normalizeModalPath(left) === normalizeModalPath(right);
}
export function migratePersistedSurfaceState(saved = {}) {
const result = { ...(saved || {}) };
result.activeSurfaceId = normalizeSurfaceId(result.activeSurfaceId || "");
result.surfaceModes = migrateSurfaceModeMap(result.surfaceModes || {});
return result;
}
function migrateSurfaceModeMap(surfaceModes = {}) {
const result = {};
for (const [surfaceId, mode] of Object.entries(surfaceModes || {})) {
const normalizedId = normalizeSurfaceId(surfaceId);
if (!normalizedId) continue;
if (result[normalizedId] && normalizedId !== surfaceId) continue;
result[normalizedId] = normalizeSurfaceMode(mode);
}
return result;
}
export function registerSurface(surface = {}) {
const id = normalizeSurfaceId(surface.id || "");
if (!id) return null;
const normalized = {
title: id,
icon: "web_asset",
image: "",
order: 100,
canOpen: () => true,
open: () => {},
close: () => {},
modalPath: "",
actionOnly: false,
...surface,
id,
};
registeredSurfaces.set(id, normalized);
return normalized;
}
export function getRegisteredSurfaces() {
const surfacesById = new Map(CORE_SURFACES.map((surface) => [surface.id, surface]));
for (const surface of registeredSurfaces.values()) {
surfacesById.set(surface.id, surface);
}
return Array.from(surfacesById.values())
.filter((surface) => surface?.id)
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
}
export function getSurface(surfaceId = "") {
const targetId = normalizeSurfaceId(surfaceId);
return getRegisteredSurfaces().find((surface) => surface.id === targetId) || null;
}
export function modalSurfaceMetadata(doc, modalPath = "") {
const htmlDataset = doc?.documentElement?.dataset || {};
const bodyDataset = doc?.body?.dataset || {};
const surfaceId = normalizeSurfaceId(
htmlDataset.surfaceId
|| bodyDataset.surfaceId
|| htmlDataset.canvasSurface
|| bodyDataset.canvasSurface
|| "",
);
if (!surfaceId) return null;
return {
surfaceId,
modalPath: (
htmlDataset.surfaceModalPath
|| bodyDataset.surfaceModalPath
|| htmlDataset.canvasModalPath
|| bodyDataset.canvasModalPath
|| modalPath
),
title: (
htmlDataset.surfaceDockTitle
|| bodyDataset.surfaceDockTitle
|| htmlDataset.canvasDockTitle
|| bodyDataset.canvasDockTitle
|| "Open in surface"
),
icon: (
htmlDataset.surfaceDockIcon
|| bodyDataset.surfaceDockIcon
|| htmlDataset.canvasDockIcon
|| bodyDataset.canvasDockIcon
|| "dock_to_right"
),
};
}
export function modalHasSurfaceMetadata(modalOrElement) {
const element = modalOrElement?.element || modalOrElement;
return Boolean(
element?.dataset?.surfaceId
|| element?.dataset?.canvasSurface
|| element?.querySelector?.(".modal-inner")?.dataset?.surfaceId
|| element?.querySelector?.(".modal-inner")?.dataset?.canvasSurface
|| modalPathMatchesSurface(modalOrElement?.path || element?.path || ""),
);
}
export function modalPathMatchesSurface(path = "") {
return getRegisteredSurfaces().some((surface) => sameModalPath(surface.modalPath || "", path));
}
function modalSurfaceDefinition(modalOrElement) {
const element = modalOrElement?.element || modalOrElement;
const path = typeof modalOrElement === "string"
? modalOrElement
: modalOrElement?.path || element?.path || element?.dataset?.modalPath || "";
return getRegisteredSurfaces().find((surface) => sameModalPath(surface.modalPath || "", path)) || null;
}
function modalSurfaceGroup(modalOrElement) {
return modalSurfaceDefinition(modalOrElement) ? SURFACE_MODAL_GROUP : "";
}
export function shouldSuppressBackdrop(modal) {
return Boolean(
modalHasSurfaceMetadata(modal)
|| modal?.element?.classList?.contains("surface-floating")
|| modal?.element?.classList?.contains("modal-floating")
|| modal?.element?.classList?.contains("modal-no-backdrop")
|| modal?.inner?.classList?.contains("surface-modal")
|| modal?.inner?.classList?.contains("modal-no-backdrop")
);
}
function setModalParked(modal, parked = false) {
const element = modal?.element;
if (!element) return;
element.classList.toggle("modal-surface-parked", parked);
element.classList.toggle("surface-modal-parked", parked);
if (parked) {
element.classList.remove("show");
element.setAttribute("aria-hidden", "true");
} else {
element.classList.add("show");
element.removeAttribute("aria-hidden");
}
}
async function modalApi() {
return await import("/js/modals.js");
}
async function parkSiblingSurfaceModals(activeModal) {
const group = modalSurfaceGroup(activeModal);
if (!group) {
setModalParked(activeModal, false);
return;
}
const { getModalStack } = await modalApi();
for (const modal of getModalStack()) {
setModalParked(modal, modal !== activeModal && modalSurfaceGroup(modal) === group);
}
}
export async function closeSurfaceGroupModals(options = {}) {
const { closeModal, getModalStack, isModalOpen } = await modalApi();
const exceptPath = normalizeModalPath(options?.exceptPath || "");
const targets = getModalStack()
.filter((modal) => modalSurfaceGroup(modal) === SURFACE_MODAL_GROUP)
.map((modal) => ({
path: modal.path,
surface: modalSurfaceDefinition(modal),
}))
.filter((target) => !exceptPath || normalizeModalPath(target.path) !== exceptPath)
.reverse();
const handoffPayload = { source: "modal-group-close" };
const handoffs = [];
let closedAll = false;
try {
for (const target of targets) {
if (!target.surface?.beginDockHandoff) continue;
await target.surface.beginDockHandoff({ ...handoffPayload, modalPath: target.path });
handoffs.push(target.surface);
}
for (const target of targets) {
if (!isModalOpen(target.path)) continue;
const closed = await closeModal(target.path);
if (closed === false) return false;
}
closedAll = true;
return true;
} finally {
for (const surface of handoffs) {
try {
if (closedAll) {
await surface.finishDockHandoff?.({ ...handoffPayload, opened: false });
} else {
await surface.cancelDockHandoff?.(handoffPayload);
}
} catch (error) {
console.error("Surface modal group handoff cleanup failed", error);
}
}
}
}
function getModalSwitchSurfaces(metadata) {
const surfacesById = new Map(CORE_SURFACES.map((surface) => [surface.id, surface]));
for (const surface of getRegisteredSurfaces()) {
if (!surface?.id || !surface.modalPath || surface.actionOnly) continue;
surfacesById.set(surface.id, {
...surface,
modalPath: surface.modalPath,
});
}
if (metadata?.surfaceId && !surfacesById.has(metadata.surfaceId)) {
surfacesById.set(metadata.surfaceId, {
id: metadata.surfaceId,
title: metadata.title,
icon: metadata.icon,
modalPath: metadata.modalPath,
});
}
return Array.from(surfacesById.values())
.filter((surface) => surface?.id && surface.modalPath && !surface.actionOnly)
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
}
function markSurfaceModal(modal, metadata) {
const element = modal?.element;
const inner = modal?.inner || element?.querySelector?.(".modal-inner");
if (!element || !inner) return;
element.dataset.surfaceId = metadata.surfaceId;
element.classList.add("surface-floating", "modal-floating", "modal-no-backdrop", "modal-explicit-close");
inner.classList.add("surface-modal", "modal-no-backdrop", "modal-explicit-close");
}
function createModalSurfaceButton(surface, metadata, modal) {
const title = surface.title || surface.id;
const targetModalPath = surface.modalPath || "";
const normalizedId = normalizeSurfaceId(surface.id);
const isActive = normalizedId === metadata.surfaceId || sameModalPath(targetModalPath, modal.path);
const button = document.createElement("button");
button.type = "button";
button.className = "surface-button modal-surface-button";
button.dataset.surfaceId = normalizedId;
button.dataset.canvasSurface = normalizedId;
button.setAttribute("aria-label", title);
button.setAttribute("aria-pressed", isActive.toString());
if (isActive) button.classList.add("is-active");
if (surface.image) {
const image = document.createElement("img");
image.className = "modal-surface-image";
image.src = surface.image;
image.alt = "";
image.setAttribute("aria-hidden", "true");
button.appendChild(image);
} else {
const icon = document.createElement("span");
icon.className = "material-symbols-outlined";
icon.setAttribute("aria-hidden", "true");
icon.textContent = surface.icon || "web_asset";
button.appendChild(icon);
}
button.addEventListener("click", async () => {
if (button.disabled || isActive || !targetModalPath) return;
button.disabled = true;
try {
await recordMode(normalizedId, SURFACE_MODE_FLOATING);
const { ensureModalOpen } = await modalApi();
const openPromise = ensureModalOpen(targetModalPath);
if (openPromise?.catch) {
openPromise.catch((error) => console.error(`Modal surface ${surface.id} failed to open`, error));
}
} finally {
if (document.contains(button)) button.disabled = false;
}
});
return button;
}
function configureModalSurfaceSwitcher(modal, metadata) {
if (!metadata || !modal?.header || modal.header.querySelector(".surface-switcher, .modal-surface-switcher")) {
return;
}
const surfaces = getModalSwitchSurfaces(metadata);
if (surfaces.length <= 1) return;
const switcher = document.createElement("div");
switcher.className = "surface-switcher modal-surface-switcher";
switcher.setAttribute("role", "group");
switcher.setAttribute("aria-label", "Modal surfaces");
for (const surface of surfaces) {
switcher.appendChild(createModalSurfaceButton(surface, metadata, modal));
}
modal.close?.insertAdjacentElement("beforebegin", switcher);
}
function configureModalDockButton(modal, metadata) {
if (!metadata || !modal?.header || modal.header.querySelector(".surface-dock-button, .modal-dock-button")) {
return;
}
void recordMode(metadata.surfaceId, SURFACE_MODE_FLOATING);
const button = document.createElement("button");
button.type = "button";
button.className = "surface-dock-button modal-dock-button";
button.setAttribute("aria-label", metadata.title);
button.innerHTML = `<span class="material-symbols-outlined" aria-hidden="true">${metadata.icon}</span>`;
button.addEventListener("click", async () => {
if (button.disabled) return;
button.disabled = true;
try {
await dock(metadata.surfaceId, {
modalPath: metadata.modalPath,
sourceModalPath: modal.path,
source: "modal",
closeSourceModal: async () => {
const closed = await closeSurfaceGroupModals();
if (closed === false) return false;
return !document.contains(modal.element);
},
});
} finally {
if (document.contains(button)) button.disabled = false;
}
});
modal.close?.insertAdjacentElement("beforebegin", button);
}
async function configureSurfaceModal(event) {
const { modal, doc } = event?.detail || {};
const metadata = modalSurfaceMetadata(doc, modal?.path || "");
if (!metadata) return;
markSurfaceModal(modal, metadata);
configureModalSurfaceSwitcher(modal, metadata);
configureModalDockButton(modal, metadata);
const { refreshModalStack } = await modalApi();
refreshModalStack();
}
export async function open(surfaceId = "", payload = {}) {
const { store } = await import("/components/canvas/right-canvas-store.js");
return await store.open(normalizeSurfaceId(surfaceId), payload);
}
export async function openLatest(surfaceId = "", payload = {}) {
const { store } = await import("/components/canvas/right-canvas-store.js");
return await store.openLatest(normalizeSurfaceId(surfaceId), payload);
}
export async function dock(surfaceId = "", payload = {}) {
const { store } = await import("/components/canvas/right-canvas-store.js");
return await store.dockSurface(normalizeSurfaceId(surfaceId), payload);
}
export async function recordMode(surfaceId = "", mode = SURFACE_MODE_DOCKED, options = {}) {
const { store } = await import("/components/canvas/right-canvas-store.js");
return store.recordSurfaceMode?.(normalizeSurfaceId(surfaceId), normalizeSurfaceMode(mode), options);
}
export function registerUrlHandler(handler) {
if (typeof handler !== "function") return () => {};
urlHandlers.add(handler);
return () => urlHandlers.delete(handler);
}
export async function handleUrlIntent(intent = {}) {
for (const handler of Array.from(urlHandlers)) {
const handled = await handler(intent);
if (handled) return true;
}
globalThis.dispatchEvent?.(new CustomEvent("surface-url-intent", { detail: intent }));
return false;
}
document.addEventListener("modal-content-loaded", (event) => {
void configureSurfaceModal(event);
});
document.addEventListener("modal-activated", (event) => {
void parkSiblingSurfaceModals(event?.detail?.modal);
});
document.addEventListener("modal-closed", async () => {
const { getModalStack, refreshModalStack } = await modalApi();
const stack = getModalStack();
if (stack.length > 0) {
refreshModalStack();
}
});
globalThis.closeSurfaceGroupModals = closeSurfaceGroupModals;