Bridge desktop URLs into Browser

Register the Agent Zero Browser as the Desktop URL handler, queue URL intents from the Xfce environment, and route them into Browser on the opposite canvas/modal surface. Also make floating Browser and Desktop modals pass outside clicks through while preserving interaction inside the modal window.

Alessandro committed May 3, 2026 at 00:57 UTC 677a0c1e64aafd0566adb9f3b1d25a6d4f2e0c0f
4 files changed +413 -14
plugins/_browser/webui/browser-store.js
+21
@@ -1242,6 +1242,27 @@ const model = {
1242 }
1243 },
1244
1245 + async openUrlIntent(url = "", options = {}) {
1246 + if (this._openPromise) {
1247 + try {
1248 + await this._openPromise;
1249 + } catch {}
1250 + }
1251 + if (!this._surfaceMounted) return false;
1252 + const targetUrl = String(url || "").trim();
1253 + if (targetUrl) {
1254 + await this.command("open", {
1255 + url: targetUrl,
1256 + source: options?.source || "desktop-url",
1257 + });
1258 + return true;
1259 + }
1260 + if (!this.activeBrowserId) {
1261 + await this.command("open");
1262 + }
1263 + return true;
1264 + },
1265 +
1266 onAddressFocus() {
1267 this.addressFocused = true;
1268 },
plugins/_office/helpers/libreoffice_desktop.py
+245 -14
@@ -1,6 +1,7 @@
1 from __future__ import annotations
2
3 import atexit
4 +import fcntl
5 import hashlib
6 import json
7 import os
@@ -57,6 +58,9 @@ DESKTOP_FOLDER_LINKS = (
58 ("Agents", ("usr", "agents")),
59 ("Downloads", ("usr", "downloads")),
60 )
61 +URL_INTENT_MAX_ITEMS = 50
62 +URL_INTENT_MAX_LENGTH = 8192
63 +URL_HANDLER_DESKTOP_ID = "agent-zero-browser.desktop"
64 OOR_NS = "http://openoffice.org/2001/registry"
65 XS_NS = "http://www.w3.org/2001/XMLSchema"
66 XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
@@ -186,11 +190,34 @@ class LibreOfficeDesktopManager:
190 session = self.get(session_id) if session_id else self._find_by_file_id(file_id)
191 if not session:
192 return {"ok": False, "error": "LibreOffice desktop session not found."}
193 + if not _url_bridge_script_path(session).exists():
194 + try:
195 + self._prepare_desktop_url_bridge(session)
196 + self._refresh_xfce_desktop(session)
197 + except Exception:
198 + pass
199 + url_intents = self.claim_url_intents(session.session_id)
200 doc = self._document_for_save(session, file_id)
201 if not doc:
191 - return {"ok": True, "session_id": session.session_id, "desktop": session.public()}
202 + return {
203 + "ok": True,
204 + "session_id": session.session_id,
205 + "desktop": session.public(),
206 + "url_intents": url_intents,
207 + }
208 updated = document_store.register_document(doc["path"])
193 - return {"ok": True, "session_id": session.session_id, "document": _public_doc(updated)}
209 + return {
210 + "ok": True,
211 + "session_id": session.session_id,
212 + "document": _public_doc(updated),
213 + "url_intents": url_intents,
214 + }
215 +
216 + def claim_url_intents(self, session_id: str = SYSTEM_SESSION_ID) -> list[dict[str, Any]]:
217 + session = self.get(session_id) or self.get(SYSTEM_SESSION_ID)
218 + if not session:
219 + return []
220 + return _claim_url_intents(session)
221
222 def retarget_document(self, file_id: str, doc: dict[str, Any]) -> dict[str, Any]:
223 session = self._find_by_file_id(file_id)
@@ -329,6 +356,8 @@ class LibreOfficeDesktopManager:
356 def _ensure_system_desktop_locked(self) -> DesktopSession:
357 existing = self._sessions.get(SYSTEM_SESSION_ID)
358 if existing and existing.alive():
359 + self._prepare_desktop_url_bridge(existing)
360 + self._refresh_xfce_desktop(existing)
361 return existing
362
363 status = collect_desktop_status()
@@ -523,20 +552,9 @@ class LibreOfficeDesktopManager:
552 encoding="utf-8",
553 )
554 _write_thunar_defaults(xfce_conf_dir / "thunar.xml")
526 - helpers_rc = config_dir / "xfce4" / "helpers.rc"
527 - helpers_rc.parent.mkdir(parents=True, exist_ok=True)
528 - helpers_rc.write_text(
529 - "\n".join(
530 - [
531 - "TerminalEmulator=xfce4-terminal",
532 - "FileManager=thunar",
533 - "",
534 - ],
535 - ),
536 - encoding="utf-8",
537 - )
555 self._hide_xpra_desktop_entries(applications_dir)
556 self._hide_xfce_menu_entries(applications_dir)
557 + self._prepare_desktop_url_bridge(session)
558
559 base_args = (
560 soffice,
@@ -608,6 +626,55 @@ class LibreOfficeDesktopManager:
626 self._prepare_xfce_panel_config(session)
627 self._prepare_xfce_profile_autostart(session)
628
629 + def _prepare_desktop_url_bridge(self, session: DesktopSession) -> None:
630 + desktop_dir = session.profile_dir / "Desktop"
631 + config_dir = session.profile_dir / ".config"
632 + data_dir = session.profile_dir / ".local" / "share"
633 + applications_dir = data_dir / "applications"
634 + desktop_dir.mkdir(parents=True, exist_ok=True)
635 + config_dir.mkdir(parents=True, exist_ok=True)
636 + applications_dir.mkdir(parents=True, exist_ok=True)
637 +
638 + browser_bridge = _write_url_bridge_script(session)
639 + helpers_rc = config_dir / "xfce4" / "helpers.rc"
640 + helpers_rc.parent.mkdir(parents=True, exist_ok=True)
641 + helpers_rc.write_text(
642 + "\n".join(
643 + [
644 + "TerminalEmulator=xfce4-terminal",
645 + "FileManager=thunar",
646 + "WebBrowser=agent-zero-browser",
647 + "",
648 + ],
649 + ),
650 + encoding="utf-8",
651 + )
652 + _write_xfce_browser_helper(
653 + config_dir / "xfce4" / "helpers" / "agent-zero-browser.desktop",
654 + browser_bridge,
655 + )
656 + _write_mimeapps_defaults(config_dir / "mimeapps.list", URL_HANDLER_DESKTOP_ID)
657 + _write_mimeapps_defaults(data_dir / "applications" / "mimeapps.list", URL_HANDLER_DESKTOP_ID)
658 + _write_desktop_launcher(
659 + applications_dir / URL_HANDLER_DESKTOP_ID,
660 + name="Agent Zero Browser",
661 + exec_line=_desktop_exec(browser_bridge, "%U"),
662 + icon="web-browser",
663 + categories="Network;WebBrowser;",
664 + try_exec=str(browser_bridge),
665 + mime_types=_url_handler_mime_types(),
666 + no_display=True,
667 + )
668 + _write_desktop_launcher(
669 + desktop_dir / "Browser.desktop",
670 + name="Browser",
671 + exec_line=_desktop_exec(browser_bridge),
672 + icon="web-browser",
673 + categories="Network;WebBrowser;",
674 + try_exec=str(browser_bridge),
675 + )
676 + self._trust_desktop_launchers(session, desktop_dir)
677 +
678 def _hide_xpra_desktop_entries(self, applications_dir: Path) -> None:
679 for filename in HIDDEN_XPRA_DESKTOP_ENTRIES:
680 _write_hidden_application_entry(applications_dir / filename, "Xpra")
@@ -908,6 +975,9 @@ fi
975 "HOME": str(session.profile_dir),
976 "LANG": os.environ.get("LANG") or "C.UTF-8",
977 }
978 + browser_bridge = _url_bridge_script_path(session)
979 + if browser_bridge.exists():
980 + env["BROWSER"] = str(browser_bridge)
981 env.setdefault("XDG_RUNTIME_DIR", str(STATE_DIR / "xdg-runtime"))
982 runtime_dir = Path(env["XDG_RUNTIME_DIR"])
983 runtime_dir.mkdir(parents=True, exist_ok=True)
@@ -1187,6 +1257,161 @@ def _ensure_desktop_folder_link(desktop_dir: Path, label: str, target: Path) ->
1257 return
1258
1259
1260 +def _url_bridge_dir(session: DesktopSession) -> Path:
1261 + return session.profile_dir / ".agent-zero"
1262 +
1263 +
1264 +def _url_bridge_script_path(session: DesktopSession) -> Path:
1265 + return _url_bridge_dir(session) / "open-url"
1266 +
1267 +
1268 +def _url_bridge_queue_path(session: DesktopSession) -> Path:
1269 + return _url_bridge_dir(session) / "browser-url-intents.jsonl"
1270 +
1271 +
1272 +def _url_bridge_lock_path(session: DesktopSession) -> Path:
1273 + return _url_bridge_dir(session) / "browser-url-intents.lock"
1274 +
1275 +
1276 +def _write_url_bridge_script(session: DesktopSession) -> Path:
1277 + bridge_dir = _url_bridge_dir(session)
1278 + bridge_dir.mkdir(parents=True, exist_ok=True)
1279 + script = _url_bridge_script_path(session)
1280 + queue = _url_bridge_queue_path(session)
1281 + lock = _url_bridge_lock_path(session)
1282 + script.write_text(
1283 + f"""#!/usr/bin/env python3
1284 +import fcntl
1285 +import json
1286 +import os
1287 +import sys
1288 +import time
1289 +
1290 +QUEUE_PATH = {str(queue)!r}
1291 +LOCK_PATH = {str(lock)!r}
1292 +MAX_URL_LENGTH = {URL_INTENT_MAX_LENGTH}
1293 +
1294 +
1295 +def main():
1296 + urls = [str(arg or "").strip()[:MAX_URL_LENGTH] for arg in sys.argv[1:] if str(arg or "").strip()]
1297 + if not urls:
1298 + urls = [""]
1299 + os.makedirs(os.path.dirname(QUEUE_PATH), exist_ok=True)
1300 + with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file:
1301 + fcntl.flock(lock_file, fcntl.LOCK_EX)
1302 + with open(QUEUE_PATH, "a", encoding="utf-8") as queue_file:
1303 + for url in urls:
1304 + queue_file.write(json.dumps({{
1305 + "url": url,
1306 + "created_at": time.time(),
1307 + "source": "desktop",
1308 + }}, ensure_ascii=True) + "\\n")
1309 + queue_file.flush()
1310 + os.fsync(queue_file.fileno())
1311 + fcntl.flock(lock_file, fcntl.LOCK_UN)
1312 +
1313 +
1314 +if __name__ == "__main__":
1315 + main()
1316 +""",
1317 + encoding="utf-8",
1318 + )
1319 + try:
1320 + script.chmod(0o755)
1321 + except OSError:
1322 + pass
1323 + return script
1324 +
1325 +
1326 +def _claim_url_intents(session: DesktopSession) -> list[dict[str, Any]]:
1327 + queue = _url_bridge_queue_path(session)
1328 + lock = _url_bridge_lock_path(session)
1329 + if not queue.exists():
1330 + return []
1331 + lock.parent.mkdir(parents=True, exist_ok=True)
1332 + try:
1333 + with open(lock, "a+", encoding="utf-8") as lock_file:
1334 + fcntl.flock(lock_file, fcntl.LOCK_EX)
1335 + try:
1336 + raw = queue.read_text(encoding="utf-8")
1337 + queue.write_text("", encoding="utf-8")
1338 + finally:
1339 + fcntl.flock(lock_file, fcntl.LOCK_UN)
1340 + except OSError:
1341 + return []
1342 +
1343 + intents: list[dict[str, Any]] = []
1344 + for line in raw.splitlines():
1345 + try:
1346 + payload = json.loads(line)
1347 + except json.JSONDecodeError:
1348 + continue
1349 + url = str(payload.get("url") or "").strip()
1350 + if len(url) > URL_INTENT_MAX_LENGTH:
1351 + url = url[:URL_INTENT_MAX_LENGTH]
1352 + created_at = payload.get("created_at")
1353 + try:
1354 + created_at = float(created_at)
1355 + except (TypeError, ValueError):
1356 + created_at = time.time()
1357 + intents.append(
1358 + {
1359 + "url": url,
1360 + "created_at": created_at,
1361 + "source": str(payload.get("source") or "desktop"),
1362 + },
1363 + )
1364 + if len(intents) >= URL_INTENT_MAX_ITEMS:
1365 + break
1366 + return intents
1367 +
1368 +
1369 +def _url_handler_mime_types() -> tuple[str, ...]:
1370 + return (
1371 + "x-scheme-handler/http",
1372 + "x-scheme-handler/https",
1373 + "text/html",
1374 + "application/xhtml+xml",
1375 + )
1376 +
1377 +
1378 +def _write_mimeapps_defaults(path: Path, desktop_id: str) -> None:
1379 + associations = ";".join([desktop_id, ""])
1380 + lines = [
1381 + "[Default Applications]",
1382 + *(f"{mime_type}={desktop_id}" for mime_type in _url_handler_mime_types()),
1383 + "",
1384 + "[Added Associations]",
1385 + *(f"{mime_type}={associations}" for mime_type in _url_handler_mime_types()),
1386 + "",
1387 + ]
1388 + path.parent.mkdir(parents=True, exist_ok=True)
1389 + path.write_text("\n".join(lines), encoding="utf-8")
1390 +
1391 +
1392 +def _write_xfce_browser_helper(path: Path, bridge_script: Path) -> None:
1393 + command = _desktop_exec(bridge_script)
1394 + command_with_parameter = _desktop_exec(bridge_script, "%s")
1395 + path.parent.mkdir(parents=True, exist_ok=True)
1396 + path.write_text(
1397 + "\n".join(
1398 + [
1399 + "[Desktop Entry]",
1400 + "NoDisplay=true",
1401 + "Version=1.0",
1402 + "Type=X-XFCE-Helper",
1403 + "X-XFCE-Category=WebBrowser",
1404 + f"X-XFCE-Commands={command}",
1405 + f"X-XFCE-CommandsWithParameter={command_with_parameter}",
1406 + "Icon=web-browser",
1407 + "Name=Agent Zero Browser",
1408 + "",
1409 + ],
1410 + ),
1411 + encoding="utf-8",
1412 + )
1413 +
1414 +
1415 def _remove_path_if_owned(path: Path) -> None:
1416 try:
1417 if path.is_symlink() or path.is_file():
@@ -1293,6 +1518,8 @@ def _write_desktop_launcher(
1518 categories: str,
1519 try_exec: str = "",
1520 working_dir: str | Path | None = None,
1521 + mime_types: tuple[str, ...] = (),
1522 + no_display: bool = False,
1523 ) -> None:
1524 path.parent.mkdir(parents=True, exist_ok=True)
1525 lines = [
@@ -1306,6 +1533,10 @@ def _write_desktop_launcher(
1533 lines.append(f"TryExec={try_exec}")
1534 if working_dir:
1535 lines.append(f"Path={working_dir}")
1536 + if mime_types:
1537 + lines.append(f"MimeType={';'.join(mime_types)};")
1538 + if no_display:
1539 + lines.append("NoDisplay=true")
1540 lines.extend(
1541 [
1542 f"Icon={icon}",
plugins/_office/webui/office-store.js
+139
@@ -2,6 +2,8 @@ import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi } from "/js/api.js";
3 import { getNamespacedClient } from "/js/websocket.js";
4 import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
5 +import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";
6 +import { store as browserStore } from "/plugins/_browser/webui/browser-store.js";
7
8 const officeSocket = getNamespacedClient("/ws");
9 officeSocket.addHandlers(["ws_webui"]);
@@ -13,6 +15,9 @@ const DESKTOP_RESIZE_DELAY_MS = 80;
15 const XPRA_DESKTOP_PRIME_INTERVAL_MS = 220;
16 const XPRA_DESKTOP_PRIME_ATTEMPTS = 120;
17 const SYSTEM_DESKTOP_FILE_ID = "system-desktop";
18 +const BROWSER_MODAL_PATH = "/plugins/_browser/webui/main.html";
19 +const OFFICE_MODAL_PATH = "/plugins/_office/webui/main.html";
20 +const URL_INTENT_PANEL_TIMEOUT_MS = 5000;
21 const MAX_HISTORY = 80;
22
23 function currentContextId() {
@@ -59,6 +64,46 @@ function isEditableInputTarget(target) {
64 return !["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"].includes(type);
65 }
66
67 +function normalizeModalPath(path = "") {
68 + return String(path || "").replace(/^\/+/, "");
69 +}
70 +
71 +function isModalPathOpen(path = "") {
72 + const normalized = normalizeModalPath(path);
73 + return Boolean(
74 + globalThis.isModalOpen?.(path)
75 + || globalThis.isModalOpen?.(`/${normalized}`)
76 + || globalThis.isModalOpen?.(normalized)
77 + );
78 +}
79 +
80 +function waitForElementByPredicate(predicate, timeoutMs = URL_INTENT_PANEL_TIMEOUT_MS) {
81 + const found = predicate();
82 + if (found) return Promise.resolve(found);
83 + return new Promise((resolve) => {
84 + const timeout = globalThis.setTimeout(() => {
85 + observer.disconnect();
86 + resolve(predicate());
87 + }, timeoutMs);
88 + const observer = new MutationObserver(() => {
89 + const element = predicate();
90 + if (!element) return;
91 + globalThis.clearTimeout(timeout);
92 + observer.disconnect();
93 + resolve(element);
94 + });
95 + observer.observe(document.body, { childList: true, subtree: true });
96 + });
97 +}
98 +
99 +function browserPanelForMode(mode = "modal") {
100 + const panels = Array.from(document.querySelectorAll(".browser-panel"));
101 + if (mode === "canvas") {
102 + return panels.find((panel) => panel.closest?.('[data-surface-id="browser"]')) || null;
103 + }
104 + return panels.find((panel) => panel.closest?.(".modal")) || null;
105 +}
106 +
107 function placeCaretAtEnd(element) {
108 if (!element) return;
109 if (element.tagName === "TEXTAREA" || element.tagName === "INPUT") {
@@ -182,6 +227,8 @@ const model = {
227 _desktopKeyboardCleanup: null,
228 _desktopClipboardCleanup: null,
229 _desktopStarting: null,
230 + _desktopUrlIntentBusy: false,
231 + _desktopUrlIntentQueue: [],
232
233 async init(element = null) {
234 return await this.onMount(element, { mode: "canvas" });
@@ -1574,6 +1621,94 @@ const model = {
1621 }
1622 },
1623
1624 + async handleDesktopUrlIntents(intents = []) {
1625 + const incoming = Array.isArray(intents)
1626 + ? intents.filter((intent) => intent && typeof intent === "object")
1627 + : [];
1628 + if (!incoming.length) return;
1629 + this._desktopUrlIntentQueue.push(...incoming);
1630 + if (this._desktopUrlIntentBusy) return;
1631 +
1632 + this._desktopUrlIntentBusy = true;
1633 + try {
1634 + while (this._desktopUrlIntentQueue.length) {
1635 + const intent = this._desktopUrlIntentQueue.shift();
1636 + await this.openDesktopUrlIntent(intent);
1637 + }
1638 + } finally {
1639 + this._desktopUrlIntentBusy = false;
1640 + }
1641 + },
1642 +
1643 + async openDesktopUrlIntent(intent = {}) {
1644 + const url = String(intent?.url || "").trim();
1645 + const destination = this.browserDestinationForDesktopUrl();
1646 + if (destination === "canvas") {
1647 + await this.openBrowserCanvasForDesktopUrl(url);
1648 + } else {
1649 + await this.openBrowserModalForDesktopUrl(url);
1650 + }
1651 + this.setMessage(url ? "Opened link in Browser" : "Opened Browser");
1652 + },
1653 +
1654 + browserDestinationForDesktopUrl() {
1655 + if (this.isDesktopInModal()) return "canvas";
1656 + return "modal";
1657 + },
1658 +
1659 + isDesktopInModal() {
1660 + if (isModalPathOpen(OFFICE_MODAL_PATH)) return true;
1661 + const modalDesktop = Array.from(document.querySelectorAll(".office-panel"))
1662 + .some((panel) => panel.closest?.(".modal") && panel.querySelector?.("[data-office-desktop-frame]"));
1663 + if (modalDesktop) return true;
1664 + if (rightCanvasStore?.isOpen && rightCanvasStore.activeSurfaceId === "office") return false;
1665 + return this._mode === "modal";
1666 + },
1667 +
1668 + async openBrowserCanvasForDesktopUrl(url = "") {
1669 + if (rightCanvasStore?.isMobileMode) {
1670 + await this.openBrowserModalForDesktopUrl(url);
1671 + return;
1672 + }
1673 + const payload = { url, source: "desktop-url" };
1674 + let opened = false;
1675 + if (isModalPathOpen(BROWSER_MODAL_PATH)) {
1676 + opened = await rightCanvasStore.dockSurface?.("browser", {
1677 + ...payload,
1678 + modalPath: BROWSER_MODAL_PATH,
1679 + sourceModalPath: BROWSER_MODAL_PATH,
1680 + });
1681 + } else {
1682 + opened = await rightCanvasStore.open?.("browser", payload);
1683 + }
1684 + if (!opened) {
1685 + await this.openBrowserModalForDesktopUrl(url);
1686 + return;
1687 + }
1688 + if (browserStore?.openUrlIntent) {
1689 + await browserStore.openUrlIntent(url);
1690 + }
1691 + },
1692 +
1693 + async openBrowserModalForDesktopUrl(url = "") {
1694 + if (rightCanvasStore?.isOpen && rightCanvasStore.activeSurfaceId === "browser") {
1695 + await rightCanvasStore.openModalSurface?.("browser", { modalPath: BROWSER_MODAL_PATH });
1696 + } else {
1697 + const openModal = globalThis.ensureModalOpen || globalThis.openModal;
1698 + const modalPromise = openModal?.(BROWSER_MODAL_PATH);
1699 + if (modalPromise?.catch) {
1700 + modalPromise.catch((error) => console.error("Browser modal open failed", error));
1701 + }
1702 + }
1703 + const panel = await waitForElementByPredicate(() => browserPanelForMode("modal"));
1704 + if (panel && browserStore?.onOpen) {
1705 + await browserStore.onOpen(panel, { mode: "modal" });
1706 + }
1707 + if (browserStore?.openUrlIntent) {
1708 + await browserStore.openUrlIntent(url);
1709 + }
1710 + },
1711 +
1712 startDesktopMonitor() {
1713 this.stopDesktopMonitor();
1714 if (!this.hasOfficialOffice()) return;
@@ -1593,6 +1728,7 @@ const model = {
1728 });
1729 if (response?.ok === false) throw new Error(response.error || "Desktop session closed.");
1730 this._desktopHeartbeatMisses = 0;
1731 + await this.handleDesktopUrlIntents(response?.url_intents);
1732 if (response?.document) {
1733 const document = normalizeDocument(response.document);
1734 this.replaceActiveSession({
@@ -1673,12 +1809,14 @@ const model = {
1809
1810 setupFloatingModal(element = null) {
1811 const root = element || globalThis.document?.querySelector(".office-panel");
1812 + const modal = root?.closest?.(".modal");
1813 const inner = root?.closest?.(".modal-inner");
1814 const body = root?.closest?.(".modal-bd");
1815 const header = inner?.querySelector?.(".modal-header");
1816 if (!inner || !body || !header || inner.dataset.officeModalReady === "1") return;
1817
1818 inner.dataset.officeModalReady = "1";
1819 + modal?.classList?.add("modal-floating", "modal-no-backdrop");
1820 inner.classList.add("office-modal", "modal-no-backdrop");
1821 body.classList.add("office-modal-body");
1822 header.style.cursor = "move";
@@ -1927,6 +2065,7 @@ const model = {
2065 }
2066 this._floatingCleanup = () => {
2067 cleanup.splice(0).reverse().forEach((entry) => entry());
2068 + modal?.classList?.remove("modal-floating", "modal-no-backdrop");
2069 inner.classList.remove("is-dragging", "is-resizing", "is-focus-mode");
2070 this._desktopResizeSuspended = false;
2071 this._desktopResizePending = false;
webui/css/modals.css
+8
@@ -18,6 +18,14 @@ the old and the new system. */
18 display: block;
19 }
20
21 +.modal.modal-floating {
22 + pointer-events: none;
23 +}
24 +
25 +.modal.modal-floating .modal-inner {
26 + pointer-events: auto;
27 +}
28 +
29 .modal-inner {
30 display: flex;
31 flex-direction: column;