Reduce Office to document ownership

Keep _office focused on document artifacts, Markdown sessions, LibreOffice-compatible file actions, and document persistence. Route binary document editing through explicit Desktop requests instead of cold-opening the live Desktop surface from artifact results.

Alessandro committed May 7, 2026 at 00:15 UTC 0c08fa65f31c0bcd6be14c983dfe0c4498f4eb55
14 files changed +586 -2655
plugins/_office/api/office_session.py
+44 -19
@@ -1,7 +1,8 @@
1 from __future__ import annotations
2
3 from helpers.api import ApiHandler, Request
4 -from plugins._office.helpers import document_store, libreoffice, libreoffice_desktop, markdown_sessions
4 +from plugins._desktop.helpers import desktop_session
5 +from plugins._office.helpers import document_store, libreoffice, markdown_sessions
6
7
8 class OfficeSession(ApiHandler):
@@ -14,6 +15,7 @@ class OfficeSession(ApiHandler):
15 if action == "home":
16 return {"ok": True, "path": document_store.default_open_path(context_id)}
17 if action == "desktop":
18 + # Compatibility only. New Desktop callers use /plugins/_desktop/desktop_session.
19 return self._desktop()
20 if action == "close":
21 closed = document_store.close_session(
@@ -58,30 +60,47 @@ class OfficeSession(ApiHandler):
60 if action == "renamed":
61 return self._renamed(input, context_id)
62 if action == "desktop_save":
63 + # Compatibility only. New Desktop callers use /plugins/_desktop/desktop_session.
64 return self._desktop_save(input)
65 if action == "desktop_sync":
66 + # Compatibility only. New Desktop callers use /plugins/_desktop/desktop_session.
67 return self._desktop_sync(input)
68 if action == "desktop_state":
69 + # Compatibility only. New Desktop callers use /plugins/_desktop/desktop_session.
70 return self._desktop_state(input)
71 if action == "desktop_shutdown":
72 + # Compatibility only. New Desktop callers use /plugins/_desktop/desktop_session.
73 return self._desktop_shutdown(input)
74 return {"ok": False, "error": f"Unsupported office session action: {action}"}
75
76 async def _open_document(self, doc: dict, input: dict, request: Request) -> dict:
77 mode = "edit" if str(input.get("mode") or "edit").lower() == "edit" else "view"
72 - store_session = document_store.create_session(
73 - doc["file_id"],
74 - user_id=str(input.get("user_id") or "agent-zero-user"),
75 - permission="write" if mode == "edit" else "read",
76 - origin=self._origin(request),
77 - )
78 - if str(doc.get("extension") or "").lower() in libreoffice_desktop.OFFICIAL_EXTENSIONS:
79 - desktop = libreoffice_desktop.get_manager().open(doc, refresh=input.get("refresh") is True)
78 + if str(doc.get("extension") or "").lower() in desktop_session.OFFICIAL_EXTENSIONS:
79 + if input.get("open_in_desktop") is not True:
80 + return {
81 + "ok": True,
82 + "requires_desktop": True,
83 + "file_id": doc["file_id"],
84 + "title": doc["basename"],
85 + "extension": doc["extension"],
86 + "path": doc["path"],
87 + "text": "",
88 + "document": _public_doc(doc),
89 + "version": document_store.item_version(doc),
90 + "mode": mode,
91 + }
92 + store_session = document_store.create_session(
93 + doc["file_id"],
94 + user_id=str(input.get("user_id") or "agent-zero-user"),
95 + permission="write" if mode == "edit" else "read",
96 + origin=self._origin(request),
97 + )
98 + desktop = desktop_session.get_manager().open(doc, refresh=input.get("refresh") is True)
99 if not desktop.get("available"):
100 document_store.close_session(session_id=store_session["session_id"])
101 return {
102 "ok": False,
84 - "error": desktop.get("error") or desktop.get("reason") or "Official LibreOffice desktop session is unavailable.",
103 + "error": desktop.get("error") or desktop.get("reason") or "Desktop session is unavailable.",
104 "desktop": desktop,
105 "libreoffice": libreoffice.collect_status(),
106 }
@@ -100,6 +119,12 @@ class OfficeSession(ApiHandler):
119 "store_session_id": store_session["session_id"],
120 "mode": mode,
121 }
122 + store_session = document_store.create_session(
123 + doc["file_id"],
124 + user_id=str(input.get("user_id") or "agent-zero-user"),
125 + permission="write" if mode == "edit" else "read",
126 + origin=self._origin(request),
127 + )
128 try:
129 editor = markdown_sessions.get_manager().open(doc, sid="")
130 except ValueError as exc:
@@ -135,8 +160,8 @@ class OfficeSession(ApiHandler):
160 except Exception as exc:
161 return {"ok": False, "error": str(exc)}
162 desktop = None
138 - if str(updated.get("extension") or "").lower() in libreoffice_desktop.OFFICIAL_EXTENSIONS:
139 - desktop = libreoffice_desktop.get_manager().retarget_document(file_id, updated)
163 + if str(updated.get("extension") or "").lower() in desktop_session.OFFICIAL_EXTENSIONS:
164 + desktop = desktop_session.get_manager().retarget_document(file_id, updated)
165 return {
166 "ok": True,
167 "document": _public_doc(updated),
@@ -146,7 +171,7 @@ class OfficeSession(ApiHandler):
171 }
172
173 def _desktop(self) -> dict:
149 - desktop = libreoffice_desktop.get_manager().ensure_system_desktop()
174 + desktop = desktop_session.get_manager().ensure_system_desktop()
175 if not desktop.get("available"):
176 return {
177 "ok": False,
@@ -155,7 +180,7 @@ class OfficeSession(ApiHandler):
180 "libreoffice": libreoffice.collect_status(),
181 }
182 document = {
158 - "file_id": libreoffice_desktop.SYSTEM_FILE_ID,
183 + "file_id": desktop_session.SYSTEM_FILE_ID,
184 "path": desktop["path"],
185 "basename": desktop["title"],
186 "title": desktop["title"],
@@ -167,7 +192,7 @@ class OfficeSession(ApiHandler):
192 "ok": True,
193 "session_id": desktop["session_id"],
194 "desktop_session_id": desktop["session_id"],
170 - "file_id": libreoffice_desktop.SYSTEM_FILE_ID,
195 + "file_id": desktop_session.SYSTEM_FILE_ID,
196 "title": desktop["title"],
197 "extension": "desktop",
198 "path": desktop["path"],
@@ -183,24 +208,24 @@ class OfficeSession(ApiHandler):
208 session_id = str(input.get("desktop_session_id") or input.get("session_id") or "").strip()
209 if not session_id:
210 return {"ok": False, "error": "desktop_session_id is required."}
186 - return libreoffice_desktop.get_manager().save(
211 + return desktop_session.get_manager().save(
212 session_id,
213 file_id=str(input.get("file_id") or ""),
214 )
215
216 def _desktop_sync(self, input: dict) -> dict:
192 - return libreoffice_desktop.get_manager().sync(
217 + return desktop_session.get_manager().sync(
218 session_id=str(input.get("desktop_session_id") or input.get("session_id") or ""),
219 file_id=str(input.get("file_id") or ""),
220 )
221
222 def _desktop_state(self, input: dict) -> dict:
223 include_screenshot = bool(input.get("include_screenshot") is True)
199 - return libreoffice_desktop.get_manager().state(include_screenshot=include_screenshot)
224 + return desktop_session.get_manager().state(include_screenshot=include_screenshot)
225
226 def _desktop_shutdown(self, input: dict) -> dict:
227 save_first = input.get("save_first") is not False
203 - return libreoffice_desktop.get_manager().shutdown_system_desktop(
228 + return desktop_session.get_manager().shutdown_system_desktop(
229 save_first=save_first,
230 source=str(input.get("source") or "api"),
231 )
plugins/_office/extensions/webui/lib/document-actions.js
+34 -5
@@ -2,6 +2,9 @@ import {
2 createActionButton,
3 copyToClipboard,
4 } from "/components/messages/action-buttons/simple-action-buttons.js";
5 +import { ensureModalOpen } from "/js/modals.js";
6 +import { open as openSurface } from "/js/surfaces.js";
7 +import { store as officeStore } from "/plugins/_office/webui/office-store.js";
8
9 function basename(path = "") {
10 const value = String(path || "").split("?")[0].split("#")[0];
@@ -33,10 +36,8 @@ export function documentFromLog(args = {}, result = {}) {
36 };
37 }
38
36 -export async function openOfficeCanvas(kvps = {}) {
37 - const canvas = globalThis.Alpine?.store?.("rightCanvas")
38 - || (await import("/components/canvas/right-canvas-store.js")).store;
39 - await canvas?.open?.("office", {
39 +export async function openDocumentInDesktop(kvps = {}) {
40 + await openSurface("desktop", {
41 path: kvps.path || "",
42 file_id: kvps.file_id || "",
43 refresh: true,
@@ -44,6 +45,33 @@ export async function openOfficeCanvas(kvps = {}) {
45 });
46 }
47
48 +export async function openDocumentArtifact(kvps = {}) {
49 + if (usesDesktop(kvps)) {
50 + await openDocumentInDesktop(kvps);
51 + return;
52 + }
53 + await ensureModalOpen("/plugins/_office/webui/main.html");
54 + await officeStore.openSession?.({
55 + path: kvps.path || "",
56 + file_id: kvps.file_id || "",
57 + refresh: true,
58 + source: "message-action",
59 + });
60 +}
61 +
62 +function usesDesktop(doc = {}) {
63 + const format = String(doc.format || doc.extension || "").toLowerCase();
64 + return ["odt", "ods", "odp", "docx", "xlsx", "pptx"].includes(format);
65 +}
66 +
67 +function desktopActionLabel(doc = {}) {
68 + const format = String(doc.format || doc.extension || "").toLowerCase();
69 + if (["odt", "docx"].includes(format)) return "Edit in Writer";
70 + if (["ods", "xlsx"].includes(format)) return "Edit in Calc";
71 + if (["odp", "pptx"].includes(format)) return "Edit in Impress";
72 + return "Open Document";
73 +}
74 +
75 export function downloadDocument(doc = {}) {
76 const path = String(doc.path || "");
77 if (!path) return;
@@ -59,7 +87,8 @@ export function buildDocumentFileActionButtons(document = {}) {
87 const hasTarget = Boolean(document?.path || document?.file_id);
88 const buttons = [];
89 if (hasTarget) {
62 - buttons.push(createActionButton("dock_to_right", "Open in canvas", () => openOfficeCanvas(document)));
90 + const icon = usesDesktop(document) ? "desktop_windows" : "article";
91 + buttons.push(createActionButton(icon, desktopActionLabel(document), () => openDocumentArtifact(document)));
92 }
93 if (document?.path) {
94 buttons.push(
plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js
+65 -38
@@ -1,14 +1,18 @@
1 +import { store as officeStore } from "/plugins/_office/webui/office-store.js";
2 +import { ensureModalOpen } from "/js/modals.js";
3 +import { open as openSurface } from "/js/surfaces.js";
4 +
5 const SYNC_WINDOW_MS = 10 * 60 * 1000;
2 -const DESKTOP_OFFICE_FORMATS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx"]);
6 +const DESKTOP_DOCUMENT_EXTENSIONS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx"]);
7 const syncedDocumentResults = new Set();
8
5 -export default async function syncDocumentResultsIntoOpenCanvas(context) {
9 +export default async function syncDocumentResultsIntoOpenOfficeModal(context) {
10 if (!context?.results?.length || context.historyEmpty) return;
11
12 for (const { args } of context.results) {
13 const payload = getDocumentPayload(args);
14 if (getToolName(payload) !== "document_artifact") continue;
11 - if (!shouldSyncOpenOfficeCanvas(args, payload)) continue;
15 + if (!shouldSyncOpenOfficeModal(args, payload)) continue;
16
17 const document = payload.document && typeof payload.document === "object" ? payload.document : {};
18 const path = payload.path || document.path || "";
@@ -25,14 +29,16 @@ export default async function syncDocumentResultsIntoOpenCanvas(context) {
29 if (syncedDocumentResults.has(key)) continue;
30 syncedDocumentResults.add(key);
31
28 - if (!isOfficeCanvasOrModalOpen() && shouldColdOpenOfficeCanvas(payload, document)) {
29 - await openOfficeCanvasFromResult({ path, file_id: fileId });
32 + if (shouldOpenDocumentUiFromResult(payload, document)) {
33 + globalThis.setTimeout(() => {
34 + void openDocumentUiFromResult({ path, file_id: fileId }, payload, document);
35 + }, 0);
36 continue;
37 }
38
39 globalThis.setTimeout(async () => {
34 - if (!isOfficeCanvasOrModalOpen()) return;
35 - const office = globalThis.Alpine?.store?.("office");
40 + if (!isOfficeModalOpen()) return;
41 + const office = officeStore;
42 if (!office || isDirtySameDocument(office, { path, file_id: fileId })) return;
43 await office.openSession?.({
44 path,
@@ -63,8 +69,14 @@ function pickPayloadFields(args = {}) {
69 "tool_name",
70 "action",
71 "canvas_surface",
72 + "extension",
73 "file_id",
74 "format",
75 + "open_canvas",
76 + "open_document",
77 + "open_desktop",
78 + "open_in_canvas",
79 + "open_in_desktop",
80 "path",
81 "version",
82 "last_modified",
@@ -78,54 +90,62 @@ function getToolName(payload = {}) {
90 return String(payload._tool_name || payload.tool_name || "").trim();
91 }
92
81 -function shouldSyncOpenOfficeCanvas(args = {}, payload = {}) {
93 +function shouldSyncOpenOfficeModal(args = {}, payload = {}) {
94 if (!isFresh(args.timestamp, payload.last_modified || payload.document?.last_modified)) return false;
95 const action = String(payload.action || "").trim().toLowerCase().replace("-", "_");
96 return ["create", "open", "edit", "restore_version"].includes(action);
97 }
98
87 -function shouldColdOpenOfficeCanvas(payload = {}, document = {}) {
88 - const action = String(payload.action || "").trim().toLowerCase().replace("-", "_");
89 - if (!["create", "open"].includes(action)) return false;
90 - return DESKTOP_OFFICE_FORMATS.has(documentFormat(payload, document));
99 +function shouldOpenDocumentUiFromResult(payload = {}, document = {}) {
100 + if (!isExplicitDocumentUiRequest(payload)) return false;
101 + return Boolean(documentExtension(payload, document));
102 }
103
93 -async function openOfficeCanvasFromResult(document = {}) {
94 - const canvas = globalThis.Alpine?.store?.("rightCanvas")
95 - || (await import("/components/canvas/right-canvas-store.js")).store;
96 - await canvas?.open?.("office", {
97 - path: document.path || "",
98 - file_id: document.file_id || "",
104 +function isExplicitDocumentUiRequest(payload = {}) {
105 + const action = String(payload.action || "").trim().toLowerCase().replace("-", "_");
106 + return action === "open"
107 + || truthy(payload.open_in_canvas)
108 + || truthy(payload.open_canvas)
109 + || truthy(payload.open_document)
110 + || truthy(payload.open_in_desktop)
111 + || truthy(payload.open_desktop);
112 +}
113 +
114 +async function openDocumentUiFromResult(target = {}, payload = {}, document = {}) {
115 + if (isDesktopDocument(payload, document)) {
116 + await openSurface("desktop", {
117 + path: target.path || "",
118 + file_id: target.file_id || "",
119 + refresh: true,
120 + source: "tool-result-open",
121 + });
122 + return;
123 + }
124 +
125 + await ensureModalOpen("/plugins/_office/webui/main.html");
126 + await officeStore.openSession?.({
127 + path: target.path || "",
128 + file_id: target.file_id || "",
129 refresh: true,
100 - source: "tool-result-sync",
130 + source: "tool-result-open",
131 });
132 }
133
104 -function documentFormat(payload = {}, document = {}) {
134 +function isDesktopDocument(payload = {}, document = {}) {
135 + return DESKTOP_DOCUMENT_EXTENSIONS.has(documentExtension(payload, document));
136 +}
137 +
138 +function documentExtension(payload = {}, document = {}) {
139 return String(
140 payload.format
141 || payload.extension
142 || document.extension
109 - || extensionOf(payload.path || document.path || ""),
110 - ).trim().toLowerCase().replace(/^\./, "");
143 + || document.format
144 + || "",
145 + ).toLowerCase();
146 }
147
113 -function extensionOf(path = "") {
114 - const name = String(path || "").split("?")[0].split("#")[0].split("/").filter(Boolean).pop() || "";
115 - const index = name.lastIndexOf(".");
116 - return index >= 0 ? name.slice(index + 1) : "";
117 -}
118 -
119 -function isOfficeCanvasAlreadyOpen() {
120 - const canvas = globalThis.Alpine?.store?.("rightCanvas");
121 - return Boolean(canvas?.isOpen && canvas?.activeSurfaceId === "office");
122 -}
123 -
124 -function isOfficeCanvasOrModalOpen() {
125 - return Boolean(isOfficeCanvasAlreadyOpen() || isOfficeModalAlreadyOpen());
126 -}
127 -
128 -function isOfficeModalAlreadyOpen() {
148 +function isOfficeModalOpen() {
149 return Boolean(
150 globalThis.isModalOpen?.("/plugins/_office/webui/main.html")
151 || globalThis.isModalOpen?.("plugins/_office/webui/main.html")
@@ -143,6 +163,13 @@ function isDirtySameDocument(office, document = {}) {
163 );
164 }
165
166 +function truthy(value) {
167 + if (value === true) return true;
168 + if (value === false || value == null) return false;
169 + if (typeof value === "number") return value !== 0;
170 + return ["1", "true", "yes", "y", "on"].includes(String(value).trim().toLowerCase());
171 +}
172 +
173 function isFresh(...timestamps) {
174 const now = Date.now();
175 for (const value of timestamps) {
plugins/_office/helpers/artifact_editor.py
+2 -2
@@ -141,9 +141,9 @@ def _refresh_open_editor_sessions(file_id: str) -> None:
141 # Direct artifact edits should never fail just because no canvas is open.
142 pass
143 try:
144 - from plugins._office.helpers import libreoffice_desktop
144 + from plugins._desktop.helpers import desktop_session
145
146 - libreoffice_desktop.get_manager().refresh_document(file_id)
146 + desktop_session.get_manager().refresh_document(file_id)
147 except Exception:
148 pass
149
plugins/_office/helpers/canvas_context.py
+3 -3
@@ -2,7 +2,7 @@ from __future__ import annotations
2
3 from typing import Any
4
5 -from plugins._office.helpers import desktop_state
5 +from plugins._desktop.helpers import desktop_state
6 from plugins._office.helpers import document_store
7
8
@@ -13,7 +13,7 @@ def build_context(max_items: int = 6) -> str:
13 return desktop_context
14
15 lines = [
16 - "These document artifacts have active canvas sessions. Content is omitted; load skill `office-artifacts` for edit workflow, then use document_artifact:read before content-sensitive edits.",
16 + "These document artifacts have active document sessions. Content is omitted; load skill `document-artifacts` for edit workflow, then use document_artifact:read before content-sensitive edits.",
17 ]
18 for doc in documents:
19 lines.append(format_document_line(doc))
@@ -46,5 +46,5 @@ def build_desktop_context() -> str:
46 return (
47 "[DESKTOP STATE]\n"
48 f"- unavailable={exc}\n"
49 - "- next=Open the Desktop canvas manually, then run plugins/_office/skills/linux-desktop/scripts/desktopctl.sh observe --json."
49 + "- next=Open the Desktop surface manually, then run plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh observe --json."
50 )
plugins/_office/helpers/document_store.py
+1 -1
@@ -40,7 +40,7 @@ ODF_MIMETYPES = {
40 "odp": "application/vnd.oasis.opendocument.presentation",
41 }
42
43 -STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "documents"))
43 +STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME, "documents"))
44 DB_PATH = STATE_DIR / "documents.sqlite3"
45 BACKUP_DIR = STATE_DIR / "backups"
46 WORKDIR = Path(files.get_abs_path("usr", "workdir"))
plugins/_office/helpers/libreoffice.py
+2 -2
@@ -36,9 +36,9 @@ def collect_status() -> dict[str, Any]:
36 "message": "LibreOffice is available." if soffice else "LibreOffice is not installed in this runtime.",
37 }
38 try:
39 - from plugins._office.helpers import libreoffice_desktop
39 + from plugins._desktop.helpers import desktop_session
40
41 - status["desktop"] = libreoffice_desktop.collect_desktop_status()
41 + status["desktop"] = desktop_session.collect_desktop_status()
42 except Exception as exc:
43 status["desktop"] = {"ok": False, "healthy": False, "error": str(exc)}
44 return status
plugins/_office/plugin.yaml
+1 -1
@@ -1,6 +1,6 @@
1 name: _office
2 title: LibreOffice
3 -description: Markdown writing and ODF-first LibreOffice document artifacts in the right canvas.
3 +description: Markdown writing and ODF-first LibreOffice document artifacts.
4 version: "0.1"
5 settings_sections:
6 - developer
plugins/_office/prompts/agent.system.tool.document_artifact.md
+7 -5
@@ -1,15 +1,17 @@
1 ### document_artifact
2 -create/open/read/edit reusable document artifacts in the Agent Zero canvas
2 +create/open/read/edit reusable document artifacts in Agent Zero
3 formats: md odt ods odp docx xlsx pptx
4 default format: md
5 methods: create open read edit inspect export version_history restore_version status
6 common args: method action kind title format content path file_id
7 +optional UI intent args: open_in_canvas open_in_desktop
8 `method` is accepted as an alias for action when the tool_name has no suffix
8 -tool results save or update artifacts only; they do not open the canvas automatically
9 -created/updated artifacts are shown with explicit Download and Open in canvas message actions
10 -ODF is first-class for LibreOffice: use ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress unless the user explicitly requests Microsoft compatibility
9 +create/read/edit results save or update artifacts only; they do not open a surface automatically unless the user explicitly asks to open the document UI
10 +use action `open`, `open_in_canvas: true`, or `open_in_desktop: true` only when the user explicitly asks to open the document/editor/Desktop
11 +created/updated artifacts are shown with explicit Download, Open Document, or Desktop edit message actions
12 +ODF is first-class for LibreOffice: use ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress unless the user explicitly requests OOXML compatibility
13 DOCX/XLSX/PPTX are compatibility formats, not defaults
14 XLSX charts: use edit operation `create_chart` with `chart` object instead of code execution for embedded spreadsheet charts when an embedded chart is required
15 chart types: line bar column pie area scatter stock ohlc candlestick
16 ODS/XLSX create/edit tabular content: CSV, TSV, Markdown tables, or rows arrays become real spreadsheet cells
15 -for nontrivial document artifact work, load skill `office-artifacts` or the specific Markdown/Writer/Calc/Impress skill first
17 +for nontrivial document artifact work, load skill `document-artifacts` or the specific Markdown/Writer/Calc/Impress skill first
plugins/_office/tools/document_artifact.py
+144 -16
@@ -28,10 +28,22 @@ class DocumentArtifact(Tool):
28 chart: Any = None,
29 slides: Any = None,
30 max_chars: int | str = 12000,
31 + open_in_canvas: bool = False,
32 + open_in_desktop: bool = False,
33 method: str = "",
34 **kwargs: Any,
35 ) -> Response:
36 action = str(action or method or self.method or "status").strip().lower().replace("-", "_")
37 + open_in_canvas = _truthy(
38 + open_in_canvas
39 + or kwargs.get("open_canvas")
40 + or kwargs.get("open_document")
41 + )
42 + open_in_desktop = _truthy(
43 + open_in_desktop
44 + or kwargs.get("open_desktop")
45 + or kwargs.get("desktop")
46 + )
47 try:
48 if action == "create":
49 doc = document_store.create_document(
@@ -56,10 +68,22 @@ class DocumentArtifact(Tool):
68 message=f"document_artifact create failed: {validation.get('error')}",
69 break_loop=False,
70 )
59 - return self._document_response("Created document artifact.", doc, action=action)
71 + return self._document_response(
72 + "Created document artifact.",
73 + doc,
74 + action=action,
75 + open_in_canvas=open_in_canvas,
76 + open_in_desktop=open_in_desktop,
77 + )
78 if action == "open":
79 doc = self._document_from_input(file_id=file_id, path=path)
62 - return self._document_response("Opened document artifact.", doc, action=action)
80 + return self._document_response(
81 + "Opened document artifact.",
82 + doc,
83 + action=action,
84 + open_in_canvas=open_in_canvas,
85 + open_in_desktop=open_in_desktop,
86 + )
87 if action in {"read", "extract"}:
88 doc = self._document_from_input(file_id=file_id, path=path)
89 payload = {
@@ -68,7 +92,13 @@ class DocumentArtifact(Tool):
92 "document": self._public_doc(doc),
93 "content": artifact_editor.read_artifact(doc, max_chars=int(max_chars or 12000)),
94 }
71 - return self._json_response(payload, doc=doc, action="read")
95 + return self._json_response(
96 + payload,
97 + doc=doc,
98 + action="read",
99 + open_in_canvas=open_in_canvas,
100 + open_in_desktop=open_in_desktop,
101 + )
102 if action in {"edit", "update", "patch"}:
103 doc = self._document_from_input(file_id=file_id, path=path)
104 updated_doc, payload = artifact_editor.edit_artifact(
@@ -85,20 +115,44 @@ class DocumentArtifact(Tool):
115 **kwargs,
116 )
117 payload["document"] = self._public_doc(updated_doc)
88 - return self._json_response(payload, doc=updated_doc, action="edit")
118 + return self._json_response(
119 + payload,
120 + doc=updated_doc,
121 + action="edit",
122 + open_in_canvas=open_in_canvas,
123 + open_in_desktop=open_in_desktop,
124 + )
125 if action == "inspect":
126 doc = self._document_from_input(file_id=file_id, path=path)
91 - return self._json_response({"ok": True, "action": action, "document": self._public_doc(doc)}, doc=doc, action=action)
127 + return self._json_response(
128 + {"ok": True, "action": action, "document": self._public_doc(doc)},
129 + doc=doc,
130 + action=action,
131 + open_in_canvas=open_in_canvas,
132 + open_in_desktop=open_in_desktop,
133 + )
134 if action == "version_history":
135 doc = self._document_from_input(file_id=file_id, path=path)
136 versions = document_store.version_history(doc["file_id"])
95 - return self._json_response({"ok": True, "action": action, "versions": versions}, doc=doc, action=action)
137 + return self._json_response(
138 + {"ok": True, "action": action, "versions": versions},
139 + doc=doc,
140 + action=action,
141 + open_in_canvas=open_in_canvas,
142 + open_in_desktop=open_in_desktop,
143 + )
144 if action == "restore_version":
145 if version_id is None or str(version_id).strip() == "":
146 return Response(message="version_id is required for restore_version.", break_loop=False)
147 doc = self._document_from_input(file_id=file_id, path=path)
148 restored = document_store.restore_version(doc["file_id"], int(version_id))
101 - return self._document_response("Restored document artifact version.", restored, action=action)
149 + return self._document_response(
150 + "Restored document artifact version.",
151 + restored,
152 + action=action,
153 + open_in_canvas=open_in_canvas,
154 + open_in_desktop=open_in_desktop,
155 + )
156 if action == "export":
157 doc = self._document_from_input(file_id=file_id, path=path)
158 target_format = str(kwargs.get("target_format") or kwargs.get("export_format") or "").lower().lstrip(".")
@@ -111,13 +165,30 @@ class DocumentArtifact(Tool):
165 "path": document_store.display_path(result["path"]),
166 "document": self._public_doc(doc),
167 }
114 - return self._json_response(payload, doc=doc, action=action)
168 + return self._json_response(
169 + payload,
170 + doc=doc,
171 + action=action,
172 + open_in_canvas=open_in_canvas,
173 + open_in_desktop=open_in_desktop,
174 + )
175 return Response(
176 message=f"document_artifact export failed: {result.get('error')}",
177 break_loop=False,
118 - additional=self._additional(doc, action=action),
178 + additional=self._additional(
179 + doc,
180 + action=action,
181 + open_in_canvas=open_in_canvas,
182 + open_in_desktop=open_in_desktop,
183 + ),
184 )
120 - return self._document_response("Document artifact export path is ready.", doc, action=action)
185 + return self._document_response(
186 + "Document artifact export path is ready.",
187 + doc,
188 + action=action,
189 + open_in_canvas=open_in_canvas,
190 + open_in_desktop=open_in_desktop,
191 + )
192 if action == "status":
193 return self._json_response({"ok": True, "action": action, "status": libreoffice.collect_status()}, action=action)
194 return Response(message=f"Unknown document_artifact action: {action}", break_loop=False)
@@ -143,28 +214,75 @@ class DocumentArtifact(Tool):
214 def _context_id(self) -> str:
215 return self.agent.context.id if self.agent and self.agent.context else ""
216
146 - def _document_response(self, message: str, doc: dict[str, Any], action: str = "") -> Response:
217 + def _document_response(
218 + self,
219 + message: str,
220 + doc: dict[str, Any],
221 + action: str = "",
222 + *,
223 + open_in_canvas: bool = False,
224 + open_in_desktop: bool = False,
225 + ) -> Response:
226 payload = {"ok": True, "action": action, "message": message, "document": self._public_doc(doc)}
227 return Response(
228 message=json.dumps(payload, indent=2, ensure_ascii=False),
229 break_loop=False,
151 - additional=self._additional(doc, action=action),
230 + additional=self._additional(
231 + doc,
232 + action=action,
233 + open_in_canvas=open_in_canvas,
234 + open_in_desktop=open_in_desktop,
235 + ),
236 )
237
154 - def _json_response(self, payload: dict[str, Any], doc: dict[str, Any] | None = None, action: str = "") -> Response:
238 + def _json_response(
239 + self,
240 + payload: dict[str, Any],
241 + doc: dict[str, Any] | None = None,
242 + action: str = "",
243 + *,
244 + open_in_canvas: bool = False,
245 + open_in_desktop: bool = False,
246 + ) -> Response:
247 return Response(
248 message=json.dumps(payload, indent=2, ensure_ascii=False, default=str),
249 break_loop=False,
158 - additional=self._additional(doc, action=action) if doc else {"_tool_name": self.name, "canvas_surface": "office", "action": action},
250 + additional=self._additional(
251 + doc,
252 + action=action,
253 + open_in_canvas=open_in_canvas,
254 + open_in_desktop=open_in_desktop,
255 + ) if doc else {
256 + "_tool_name": self.name,
257 + "canvas_surface": "office",
258 + "action": action,
259 + "open_in_canvas": bool(open_in_canvas),
260 + "open_in_desktop": bool(open_in_desktop),
261 + },
262 )
263
161 - def _additional(self, doc: dict[str, Any] | None, action: str = "") -> dict[str, Any]:
264 + def _additional(
265 + self,
266 + doc: dict[str, Any] | None,
267 + action: str = "",
268 + *,
269 + open_in_canvas: bool = False,
270 + open_in_desktop: bool = False,
271 + ) -> dict[str, Any]:
272 if not doc:
163 - return {"_tool_name": self.name, "canvas_surface": "office", "action": action}
273 + return {
274 + "_tool_name": self.name,
275 + "canvas_surface": "office",
276 + "action": action,
277 + "open_in_canvas": bool(open_in_canvas),
278 + "open_in_desktop": bool(open_in_desktop),
279 + }
280 return {
281 "_tool_name": self.name,
282 "canvas_surface": "office",
283 "action": action,
284 + "open_in_canvas": bool(open_in_canvas),
285 + "open_in_desktop": bool(open_in_desktop),
286 "file_id": doc["file_id"],
287 "title": doc["basename"],
288 "format": doc["extension"],
@@ -183,3 +301,13 @@ class DocumentArtifact(Tool):
301 "last_modified": doc["last_modified"],
302 "exists": Path(doc["path"]).exists(),
303 }
304 +
305 +
306 +def _truthy(value: Any) -> bool:
307 + if isinstance(value, bool):
308 + return value
309 + if value is None:
310 + return False
311 + if isinstance(value, (int, float)):
312 + return value != 0
313 + return str(value).strip().lower() in {"1", "true", "yes", "y", "on"}
plugins/_office/webui/main.html
+2 -8
@@ -1,12 +1,6 @@
1 -<html
2 - class="office-modal modal-no-backdrop"
3 - data-canvas-surface="office"
4 - data-canvas-modal-path="/plugins/_office/webui/main.html"
5 - data-canvas-dock-title="Open Desktop in canvas"
6 - data-canvas-dock-icon="dock_to_right"
7 ->
1 +<html class="office-modal">
2 <head>
9 - <title>Desktop</title>
3 + <title>Documents</title>
4 <script type="module">
5 import { store } from "/plugins/_office/webui/office-store.js";
6 </script>
plugins/_office/webui/office-panel.html
+210 -595
@@ -5,123 +5,130 @@
5 </script>
6 </head>
7 <body>
8 - <div class="office-panel" x-data x-create="$store.office.onMount($el, xAttrs($el) || {})" x-destroy="$store.office.cleanup()">
8 + <div x-data>
9 <template x-if="$store.office">
10 - <div class="office-shell">
11 - <div class="office-document-header" x-show="$store.office.hasActiveFile()" style="display: none;">
12 - <div class="office-document-title" :title="$store.office.tabLabel($store.office.session)">
13 - <span class="material-symbols-outlined office-document-icon" aria-hidden="true" x-text="$store.office.tabIcon($store.office.session)"></span>
14 - <span class="office-document-name" x-text="$store.office.tabTitle($store.office.session)"></span>
15 - <span class="office-document-dirty" x-show="$store.office.dirty" aria-hidden="true">*</span>
16 - </div>
10 + <div class="office-panel" x-create="$store.office.onMount($el, xAttrs($el) || {})" x-destroy="$store.office.cleanup()">
11 + <div class="office-shell">
12 + <div class="office-document-header" x-show="$store.office.hasActiveFile()" style="display: none;">
13 + <div class="office-document-title" :title="$store.office.tabLabel($store.office.session)">
14 + <span class="material-symbols-outlined office-document-icon" aria-hidden="true" x-text="$store.office.tabIcon($store.office.session)"></span>
15 + <span class="office-document-name" x-text="$store.office.tabTitle($store.office.session)"></span>
16 + <span class="office-document-dirty" x-show="$store.office.dirty" aria-hidden="true">*</span>
17 + </div>
18
18 - <button
19 - type="button"
20 - class="office-icon-button office-document-save-button"
21 - title="Save"
22 - aria-label="Save"
23 - :class="{ 'is-primary': $store.office.dirty }"
24 - :disabled="$store.office.saving"
25 - @click="$store.office.save()"
26 - >
27 - <span class="material-symbols-outlined" :class="{ spinning: $store.office.saving }" x-text="$store.office.saving ? 'progress_activity' : 'save'"></span>
28 - </button>
29 -
30 - <div class="office-file-actions" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false">
19 <button
20 type="button"
33 - class="office-icon-button office-file-menu-button"
34 - title="File actions"
35 - aria-label="File actions"
36 - aria-haspopup="menu"
37 - :aria-expanded="open.toString()"
21 + class="office-icon-button office-document-save-button"
22 + title="Save"
23 + aria-label="Save"
24 + :class="{ 'is-primary': $store.office.dirty }"
25 :disabled="$store.office.saving"
39 - @click.stop="open = !open"
26 + @click="$store.office.save()"
27 >
41 - <span class="material-symbols-outlined">more_vert</span>
28 + <span class="material-symbols-outlined" :class="{ spinning: $store.office.saving }" x-text="$store.office.saving ? 'progress_activity' : 'save'"></span>
29 </button>
43 - <div class="office-new-menu office-file-menu" role="menu" x-show="open" @click.stop>
44 - <button type="button" class="office-new-menu-item" role="menuitem" :disabled="$store.office.saving" @click="open = false; $store.office.renameActiveFile()">
45 - <span class="material-symbols-outlined" aria-hidden="true">edit</span>
46 - <span>Rename</span>
47 - </button>
48 - <button type="button" class="office-new-menu-item" role="menuitem" :disabled="$store.office.loading" @click="open = false; $store.office.closeActiveFile()">
49 - <span class="material-symbols-outlined" aria-hidden="true">close</span>
50 - <span>Close File</span>
30 +
31 + <div class="office-file-actions" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false">
32 + <button
33 + type="button"
34 + class="office-icon-button office-file-menu-button"
35 + title="File actions"
36 + aria-label="File actions"
37 + aria-haspopup="menu"
38 + :aria-expanded="open.toString()"
39 + :disabled="$store.office.saving"
40 + @click.stop="open = !open"
41 + >
42 + <span class="material-symbols-outlined">more_vert</span>
43 </button>
44 + <div class="office-new-menu office-file-menu" role="menu" x-show="open" @click.stop>
45 + <button type="button" class="office-new-menu-item" role="menuitem" :disabled="$store.office.saving" @click="open = false; $store.office.renameActiveFile()">
46 + <span class="material-symbols-outlined" aria-hidden="true">edit</span>
47 + <span>Rename</span>
48 + </button>
49 + <button type="button" class="office-new-menu-item" role="menuitem" :disabled="$store.office.loading" @click="open = false; $store.office.closeActiveFile()">
50 + <span class="material-symbols-outlined" aria-hidden="true">close</span>
51 + <span>Close File</span>
52 + </button>
53 + </div>
54 </div>
55 </div>
54 - </div>
56
56 - <div class="office-toolbar" x-show="$store.office.session && $store.office.isMarkdown()" style="display: none;">
57 - <div class="office-toolbar-row">
58 - <div class="office-tool-group office-editor-tools" x-show="$store.office.session && $store.office.isMarkdown()" style="display: none;">
59 - <button type="button" class="office-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.office.canUndo()" @click="$store.office.undo()">
60 - <span class="material-symbols-outlined">undo</span>
61 - </button>
62 - <button type="button" class="office-icon-button" title="Redo" aria-label="Redo" :disabled="!$store.office.canRedo()" @click="$store.office.redo()">
63 - <span class="material-symbols-outlined">redo</span>
64 - </button>
65 - <button type="button" class="office-icon-button" title="Bold" aria-label="Bold" @click="$store.office.format('bold')">
66 - <span class="material-symbols-outlined">format_bold</span>
67 - </button>
68 - <button type="button" class="office-icon-button" title="Italic" aria-label="Italic" @click="$store.office.format('italic')">
69 - <span class="material-symbols-outlined">format_italic</span>
70 - </button>
71 - <button type="button" class="office-icon-button" title="List" aria-label="List" @click="$store.office.format('list')">
72 - <span class="material-symbols-outlined">format_list_bulleted</span>
73 - </button>
74 - <button type="button" class="office-icon-button" title="Numbered list" aria-label="Numbered list" @click="$store.office.format('numbered')">
75 - <span class="material-symbols-outlined">format_list_numbered</span>
76 - </button>
77 - <button type="button" class="office-icon-button" title="Table" aria-label="Table" @click="$store.office.format('table')">
78 - <span class="material-symbols-outlined">table</span>
79 - </button>
57 + <div class="office-toolbar" x-show="$store.office.session && $store.office.isMarkdown()" style="display: none;">
58 + <div class="office-toolbar-row">
59 + <div class="office-tool-group office-editor-tools">
60 + <button type="button" class="office-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.office.canUndo()" @click="$store.office.undo()">
61 + <span class="material-symbols-outlined">undo</span>
62 + </button>
63 + <button type="button" class="office-icon-button" title="Redo" aria-label="Redo" :disabled="!$store.office.canRedo()" @click="$store.office.redo()">
64 + <span class="material-symbols-outlined">redo</span>
65 + </button>
66 + <button type="button" class="office-icon-button" title="Bold" aria-label="Bold" @click="$store.office.format('bold')">
67 + <span class="material-symbols-outlined">format_bold</span>
68 + </button>
69 + <button type="button" class="office-icon-button" title="Italic" aria-label="Italic" @click="$store.office.format('italic')">
70 + <span class="material-symbols-outlined">format_italic</span>
71 + </button>
72 + <button type="button" class="office-icon-button" title="List" aria-label="List" @click="$store.office.format('list')">
73 + <span class="material-symbols-outlined">format_list_bulleted</span>
74 + </button>
75 + <button type="button" class="office-icon-button" title="Numbered list" aria-label="Numbered list" @click="$store.office.format('numbered')">
76 + <span class="material-symbols-outlined">format_list_numbered</span>
77 + </button>
78 + <button type="button" class="office-icon-button" title="Table" aria-label="Table" @click="$store.office.format('table')">
79 + <span class="material-symbols-outlined">table</span>
80 + </button>
81 + </div>
82 + <span class="office-toolbar-spacer"></span>
83 </div>
81 -
82 - <span class="office-toolbar-spacer"></span>
84 </div>
84 - </div>
85
86 - <div class="office-state-line" x-show="$store.office.message || $store.office.error || $store.office.loading" style="display: none;">
87 - <span class="material-symbols-outlined" :class="{ spinning: $store.office.loading }" x-text="$store.office.loading ? 'progress_activity' : ($store.office.error ? 'error' : 'check_circle')"></span>
88 - <span x-text="$store.office.error || $store.office.message || 'Working'"></span>
89 - </div>
90 -
91 - <div class="office-body" :class="{ 'is-source': $store.office.isMarkdown() }">
92 - <div class="office-editor-wrap" x-show="$store.office.session" style="display: none;">
93 - <div class="office-editor-scroll" :class="{ 'is-desktop': $store.office.hasOfficialOffice(), 'is-source': $store.office.isMarkdown() }" @click.self="$store.office.focusEditor()">
94 - <template x-if="$store.office.hasOfficialOffice()">
95 - <div
96 - class="office-desktop-wrap"
97 - data-office-desktop-host
98 - x-init="$nextTick(() => $store.office.mountDesktopFrameHost($el))"
99 - >
100 - </div>
101 - </template>
102 -
103 - <textarea
104 - class="office-source-editor"
105 - data-office-source
106 - aria-label="Markdown source"
107 - x-show="$store.office.isMarkdown()"
108 - x-model="$store.office.editorText"
109 - @input="$store.office.onSourceInput()"
110 - @blur="$store.office.flushInput()"
111 - spellcheck="true"
112 - style="display: none;"
113 - ></textarea>
86 + <div class="office-state-line" x-show="$store.office.message || $store.office.error || $store.office.loading" style="display: none;">
87 + <span class="material-symbols-outlined" :class="{ spinning: $store.office.loading }" x-text="$store.office.loading ? 'progress_activity' : ($store.office.error ? 'error' : 'check_circle')"></span>
88 + <span x-text="$store.office.error || $store.office.message || 'Working'"></span>
89 + </div>
90
91 + <div class="office-body" :class="{ 'is-source': $store.office.isMarkdown() }">
92 + <div class="office-editor-wrap" x-show="$store.office.session" style="display: none;">
93 + <div class="office-editor-scroll is-source" @click.self="$store.office.focusEditor()">
94 + <textarea
95 + class="office-source-editor"
96 + data-office-source
97 + aria-label="Markdown source"
98 + x-show="$store.office.isMarkdown()"
99 + x-model="$store.office.editorText"
100 + @input="$store.office.onSourceInput()"
101 + @blur="$store.office.flushInput()"
102 + spellcheck="true"
103 + style="display: none;"
104 + ></textarea>
105 + </div>
106 </div>
116 - </div>
107
118 - <div class="office-desktop-empty" x-show="$store.office.shouldShowDesktopEmptyState()" style="display: none;">
119 - <span class="material-symbols-outlined" aria-hidden="true">power_settings_new</span>
120 - <span class="office-desktop-empty-title">Desktop is shut down</span>
121 - <button type="button" class="office-icon-button office-command-button" @click="$store.office.restartDesktopSession()">
122 - <span class="material-symbols-outlined" aria-hidden="true">restart_alt</span>
123 - <span class="office-button-label">Restart Desktop</span>
124 - </button>
108 + <div class="office-empty" x-show="!$store.office.session && !$store.office.loading" style="display: none;">
109 + <div class="office-empty-actions">
110 + <button type="button" class="office-icon-button office-command-button" @click="$store.office.runNewMenuAction('open')">
111 + <span class="material-symbols-outlined" aria-hidden="true">folder_open</span>
112 + <span class="office-button-label">Open</span>
113 + </button>
114 + <button type="button" class="office-icon-button office-command-button" @click="$store.office.runNewMenuAction('markdown')">
115 + <span class="material-symbols-outlined" aria-hidden="true">article</span>
116 + <span class="office-button-label">Markdown</span>
117 + </button>
118 + <button type="button" class="office-icon-button office-command-button" @click="$store.office.runNewMenuAction('writer')">
119 + <span class="material-symbols-outlined" aria-hidden="true">description</span>
120 + <span class="office-button-label">Writer</span>
121 + </button>
122 + <button type="button" class="office-icon-button office-command-button" @click="$store.office.runNewMenuAction('spreadsheet')">
123 + <span class="material-symbols-outlined" aria-hidden="true">table_chart</span>
124 + <span class="office-button-label">Calc</span>
125 + </button>
126 + <button type="button" class="office-icon-button office-command-button" @click="$store.office.runNewMenuAction('presentation')">
127 + <span class="material-symbols-outlined" aria-hidden="true">co_present</span>
128 + <span class="office-button-label">Impress</span>
129 + </button>
130 + </div>
131 + </div>
132 </div>
133 </div>
134 </div>
@@ -148,24 +155,13 @@
155
156 .modal-inner.office-modal {
157 box-sizing: border-box;
151 - width: min(1120px, calc(100vw - 32px));
152 - height: min(820px, calc(100vh - 32px));
153 - min-width: min(720px, calc(100vw - 16px));
154 - min-height: min(520px, calc(100vh - 16px));
158 + width: min(1040px, calc(100vw - 32px));
159 + height: min(760px, calc(100vh - 32px));
160 + min-width: min(640px, calc(100vw - 16px));
161 + min-height: min(460px, calc(100vh - 16px));
162 max-width: none;
163 max-height: none;
157 - resize: none;
164 overflow: hidden;
159 - will-change: width, height, left, top;
160 - }
161 -
162 - .modal-inner.office-modal.is-resizing,
163 - .modal-inner.office-modal.is-dragging {
164 - user-select: none;
165 - }
166 -
167 - .modal-inner.office-modal.is-focus-mode {
168 - border-radius: 6px;
165 }
166
167 .modal-inner.office-modal .modal-scroll {
@@ -178,592 +174,211 @@
174 }
175
176 .modal-inner.office-modal .modal-header {
181 - grid-template-columns: minmax(0, 1fr) repeat(5, auto);
182 - }
183 -
184 - .office-modal-input-shield {
185 - position: absolute;
186 - inset: 42px 0 0 0;
187 - z-index: 4;
188 - display: none;
189 - background: transparent;
190 - }
191 -
192 - .office-modal-resizer {
193 - position: absolute;
194 - z-index: 5;
195 - display: block;
196 - touch-action: none;
197 - }
198 -
199 - .office-modal-resizer.is-right {
200 - top: 42px;
201 - right: -4px;
202 - bottom: 12px;
203 - width: 10px;
204 - cursor: ew-resize;
205 - }
206 -
207 - .office-modal-resizer.is-bottom {
208 - right: 12px;
209 - bottom: -4px;
210 - left: 0;
211 - height: 10px;
212 - cursor: ns-resize;
213 - }
214 -
215 - .office-modal-resizer.is-corner {
216 - right: 0;
217 - bottom: 0;
218 - width: 22px;
219 - height: 22px;
220 - cursor: nwse-resize;
221 - }
222 -
223 - .office-modal-resizer.is-corner::after {
224 - content: "";
225 - position: absolute;
226 - right: 6px;
227 - bottom: 6px;
228 - width: 9px;
229 - height: 9px;
230 - border-right: 2px solid color-mix(in srgb, var(--color-text) 42%, transparent);
231 - border-bottom: 2px solid color-mix(in srgb, var(--color-text) 42%, transparent);
232 - border-radius: 1px;
233 - }
234 -
235 - .modal-inner.office-modal.is-focus-mode .office-modal-resizer {
236 - display: none;
237 - }
238 -
239 - .modal-inner.office-modal .modal-bd.office-modal-body,
240 - .modal-inner.office-modal .modal-bd.office-modal-body > x-component,
241 - .modal-inner.office-modal .modal-bd.office-modal-body > x-component > .office-panel {
242 - display: flex;
243 - flex: 1 1 auto;
244 - min-height: 0;
245 - height: 100%;
246 - padding: 0;
247 - }
248 -
249 - .office-toolbar {
250 - display: flex;
251 - flex-direction: row;
252 - align-items: stretch;
253 - flex-wrap: nowrap;
254 - min-height: 0;
255 - padding: 6px 10px;
256 - overflow: hidden;
257 - border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 20%);
258 - background: color-mix(in srgb, var(--color-background), var(--color-panel) 48%);
259 - }
260 -
261 - .office-toolbar-row {
262 - display: flex;
263 - align-items: center;
264 - flex: 0 0 auto;
265 - flex-wrap: nowrap;
266 - gap: 6px;
267 - width: 100%;
268 - min-width: 0;
269 - min-height: 32px;
270 - overflow-x: auto;
271 - overflow-y: hidden;
272 - scrollbar-width: thin;
177 + grid-template-columns: minmax(0, 1fr) auto auto;
178 }
179
275 - .office-tool-group {
180 + .office-document-header,
181 + .office-toolbar,
182 + .office-state-line {
183 display: flex;
184 align-items: center;
278 - flex-wrap: nowrap;
279 - flex: 0 0 auto;
280 - gap: 4px;
185 + gap: 8px;
186 + border-bottom: 1px solid var(--color-border);
187 + padding: 8px 10px;
188 min-width: 0;
189 }
190
284 - .office-editor-tools {
285 - gap: 3px;
286 - }
287 -
288 - .office-tool-actions {
289 - justify-content: flex-end;
290 - }
291 -
292 - .office-toolbar-spacer {
293 - flex: 1 1 16px;
294 - min-width: 8px;
295 - }
296 -
297 - .office-toolbar-divider {
298 - flex: 0 0 auto;
299 - width: 1px;
300 - height: 22px;
301 - margin-inline: 2px;
302 - background: color-mix(in srgb, var(--color-border), transparent 18%);
303 - }
304 -
305 - .office-document-header {
306 - display: flex;
307 - align-items: center;
308 - gap: 10px;
309 - min-height: 38px;
310 - padding: 5px 10px 5px 12px;
311 - border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 22%);
312 - background: color-mix(in srgb, var(--color-panel), var(--color-background) 30%);
313 - }
314 -
191 .office-document-title {
192 display: flex;
193 align-items: center;
318 - gap: 7px;
194 + gap: 8px;
195 min-width: 0;
196 flex: 1 1 auto;
321 - color: var(--color-text);
322 - }
323 -
324 - .office-document-icon {
325 - flex: 0 0 auto;
326 - font-size: 19px;
327 - line-height: 1;
197 + font-size: 14px;
198 + font-weight: 600;
199 }
200
201 .office-document-name {
331 - min-width: 0;
202 overflow: hidden;
203 text-overflow: ellipsis;
204 white-space: nowrap;
335 - font-size: 13px;
336 - font-weight: 750;
337 - letter-spacing: 0;
338 - line-height: 1.2;
205 }
206
207 .office-document-dirty {
342 - flex: 0 0 auto;
343 - color: #2ca58d;
344 - font-size: 14px;
345 - font-weight: 800;
346 - line-height: 1;
347 - }
348 -
349 - .office-file-actions {
350 - position: relative;
351 - display: inline-flex;
352 - align-items: center;
353 - flex: 0 0 auto;
354 - }
355 -
356 - .office-document-save-button,
357 - .office-file-menu-button {
358 - width: 30px;
359 - height: 30px;
360 - min-width: 30px;
361 - }
362 -
363 - .office-header-actions {
364 - position: relative;
365 - display: inline-flex;
366 - align-items: center;
367 - flex: 0 0 auto;
208 + color: var(--color-accent);
209 }
210
370 - .office-header-new-button {
371 - appearance: none;
211 + .office-icon-button {
212 display: inline-flex;
213 align-items: center;
214 justify-content: center;
375 - gap: 4px;
376 - height: 34px;
377 - min-height: 34px;
378 - padding: 0 9px 0 8px;
379 - border: 1px solid transparent;
380 - border-radius: 7px;
381 - background: transparent;
215 + gap: 6px;
216 + height: 32px;
217 + min-width: 32px;
218 + border: 1px solid var(--color-border);
219 + border-radius: 6px;
220 + background: var(--color-background);
221 color: var(--color-text);
222 cursor: pointer;
384 - font: inherit;
385 - font-size: 12px;
386 - font-weight: 750;
387 - letter-spacing: 0;
388 - line-height: 1;
389 - opacity: 0.82;
390 - white-space: nowrap;
391 - transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
223 }
224
394 - .office-header-new-button:hover,
395 - .office-header-actions.is-open .office-header-new-button {
396 - opacity: 1;
397 - border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
398 - background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
225 + .office-icon-button:hover:not(:disabled) {
226 + background: var(--color-surface);
227 + }
228 +
229 + .office-icon-button:disabled {
230 + cursor: default;
231 + opacity: 0.55;
232 }
233
401 - .office-header-new-button .material-symbols-outlined {
402 - font-size: 18px;
403 - line-height: 1;
234 + .office-document-save-button.is-primary {
235 + border-color: var(--color-accent);
236 + color: var(--color-accent);
237 }
238
406 - .office-header-new-button .office-new-chevron {
407 - margin-left: -2px;
408 - font-size: 16px;
409 - opacity: 0.8;
239 + .office-file-actions {
240 + position: relative;
241 }
242
243 .office-new-menu {
244 position: absolute;
245 top: calc(100% + 6px);
246 right: 0;
416 - z-index: 100;
417 - min-width: 184px;
418 - padding: 5px;
419 - border: 1px solid color-mix(in srgb, var(--color-border), transparent 10%);
420 - border-radius: 8px;
421 - background: color-mix(in srgb, var(--color-panel), var(--color-background) 10%);
422 - box-shadow: 0 14px 34px rgba(0, 0, 0, 0.34);
423 - }
424 -
425 - .office-new-menu[hidden] {
426 - display: none;
247 + z-index: 15;
248 + display: grid;
249 + min-width: 172px;
250 + padding: 6px;
251 + border: 1px solid var(--color-border);
252 + border-radius: 6px;
253 + background: var(--color-background);
254 + box-shadow: 0 16px 36px rgba(0, 0, 0, 0.18);
255 }
256
257 .office-new-menu-item {
430 - appearance: none;
258 display: flex;
259 align-items: center;
260 gap: 8px;
261 width: 100%;
435 - height: 32px;
436 - padding: 0 8px;
437 - border: 1px solid transparent;
438 - border-radius: 6px;
262 + border: 0;
263 + border-radius: 5px;
264 background: transparent;
265 color: var(--color-text);
441 - cursor: pointer;
442 - font: inherit;
443 - font-size: 12px;
444 - font-weight: 650;
445 - letter-spacing: 0;
446 - line-height: 1;
266 + padding: 8px;
267 text-align: left;
448 - white-space: nowrap;
268 + cursor: pointer;
269 }
270
271 .office-new-menu-item:hover {
452 - border-color: color-mix(in srgb, var(--color-primary) 22%, transparent);
453 - background: color-mix(in srgb, var(--color-background-hover) 76%, transparent);
272 + background: var(--color-surface);
273 }
274
456 - .office-new-menu-item:disabled {
457 - cursor: default;
458 - opacity: 0.46;
459 - }
460 -
461 - .office-new-menu-item .material-symbols-outlined {
462 - flex: 0 0 auto;
463 - width: 18px;
464 - font-size: 18px;
465 - line-height: 1;
466 - text-align: center;
467 - }
468 -
469 - .office-icon-button,
470 - .office-tab,
471 - .office-tab-close {
472 - border: 1px solid color-mix(in srgb, var(--color-border), transparent 12%);
473 - border-radius: 8px;
474 - background: color-mix(in srgb, var(--color-panel), var(--color-background) 16%);
475 - color: inherit;
476 - transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
477 - }
478 -
479 - .office-icon-button {
480 - display: inline-grid;
481 - place-items: center;
482 - width: 32px;
483 - height: 32px;
484 - min-width: 32px;
485 - padding: 0;
486 - }
487 -
488 - .office-command-button {
489 - display: inline-flex;
490 - align-items: center;
491 - justify-content: center;
492 - gap: 5px;
493 - width: auto;
494 - max-width: 126px;
495 - padding: 0 8px;
496 - white-space: nowrap;
497 - }
498 -
499 - .office-command-button .office-button-label {
500 - min-width: 0;
501 - overflow: hidden;
502 - text-overflow: ellipsis;
503 - font-size: 11px;
504 - font-weight: 700;
505 - line-height: 1;
506 - }
507 -
508 - .office-icon-button.is-primary {
509 - border-color: color-mix(in srgb, #2c7be5, var(--color-border) 20%);
510 - background: color-mix(in srgb, #2c7be5, var(--color-panel) 82%);
511 - }
512 -
513 - .office-icon-button.is-active {
514 - border-color: color-mix(in srgb, #2ca58d, var(--color-border) 24%);
515 - background: color-mix(in srgb, #2ca58d, var(--color-panel) 84%);
516 - }
517 -
518 - .office-icon-button:hover:not(:disabled),
519 - .office-tab:hover,
520 - .office-tab-close:hover {
521 - border-color: color-mix(in srgb, #2c7be5, var(--color-border) 45%);
522 - background: color-mix(in srgb, var(--color-panel), #2c7be5 8%);
523 - }
524 -
525 - .office-icon-button:disabled {
526 - cursor: default;
527 - opacity: 0.42;
528 - }
529 -
530 - .office-icon-button .material-symbols-outlined,
531 - .office-tab-icon {
532 - font-size: 19px;
533 - line-height: 1;
534 - }
535 -
536 - .office-tabs {
275 + .office-toolbar-row {
276 display: flex;
538 - gap: 6px;
539 - min-height: 42px;
540 - padding: 7px 10px;
541 - overflow-x: auto;
542 - border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 22%);
543 - background: color-mix(in srgb, var(--color-panel), var(--color-background) 28%);
544 - }
545 -
546 - .office-tab-shell {
547 - display: grid;
548 - grid-template-columns: minmax(0, 1fr) 28px;
277 align-items: center;
550 - min-width: 150px;
551 - max-width: 240px;
552 - }
553 -
554 - .office-tab-shell.is-system {
555 - grid-template-columns: minmax(0, 1fr);
556 - min-width: 172px;
557 - }
558 -
559 - .office-tab,
560 - .office-tab-close {
561 - height: 28px;
562 - min-height: 28px;
563 - border-radius: 7px;
278 + gap: 8px;
279 + width: 100%;
280 + min-width: 0;
281 }
282
566 - .office-tab {
283 + .office-tool-group,
284 + .office-empty-actions {
285 display: flex;
286 align-items: center;
287 + flex-wrap: wrap;
288 gap: 6px;
570 - min-width: 0;
571 - border-top-right-radius: 0;
572 - border-bottom-right-radius: 0;
573 - padding: 0 8px;
574 - text-align: left;
575 - }
576 -
577 - .office-tab-shell.is-system .office-tab {
578 - border-radius: 7px;
579 - }
580 -
581 - .office-tab-close {
582 - display: grid;
583 - place-items: center;
584 - border-left: 0;
585 - border-top-left-radius: 0;
586 - border-bottom-left-radius: 0;
587 - padding: 0;
588 - }
589 -
590 - .office-tab-close .material-symbols-outlined {
591 - font-size: 17px;
592 - }
593 -
594 - .office-tab-shell.is-active .office-tab,
595 - .office-tab-shell.is-active .office-tab-close {
596 - border-color: color-mix(in srgb, #2c7be5, var(--color-border) 36%);
597 - background: color-mix(in srgb, #2c7be5, var(--color-panel) 88%);
289 }
290
600 - .office-tab-shell.is-dirty .office-tab-title::after {
601 - content: " *";
602 - color: #2ca58d;
603 - }
604 -
605 - .office-tab-title {
606 - min-width: 0;
607 - overflow: hidden;
608 - text-overflow: ellipsis;
609 - white-space: nowrap;
610 - font-size: 12px;
611 - line-height: 1;
291 + .office-toolbar-spacer {
292 + flex: 1 1 auto;
293 }
294
295 .office-state-line {
615 - display: flex;
616 - align-items: center;
617 - gap: 8px;
618 - min-height: 34px;
619 - padding: 6px 12px;
620 - border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 28%);
621 - color: var(--color-text-secondary);
622 - font-size: 12px;
296 + font-size: 13px;
297 + color: var(--color-muted);
298 }
299
300 .office-body {
626 - position: relative;
301 display: flex;
302 flex: 1 1 auto;
303 min-height: 0;
630 - overflow: hidden;
631 - background: var(--color-background);
632 - }
633 -
634 - .office-body.is-source {
635 - background: transparent;
304 }
305
638 - .office-editor-wrap {
306 + .office-editor-wrap,
307 + .office-editor-scroll {
308 display: flex;
309 flex: 1 1 auto;
641 - flex-direction: column;
310 min-width: 0;
311 min-height: 0;
312 }
313
646 - .office-editor-scroll {
647 - flex: 1 1 auto;
648 - min-height: 0;
649 - overflow: auto;
650 - padding: 30px 24px;
651 - }
652 -
653 - .office-editor-scroll.is-source {
654 - display: flex;
655 - overflow: hidden;
656 - padding: var(--spacing-md);
657 - background: transparent;
658 - }
659 -
660 - .office-editor-scroll.is-desktop {
661 - display: flex;
662 - overflow: hidden;
663 - padding: 0;
664 - background: #1f2329;
665 - }
666 -
667 - .office-desktop-wrap {
668 - display: flex;
314 + .office-source-editor {
315 flex: 1 1 auto;
316 width: 100%;
317 height: 100%;
318 min-width: 0;
319 min-height: 0;
674 - aspect-ratio: auto;
675 - background: #1f2329;
320 + resize: none;
321 + border: 0;
322 + outline: none;
323 + padding: 16px;
324 + background: var(--color-background);
325 + color: var(--color-text);
326 + font: 14px/1.55 var(--font-mono, monospace);
327 }
328
678 - .office-desktop-empty {
679 - display: grid;
329 + .office-empty {
330 + display: flex;
331 flex: 1 1 auto;
681 - place-items: center;
682 - align-content: center;
683 - gap: 12px;
684 - min-width: 0;
685 - min-height: 0;
332 + align-items: center;
333 + justify-content: center;
334 padding: 24px;
687 - color: var(--color-text-secondary);
688 - text-align: center;
335 }
336
691 - .office-desktop-empty > .material-symbols-outlined {
692 - font-size: 32px;
693 - color: color-mix(in srgb, var(--color-text) 68%, transparent);
694 - }
695 -
696 - .office-desktop-empty-title {
697 - font-size: 13px;
698 - font-weight: 700;
699 - }
700 -
701 - .office-desktop-frame {
702 - flex: 1 1 auto;
703 - width: 100%;
704 - height: 100%;
705 - min-height: 0;
706 - aspect-ratio: auto;
707 - border: 0;
708 - background: #20242a;
709 - }
710 -
711 - .office-source-editor {
712 - box-sizing: border-box;
713 - flex: 1 1 auto;
714 - width: 100%;
715 - height: 100%;
716 - min-width: 0;
717 - min-height: 0;
718 - margin: 0;
719 - padding: 0;
720 - border: 0;
721 - outline: none;
722 - background: transparent;
723 - box-shadow: none;
724 - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
725 - font-size: 13px;
726 - line-height: 1.65;
727 - resize: none;
337 + .office-command-button {
338 + min-width: 108px;
339 + justify-content: flex-start;
340 + padding: 0 10px;
341 }
342
730 - textarea:focus {
731 - background: transparent;
732 - filter: brightness(1) !important;
343 + .office-header-actions {
344 + position: relative;
345 + display: inline-flex;
346 + align-items: center;
347 }
348
735 - .office-panel .spinning {
736 - animation: office-spin 0.8s linear infinite;
349 + .office-header-new-button {
350 + display: inline-flex;
351 + align-items: center;
352 + gap: 4px;
353 + height: 32px;
354 + border: 1px solid var(--color-border);
355 + border-radius: 6px;
356 + background: var(--color-background);
357 + color: var(--color-text);
358 + padding: 0 9px;
359 + cursor: pointer;
360 }
361
739 - @keyframes office-spin {
740 - to { transform: rotate(360deg); }
362 + .office-header-actions .office-new-menu {
363 + right: 0;
364 }
365
743 - @container (max-width: 680px) {
744 - .office-toolbar {
745 - padding-inline: 8px;
366 + @container (max-width: 560px) {
367 + .office-document-header,
368 + .office-toolbar,
369 + .office-state-line {
370 + padding: 7px;
371 }
372
748 - .office-toolbar-row {
749 - gap: 5px;
373 + .office-button-label,
374 + .office-header-new-button span:not(.material-symbols-outlined) {
375 + display: none;
376 }
377
378 .office-command-button {
753 - width: 32px;
754 - max-width: 32px;
755 - padding-inline: 0;
756 - }
757 -
758 - .office-command-button .office-button-label {
759 - position: absolute;
760 - width: 1px;
761 - height: 1px;
762 - overflow: hidden;
763 - clip: rect(0 0 0 0);
764 - white-space: nowrap;
379 + min-width: 32px;
380 + justify-content: center;
381 }
766 -
382 }
383 </style>
384 </body>
plugins/_office/webui/office-store.js
+71 -1960
@@ -2,25 +2,15 @@ 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";
5 +import { open as openSurface } from "/js/surfaces.js";
6
7 const officeSocket = getNamespacedClient("/ws");
8 officeSocket.addHandlers(["ws_webui"]);
9
10 const SAVE_MESSAGE_MS = 1800;
11 const INPUT_PUSH_DELAY_MS = 650;
13 -const DESKTOP_HEARTBEAT_MS = 3500;
14 -const DESKTOP_RESIZE_DELAY_MS = 80;
15 -const DESKTOP_START_MESSAGE = "Starting Agent Zero Desktop environment";
16 -const XPRA_DESKTOP_PRIME_INTERVAL_MS = 220;
17 -const XPRA_DESKTOP_PRIME_ATTEMPTS = 120;
18 -const SYSTEM_DESKTOP_FILE_ID = "system-desktop";
19 -const BROWSER_MODAL_PATH = "/plugins/_browser/webui/main.html";
20 -const OFFICE_MODAL_PATH = "/plugins/_office/webui/main.html";
21 -const URL_INTENT_PANEL_TIMEOUT_MS = 5000;
22 -const DESKTOP_SHUTDOWN_STORAGE_KEY = "a0.office.desktopShutdown";
12 const MAX_HISTORY = 80;
13 +const DESKTOP_DOCUMENT_EXTENSIONS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx"]);
14
15 function currentContextId() {
16 try {
@@ -57,55 +47,6 @@ function editorContainsFocus(element) {
47 return Boolean(element && active && (element === active || element.contains(active)));
48 }
49
60 -function isEditableInputTarget(target) {
61 - const element = target?.nodeType === 1 ? target : target?.parentElement;
62 - const editable = element?.closest?.("input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='textbox']");
63 - if (!editable) return false;
64 - if (editable.tagName !== "INPUT") return true;
65 - const type = String(editable.getAttribute("type") || "text").toLowerCase();
66 - return !["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"].includes(type);
67 -}
68 -
69 -function normalizeModalPath(path = "") {
70 - return String(path || "").replace(/^\/+/, "");
71 -}
72 -
73 -function isModalPathOpen(path = "") {
74 - const normalized = normalizeModalPath(path);
75 - return Boolean(
76 - globalThis.isModalOpen?.(path)
77 - || globalThis.isModalOpen?.(`/${normalized}`)
78 - || globalThis.isModalOpen?.(normalized)
79 - );
80 -}
81 -
82 -function waitForElementByPredicate(predicate, timeoutMs = URL_INTENT_PANEL_TIMEOUT_MS) {
83 - const found = predicate();
84 - if (found) return Promise.resolve(found);
85 - return new Promise((resolve) => {
86 - const timeout = globalThis.setTimeout(() => {
87 - observer.disconnect();
88 - resolve(predicate());
89 - }, timeoutMs);
90 - const observer = new MutationObserver(() => {
91 - const element = predicate();
92 - if (!element) return;
93 - globalThis.clearTimeout(timeout);
94 - observer.disconnect();
95 - resolve(element);
96 - });
97 - observer.observe(document.body, { childList: true, subtree: true });
98 - });
99 -}
100 -
101 -function browserPanelForMode(mode = "modal") {
102 - const panels = Array.from(document.querySelectorAll(".browser-panel"));
103 - if (mode === "canvas") {
104 - return panels.find((panel) => panel.closest?.('[data-surface-id="browser"]')) || null;
105 - }
106 - return panels.find((panel) => panel.closest?.(".modal")) || null;
107 -}
108 -
50 function placeCaretAtEnd(element) {
51 if (!element) return;
52 if (element.tagName === "TEXTAREA" || element.tagName === "INPUT") {
@@ -137,22 +78,23 @@ function normalizeDocument(doc = {}) {
78
79 function normalizeSession(payload = {}) {
80 const document = normalizeDocument(payload.document || payload);
140 - const extension = String(payload.extension || document.extension || "").toLowerCase();
81 return {
82 ...payload,
83 document,
144 - extension,
84 + extension: String(payload.extension || document.extension || "").toLowerCase(),
85 file_id: payload.file_id || document.file_id || "",
86 path: document.path || payload.path || "",
87 title: payload.title || document.title || document.basename || basename(document.path),
88 tab_id: uniqueTabId(payload),
89 text: String(payload.text || ""),
150 - desktop: payload.desktop || null,
151 - desktop_session_id: payload.desktop_session_id || payload.desktop?.session_id || "",
90 dirty: false,
91 };
92 }
93
94 +function documentLabel(document = {}) {
95 + return document.title || document.basename || basename(document.path);
96 +}
97 +
98 async function callOffice(action, payload = {}) {
99 return await callJsonApi("/plugins/_office/office_session", {
100 action,
@@ -187,8 +129,6 @@ function isOfficeSocketData(data) {
129 || Object.prototype.hasOwnProperty.call(data, "ok")
130 || Object.prototype.hasOwnProperty.call(data, "session_id")
131 || Object.prototype.hasOwnProperty.call(data, "document")
190 - || Object.prototype.hasOwnProperty.call(data, "desktop")
191 - || Object.prototype.hasOwnProperty.call(data, "closed")
132 );
133 }
134
@@ -204,7 +144,8 @@ const model = {
144 message: "",
145 editorText: "",
146 _root: null,
207 - _mode: "canvas",
147 + _mode: "modal",
148 + _initialized: false,
149 _saveMessageTimer: null,
150 _inputTimer: null,
151 _history: [],
@@ -212,57 +153,24 @@ const model = {
153 _pendingFocus: false,
154 _pendingFocusEnd: true,
155 _focusAttempts: 0,
215 - _floatingCleanup: null,
216 - _desktopHeartbeatTimer: null,
217 - _desktopHeartbeatSessionId: "",
218 - _desktopHeartbeatTabId: "",
219 - _desktopHeartbeatMisses: 0,
220 - _desktopResizeCleanup: null,
221 - _desktopResizeTarget: null,
222 - _desktopResizeTimer: null,
223 - _desktopResizeKey: "",
224 - _desktopResizePendingKey: "",
225 - _desktopResizeSuspended: false,
226 - _desktopResizePending: false,
227 - _desktopViewportSyncTimers: [],
228 - _desktopHostVisible: false,
229 - _desktopPrimeTimer: null,
230 - _desktopPrimeAttempts: 0,
231 - _desktopKeyboardActive: false,
232 - _desktopFocusInProgress: false,
233 - _desktopBridgeReady: false,
234 - _desktopKeyboardCaptureState: { ready: false, active: false, capture: false, focused: false },
235 - _desktopLastState: null,
236 - _desktopKeyboardCleanup: null,
237 - _desktopClipboardCleanup: null,
238 - _desktopStarting: null,
239 - _desktopUrlIntentBusy: false,
240 - _desktopUrlIntentQueue: [],
241 - _desktopFrame: null,
242 - _desktopFrameHost: null,
243 - _desktopFrameLoadHandler: null,
244 - _desktopKeepaliveHost: null,
245 - _desktopIntentionalShutdown: false,
246 -
247 - async init(element = null) {
248 - this.restoreDesktopShutdownState();
249 - return await this.onMount(element, { mode: "canvas" });
156 + _headerCleanup: null,
157 +
158 + async init() {
159 + if (this._initialized) return;
160 + this._initialized = true;
161 + await this.refresh();
162 },
163
164 async onMount(element = null, options = {}) {
165 + await this.init();
166 if (element) this._root = element;
254 - this._mode = options?.mode === "modal" ? "modal" : "canvas";
255 - if (this._mode === "modal") {
256 - this._desktopHostVisible = true;
257 - this.setupFloatingModal(element);
258 - await this.onOpen({ source: "modal" });
259 - return;
260 - }
167 + this._mode = options?.mode === "canvas" ? "canvas" : "modal";
168 + if (this._mode === "modal") this.setupDocumentModal(element);
169 this.queueRender();
170 },
171
172 async onOpen(payload = {}) {
265 - this.restoreDesktopShutdownState();
173 + await this.init();
174 await this.refresh();
175 if (payload?.path || payload?.file_id) {
176 await this.openSession({
@@ -271,39 +179,17 @@ const model = {
179 refresh: payload.refresh === true,
180 source: payload.source || "",
181 });
274 - } else if (this._desktopIntentionalShutdown) {
275 - this.session = null;
276 - this.activeTabId = "";
277 - this.editorText = "";
278 - this.dirty = false;
279 - } else {
280 - await this.ensureDesktopSession({ select: !this.session });
182 }
282 - this.restoreDesktopFrames();
283 - this.requestDesktopViewportSync({ force: true });
183 },
184
286 - beforeHostHidden(options = {}) {
287 - this._desktopHostVisible = false;
185 + beforeHostHidden() {
186 this.flushInput();
289 - this.clearDesktopViewportSyncTimers();
290 - this.stopDesktopMonitor();
291 - this.stopDesktopKeyboardBridge();
292 - this.stopDesktopClipboardBridge();
293 - this.unloadDesktopFrames();
187 },
188
189 cleanup() {
190 this.flushInput();
298 - this.stopDesktopMonitor();
299 - this.stopDesktopResizeObserver();
300 - this.clearDesktopViewportSyncTimers();
301 - this.stopXpraDesktopPrime();
302 - this.stopDesktopKeyboardBridge();
303 - this.stopDesktopClipboardBridge();
304 - if (!this._desktopIntentionalShutdown) this.moveDesktopFrameToKeepalive();
305 - this._floatingCleanup?.();
306 - this._floatingCleanup = null;
191 + this._headerCleanup?.();
192 + this._headerCleanup = null;
193 if (this._mode === "modal") this._root = null;
194 },
195
@@ -317,176 +203,6 @@ const model = {
203 }
204 },
205
320 - restoreDesktopShutdownState() {
321 - try {
322 - this._desktopIntentionalShutdown = localStorage.getItem(DESKTOP_SHUTDOWN_STORAGE_KEY) === "1";
323 - } catch {
324 - this._desktopIntentionalShutdown = Boolean(this._desktopIntentionalShutdown);
325 - }
326 - },
327 -
328 - persistDesktopShutdownState() {
329 - try {
330 - if (this._desktopIntentionalShutdown) {
331 - localStorage.setItem(DESKTOP_SHUTDOWN_STORAGE_KEY, "1");
332 - } else {
333 - localStorage.removeItem(DESKTOP_SHUTDOWN_STORAGE_KEY);
334 - }
335 - } catch {
336 - // Shutdown state is still correct for this page even without storage.
337 - }
338 - },
339 -
340 - setDesktopIntentionalShutdown(value) {
341 - this._desktopIntentionalShutdown = Boolean(value);
342 - this.persistDesktopShutdownState();
343 - },
344 -
345 - isDesktopShutdown() {
346 - return Boolean(this._desktopIntentionalShutdown);
347 - },
348 -
349 - shouldShowDesktopEmptyState() {
350 - return Boolean(this._desktopIntentionalShutdown && !this.session);
351 - },
352 -
353 - async restartDesktopSession() {
354 - this.error = "";
355 - const session = await this.ensureDesktopSession({
356 - force: true,
357 - restart: true,
358 - select: true,
359 - message: "Restarting Agent Zero Desktop environment",
360 - });
361 - if (!session) {
362 - this.setDesktopIntentionalShutdown(true);
363 - return null;
364 - }
365 - this.restoreDesktopFrames();
366 - this.requestDesktopViewportSync({ force: true });
367 - return session;
368 - },
369 -
370 - async shutdownDesktop(options = {}) {
371 - this.loading = options.progress !== false;
372 - this.message = this.loading ? "Shutting down Desktop" : this.message;
373 - this.error = "";
374 - try {
375 - const response = await callOffice("desktop_shutdown", {
376 - save_first: options.saveFirst !== false,
377 - source: options.source || "ui",
378 - });
379 - await this.handleIntentionalDesktopShutdown(response);
380 - return response;
381 - } catch (error) {
382 - this.error = error instanceof Error ? error.message : String(error);
383 - return null;
384 - } finally {
385 - if (options.progress !== false) {
386 - this.loading = false;
387 - if (this.message === "Shutting down Desktop") this.message = "";
388 - }
389 - }
390 - },
391 -
392 - async handleIntentionalDesktopShutdown(response = {}) {
393 - this.setDesktopIntentionalShutdown(true);
394 - this.stopDesktopMonitor();
395 - this.stopDesktopResizeObserver();
396 - this.clearDesktopViewportSyncTimers();
397 - this.stopXpraDesktopPrime();
398 - this.stopDesktopKeyboardBridge();
399 - this.stopDesktopClipboardBridge();
400 - this.destroyDesktopFrame();
401 - const activeTabId = this.activeTabId;
402 - this.tabs = this.tabs.filter((tab) => !this.isDesktopSession(tab) && !this.hasOfficialOffice(tab));
403 - if (!this.tabs.some((tab) => tab.tab_id === activeTabId)) {
404 - this.session = null;
405 - this.activeTabId = "";
406 - this.editorText = "";
407 - this.dirty = false;
408 - this.resetHistory("");
409 - }
410 - this._desktopStarting = null;
411 - this._desktopHeartbeatMisses = 0;
412 - this.message = response?.source === "tray" ? "Desktop shut down from system tray" : "Desktop is shut down";
413 - await this.refresh();
414 - },
415 -
416 - async ensureDesktopSession(options = {}) {
417 - if (this._desktopIntentionalShutdown && options.restart !== true) {
418 - return null;
419 - }
420 - if (options.restart === true) {
421 - this.setDesktopIntentionalShutdown(false);
422 - this.destroyDesktopFrame();
423 - }
424 - const existing = this.tabs.find((tab) => this.isDesktopSession(tab));
425 - if (existing && !options.force) {
426 - if (options.select) this.selectTab(existing.tab_id, { focus: false });
427 - this.updateDesktopMonitor();
428 - return existing;
429 - }
430 - const showProgress = options.progress !== false;
431 - const progressMessage = String(options.message || DESKTOP_START_MESSAGE);
432 - if (this._desktopStarting) {
433 - if (showProgress) {
434 - this.loading = true;
435 - this.message = progressMessage;
436 - }
437 - return await this._desktopStarting;
438 - }
439 -
440 - this._desktopStarting = (async () => {
441 - try {
442 - if (showProgress) {
443 - this.loading = true;
444 - this.message = progressMessage;
445 - this.error = "";
446 - }
447 - const response = await callOffice("desktop");
448 - if (response?.ok === false) throw new Error(response.error || "Desktop session could not be opened.");
449 - this.setDesktopIntentionalShutdown(false);
450 - const session = normalizeSession(response);
451 - const existingIndex = this.tabs.findIndex((tab) => this.isDesktopSession(tab));
452 - let desktopTabId = session.tab_id;
453 - if (existingIndex >= 0) {
454 - desktopTabId = this.tabs[existingIndex].tab_id;
455 - this.tabs.splice(existingIndex, 1, { ...this.tabs[existingIndex], ...session, tab_id: desktopTabId });
456 - } else {
457 - this.tabs.unshift(session);
458 - }
459 - this.tabs = this.tabs.map((tab) => (
460 - this.hasOfficialOffice(tab)
461 - ? {
462 - ...tab,
463 - desktop: session.desktop,
464 - desktop_session_id: session.desktop_session_id,
465 - session_id: this.isDesktopSession(tab) ? session.session_id : tab.session_id,
466 - }
467 - : tab
468 - ));
469 - if (options.select || !this.session) {
470 - this.selectTab(desktopTabId, { focus: false });
471 - } else {
472 - this.updateDesktopMonitor();
473 - }
474 - this.restoreDesktopFrames();
475 - return { ...session, tab_id: desktopTabId };
476 - } catch (error) {
477 - this.error = error instanceof Error ? error.message : String(error);
478 - return null;
479 - } finally {
480 - if (showProgress) {
481 - this.loading = false;
482 - if (this.message === progressMessage) this.message = "";
483 - }
484 - this._desktopStarting = null;
485 - }
486 - })();
487 - return await this._desktopStarting;
488 - },
489 -
206 async create(kind = "document", format = "") {
207 const fmt = String(format || (kind === "spreadsheet" ? "ods" : kind === "presentation" ? "odp" : "md")).toLowerCase();
208 const title = this.defaultTitle(kind, fmt);
@@ -527,6 +243,12 @@ const model = {
243 this.error = response.error || "Document could not be opened.";
244 return null;
245 }
246 + if (response?.requires_desktop || this.isDesktopDocument(response)) {
247 + const document = normalizeDocument(response.document || response);
248 + this.setMessage(`${documentLabel(document)} is ready. Use Open in Desktop to edit it.`);
249 + await this.refresh();
250 + return response;
251 + }
252 const session = normalizeSession(response);
253 this.installSession(session);
254 await this.refresh();
@@ -540,10 +262,6 @@ const model = {
262 },
263
264 installSession(session) {
543 - if (this.isDesktopOfficeDocument(session)) {
544 - this.installDesktopDocumentSession(session);
545 - return;
546 - }
265 const existingIndex = this.tabs.findIndex((tab) => (
266 (session.file_id && tab.file_id === session.file_id)
267 || (session.path && tab.path === session.path)
@@ -558,67 +276,14 @@ const model = {
276 this.selectTab(this.activeTabId);
277 },
278
561 - installDesktopDocumentSession(session) {
562 - this.setDesktopIntentionalShutdown(false);
563 - this.tabs = this.tabs.filter((tab) => !this.isDesktopOfficeDocument(tab));
564 - let desktopTab = this.tabs.find((tab) => this.isDesktopSession(tab));
565 - if (!desktopTab) {
566 - desktopTab = {
567 - ...session,
568 - tab_id: SYSTEM_DESKTOP_FILE_ID,
569 - file_id: SYSTEM_DESKTOP_FILE_ID,
570 - extension: "desktop",
571 - title: "Desktop",
572 - path: session.desktop?.desktop_path || "/desktop/session",
573 - mode: "desktop",
574 - document: {
575 - file_id: SYSTEM_DESKTOP_FILE_ID,
576 - path: session.desktop?.desktop_path || "/desktop/session",
577 - basename: "Desktop",
578 - title: "Desktop",
579 - extension: "desktop",
580 - },
581 - dirty: false,
582 - };
583 - this.tabs.unshift(desktopTab);
584 - }
585 - const documentSession = { ...session, tab_id: session.tab_id || uniqueTabId(session) };
586 - const existingIndex = this.tabs.findIndex((tab) => (
587 - (documentSession.file_id && tab.file_id === documentSession.file_id)
588 - || (documentSession.path && tab.path === documentSession.path)
589 - ));
590 - if (existingIndex >= 0) {
591 - this.tabs.splice(existingIndex, 1, documentSession);
592 - } else {
593 - this.tabs.push(documentSession);
594 - }
595 - this.session = documentSession;
596 - this.activeTabId = documentSession.tab_id;
597 - this.editorText = "";
598 - this.dirty = false;
599 - this.resetHistory("");
600 - this.queueRender({ focus: true });
601 - this.restoreDesktopFrames();
602 - this.requestDesktopViewportSync({ force: true });
603 - this.updateDesktopMonitor();
604 - },
605 -
279 selectTab(tabId, options = {}) {
280 const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null;
608 - if (this.hasOfficialOffice(this.session) && !this.hasOfficialOffice(tab)) {
609 - this.moveDesktopFrameToKeepalive();
610 - }
281 this.session = tab;
282 this.activeTabId = tab?.tab_id || "";
283 this.editorText = String(tab?.text || "");
284 this.dirty = Boolean(tab?.dirty);
285 this.resetHistory(this.editorText);
286 this.queueRender({ focus: Boolean(tab) && options.focus !== false });
617 - if (this.hasOfficialOffice(tab)) {
618 - this.restoreDesktopFrames();
619 - this.requestDesktopViewportSync({ force: true });
620 - }
621 - this.updateDesktopMonitor();
287 },
288
289 ensureActiveTab() {
@@ -633,21 +298,12 @@ const model = {
298 async closeTab(tabId) {
299 const tab = this.tabs.find((item) => item.tab_id === tabId);
300 if (!tab) return;
636 - if (this.isDesktopSession(tab)) {
637 - this.selectTab(tab.tab_id, { focus: false });
638 - return;
639 - }
640 - if (!this.hasOfficialOffice(tab) && (tab.dirty || (this.isActiveTab(tab) && this.dirty))) {
301 + if (tab.dirty || (this.isActiveTab(tab) && this.dirty)) {
302 const shouldSave = globalThis.confirm?.("Save changes?") ?? true;
303 if (shouldSave) await this.save();
304 }
305 try {
645 - if (this.hasOfficialOffice(tab)) {
646 - await callOffice("desktop_save", {
647 - desktop_session_id: tab.desktop_session_id || tab.session_id,
648 - file_id: tab.file_id || "",
649 - }).catch(() => null);
650 - } else if (tab.session_id) {
306 + if (tab.session_id) {
307 await requestOffice("office_close", { session_id: tab.session_id }, 2500).catch(() => null);
308 }
309 await callOffice("close", {
@@ -665,48 +321,17 @@ const model = {
321 this.dirty = false;
322 this.ensureActiveTab();
323 }
668 - this.updateDesktopMonitor();
324 this.ensureActiveTab();
325 await this.refresh();
326 },
327
328 async closeActiveFile() {
674 - if (!this.session || this.isDesktopSession() || this.loading) return;
329 + if (!this.session || this.loading) return;
330 await this.closeTab(this.session.tab_id);
331 },
332
333 async save() {
679 - if (!this.session || this.saving) return;
680 - if (this.isDesktopSession()) return;
681 - if (this.hasOfficialOffice()) {
682 - this.saving = true;
683 - this.error = "";
684 - try {
685 - const response = await callOffice("desktop_save", {
686 - desktop_session_id: this.session.desktop_session_id || this.session.session_id,
687 - file_id: this.session.file_id || "",
688 - });
689 - if (response?.ok === false) throw new Error(response.error || "Save failed.");
690 - const document = normalizeDocument(response.document || this.session.document || {});
691 - const updated = {
692 - ...this.session,
693 - dirty: false,
694 - document,
695 - path: document.path || this.session.path,
696 - file_id: document.file_id || this.session.file_id,
697 - version: document.version || response.version || this.session.version,
698 - };
699 - this.replaceActiveSession(updated);
700 - this.dirty = false;
701 - this.setMessage("Saved");
702 - await this.refresh();
703 - } catch (error) {
704 - this.error = error instanceof Error ? error.message : String(error);
705 - } finally {
706 - this.saving = false;
707 - }
708 - return;
709 - }
334 + if (!this.session || this.saving || !this.isMarkdown()) return;
335 this.syncEditorText();
336 this.saving = true;
337 this.error = "";
@@ -741,8 +366,7 @@ const model = {
366 },
367
368 async renameActiveFile() {
744 - if (!this.session || this.isDesktopSession() || this.saving) return;
745 -
369 + if (!this.session || this.saving) return;
370 const session = this.session;
371 const path = session.path || session.document?.path || "";
372 if (!path) {
@@ -800,7 +424,6 @@ const model = {
424 extension: document.extension || session.extension,
425 file_id: document.file_id || session.file_id,
426 version: document.version || response.version || session.version,
803 - desktop: response.desktop?.desktop || session.desktop,
427 text: this.session?.tab_id === session.tab_id ? this.editorText : session.text,
428 dirty: false,
429 };
@@ -820,7 +443,6 @@ const model = {
443 const index = this.tabs.findIndex((tab) => tab.tab_id === (previous?.tab_id || next.tab_id));
444 if (index >= 0) this.tabs.splice(index, 1, next);
445 this.queueRender();
823 - this.updateDesktopMonitor();
446 },
447
448 setMessage(value) {
@@ -889,12 +511,11 @@ const model = {
511
512 syncEditorText() {
513 if (!this.session) return;
892 - if (this.hasOfficialOffice()) return;
514 this.session.text = this.editorText;
515 },
516
517 scheduleInputPush() {
897 - if (!this.session?.session_id) return;
518 + if (!this.session?.session_id || !this.isMarkdown()) return;
519 if (this._inputTimer) globalThis.clearTimeout(this._inputTimer);
520 this._inputTimer = globalThis.setTimeout(() => {
521 this._inputTimer = null;
@@ -903,8 +524,7 @@ const model = {
524 },
525
526 flushInput() {
906 - if (!this.session?.session_id) return;
907 - if (this.hasOfficialOffice()) return;
527 + if (!this.session?.session_id || !this.isMarkdown()) return;
528 this.syncEditorText();
529 requestOffice("office_input", {
530 session_id: this.session.session_id,
@@ -913,12 +533,7 @@ const model = {
533 },
534
535 format(command) {
916 - if (!this.session) return;
917 - if (!this.isMarkdown()) return;
918 - this.applySourceFormat(command);
919 - },
920 -
921 - applySourceFormat(command) {
536 + if (!this.session || !this.isMarkdown()) return;
537 const textarea = this._root?.querySelector?.("[data-office-source]");
538 if (!textarea) return;
539 const start = textarea.selectionStart || 0;
@@ -941,7 +556,6 @@ const model = {
556 },
557
558 queueRender(options = {}) {
944 - const force = Boolean(options.force);
559 if (options.focus) {
560 this._pendingFocus = true;
561 this._pendingFocusEnd = options.end !== false;
@@ -964,12 +578,9 @@ const model = {
578 },
579
580 focusEditor(options = {}) {
967 - if (!this.session) return false;
968 - if (this.hasOfficialOffice()) {
969 - return this.focusDesktopFrame(this.desktopFrame(), { arm: true });
970 - }
581 + if (!this.session || !this.isMarkdown()) return false;
582 const source = this._root?.querySelector?.("[data-office-source]");
972 - if (!this.isMarkdown() || !source) return false;
583 + if (!source) return false;
584 source.focus?.({ preventScroll: true });
585 if (!editorContainsFocus(source)) return false;
586 if (options.end !== false) placeCaretAtEnd(source);
@@ -981,1275 +592,17 @@ const model = {
592 return ext === "md";
593 },
594
984 - isBinaryOffice(tab = this.session) {
595 + isDesktopDocument(tab = this.session) {
596 const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
986 - return ["odt", "ods", "odp", "docx", "xlsx", "pptx"].includes(ext);
987 - },
988 -
989 - hasOfficialOffice(tab = this.session) {
990 - return Boolean(tab?.desktop?.available && tab.desktop.url);
991 - },
992 -
993 - isDesktopSession(tab = this.session) {
994 - return Boolean(
995 - tab
996 - && (
997 - tab.file_id === SYSTEM_DESKTOP_FILE_ID
998 - || tab.extension === "desktop"
999 - || tab.mode === "desktop"
1000 - )
1001 - );
1002 - },
1003 -
1004 - isDesktopOfficeDocument(tab = this.session) {
1005 - return Boolean(tab && this.hasOfficialOffice(tab) && !this.isDesktopSession(tab) && this.isBinaryOffice(tab));
597 + return DESKTOP_DOCUMENT_EXTENSIONS.has(ext);
598 },
599
600 hasActiveFile(tab = this.session) {
1009 - return Boolean(tab && !this.isDesktopSession(tab) && (this.isMarkdown(tab) || this.isDesktopOfficeDocument(tab)));
1010 - },
1011 -
1012 - isVisibleOfficeTab(tab = {}) {
1013 - return Boolean(this.hasActiveFile(tab));
601 + return Boolean(tab && this.isMarkdown(tab));
602 },
603
604 visibleTabs() {
1017 - return this.tabs.filter((tab) => this.isVisibleOfficeTab(tab));
1018 - },
1019 -
1020 - officialOfficeUrl(tab = this.session) {
1021 - const url = tab?.desktop?.url || "";
1022 - if (!url) return "";
1023 - try {
1024 - const parsed = new URL(url, window.location.href);
1025 - const secureContext = globalThis.isSecureContext === true;
1026 - parsed.searchParams.set("offscreen", secureContext ? "true" : "false");
1027 - parsed.searchParams.set("clipboard_poll", secureContext ? "true" : "false");
1028 - if (parsed.origin === window.location.origin) return `${parsed.pathname}${parsed.search}${parsed.hash}`;
1029 - return parsed.href;
1030 - } catch {
1031 - return url;
1032 - }
1033 - },
1034 -
1035 - isDesktopHostVisible() {
1036 - if (this._mode === "modal") return true;
1037 - const canvas = rightCanvasStore;
1038 - return Boolean(canvas?.isOpen && (canvas.isSurfaceMounted?.("office") ?? canvas.activeSurfaceId === "office"));
1039 - },
1040 -
1041 - setDesktopHostVisible(visible) {
1042 - const next = Boolean(visible);
1043 - if (!next && this._mode === "modal") return;
1044 - if (this._desktopHostVisible === next) return;
1045 - this._desktopHostVisible = next;
1046 - if (next) {
1047 - this.afterDesktopHostShown({ source: "canvas-visibility" });
1048 - } else {
1049 - this.beforeHostHidden({ reason: "hidden" });
1050 - }
1051 - },
1052 -
1053 - desktopFrames() {
1054 - const frames = [];
1055 - if (this._desktopFrame) frames.push(this._desktopFrame);
1056 - for (const frame of Array.from(document.querySelectorAll("[data-office-desktop-frame]"))) {
1057 - if (!frames.includes(frame)) frames.push(frame);
1058 - }
1059 - return frames;
1060 - },
1061 -
1062 - isUsableDesktopFrame(frame) {
1063 - if (!frame?.contentWindow) return false;
1064 - const rect = frame.getBoundingClientRect?.();
1065 - return Boolean(rect && rect.width >= 120 && rect.height >= 80);
1066 - },
1067 -
1068 - desktopFrame(preferred = null) {
1069 - if (this.isUsableDesktopFrame(preferred)) return preferred;
1070 - const rootFrame = this._root?.querySelector?.("[data-office-desktop-frame]");
1071 - if (this.isUsableDesktopFrame(rootFrame)) return rootFrame;
1072 - const frames = this.desktopFrames();
1073 - return frames
1074 - .filter((frame) => this.isUsableDesktopFrame(frame))
1075 - .sort((left, right) => {
1076 - const leftRect = left.getBoundingClientRect();
1077 - const rightRect = right.getBoundingClientRect();
1078 - return (rightRect.width * rightRect.height) - (leftRect.width * leftRect.height);
1079 - })[0] || null;
1080 - },
1081 -
1082 - isUsableDesktopHost(host) {
1083 - if (!host?.appendChild) return false;
1084 - const rect = host.getBoundingClientRect?.();
1085 - return Boolean(rect && rect.width >= 120 && rect.height >= 80);
1086 - },
1087 -
1088 - desktopHost(preferred = null) {
1089 - if (preferred?.matches?.("[data-office-desktop-host]")) return preferred;
1090 - const rootHost = this._root?.querySelector?.("[data-office-desktop-host]");
1091 - if (this.isUsableDesktopHost(rootHost)) return rootHost;
1092 - const hosts = Array.from(document.querySelectorAll("[data-office-desktop-host]"));
1093 - return hosts
1094 - .filter((host) => this.isUsableDesktopHost(host))
1095 - .sort((left, right) => {
1096 - const leftRect = left.getBoundingClientRect();
1097 - const rightRect = right.getBoundingClientRect();
1098 - return (rightRect.width * rightRect.height) - (leftRect.width * leftRect.height);
1099 - })[0] || rootHost || hosts[0] || null;
1100 - },
1101 -
1102 - ensureDesktopKeepaliveHost() {
1103 - if (this._desktopKeepaliveHost?.isConnected) return this._desktopKeepaliveHost;
1104 - const host = document.createElement("div");
1105 - host.className = "office-desktop-keepalive";
1106 - host.dataset.officeDesktopKeepalive = "true";
1107 - Object.assign(host.style, {
1108 - position: "fixed",
1109 - left: "-10000px",
1110 - top: "-10000px",
1111 - width: "720px",
1112 - height: "480px",
1113 - overflow: "hidden",
1114 - pointerEvents: "none",
1115 - visibility: "hidden",
1116 - });
1117 - document.body?.appendChild(host);
1118 - this._desktopKeepaliveHost = host;
1119 - return host;
1120 - },
1121 -
1122 - rememberDesktopFrameSize() {
1123 - const frame = this._desktopFrame;
1124 - const rect = frame?.getBoundingClientRect?.();
1125 - const hostRect = this._desktopFrameHost?.getBoundingClientRect?.();
1126 - const width = Math.round(rect?.width || hostRect?.width || 720);
1127 - const height = Math.round(rect?.height || hostRect?.height || 480);
1128 - const keepalive = this.ensureDesktopKeepaliveHost();
1129 - keepalive.style.width = `${Math.max(320, width)}px`;
1130 - keepalive.style.height = `${Math.max(220, height)}px`;
1131 - return keepalive;
1132 - },
1133 -
1134 - ensureDesktopFrame() {
1135 - if (this._desktopFrame) return this._desktopFrame;
1136 - const frame = document.createElement("iframe");
1137 - frame.className = "office-desktop-frame";
1138 - frame.dataset.officeDesktopFrame = "true";
1139 - frame.dataset.officePersistentDesktopFrame = "true";
1140 - frame.setAttribute("tabindex", "0");
1141 - frame.setAttribute("aria-label", "Desktop");
1142 - frame.setAttribute("allow", "clipboard-read; clipboard-write; autoplay");
1143 - this._desktopFrameLoadHandler = (event) => this.onDesktopFrameLoaded(event);
1144 - frame.addEventListener("load", this._desktopFrameLoadHandler);
1145 - this._desktopFrame = frame;
1146 - return frame;
1147 - },
1148 -
1149 - desktopFrameSrcMatches(frame, url) {
1150 - const current = frame?.getAttribute?.("src") || frame?.src || "";
1151 - if (!current && !url) return true;
1152 - try {
1153 - return new URL(current, window.location.href).href === new URL(url, window.location.href).href;
1154 - } catch {
1155 - return current === url;
1156 - }
1157 - },
1158 -
1159 - attachDesktopFrame(host = null) {
1160 - if (!this.hasOfficialOffice()) return false;
1161 - const target = this.desktopHost(host);
1162 - if (!target) return false;
1163 - const frame = this.ensureDesktopFrame();
1164 - if (frame.parentElement !== target) {
1165 - frame.parentElement?.removeAttribute?.("data-office-desktop-attached");
1166 - target.appendChild(frame);
1167 - }
1168 - target.dataset.officeDesktopAttached = "true";
1169 - if (this._desktopFrameHost !== target) this._desktopFrameHost = target;
1170 - const url = this.officialOfficeUrl();
1171 - if (url && !this.desktopFrameSrcMatches(frame, url)) {
1172 - frame.setAttribute("src", url);
1173 - }
1174 - return true;
1175 - },
1176 -
1177 - mountDesktopFrameHost(host = null) {
1178 - const attached = this.attachDesktopFrame(host);
1179 - if (attached && this.isDesktopHostVisible()) {
1180 - this.requestDesktopViewportSync({ force: true, frame: this._desktopFrame, followup: true });
1181 - }
1182 - return attached;
1183 - },
1184 -
1185 - moveDesktopFrameToKeepalive() {
1186 - const frame = this._desktopFrame;
1187 - if (!frame) return false;
1188 - const keepalive = this.rememberDesktopFrameSize();
1189 - if (frame.parentElement !== keepalive) {
1190 - frame.parentElement?.removeAttribute?.("data-office-desktop-attached");
1191 - keepalive.appendChild(frame);
1192 - }
1193 - this._desktopFrameHost = keepalive;
1194 - this._desktopKeyboardActive = false;
1195 - this.updateDesktopKeyboardCaptureState(frame);
1196 - return true;
1197 - },
1198 -
1199 - destroyDesktopFrame() {
1200 - const frame = this._desktopFrame;
1201 - if (!frame) return;
1202 - if (this._desktopFrameLoadHandler) {
1203 - frame.removeEventListener("load", this._desktopFrameLoadHandler);
1204 - }
1205 - frame.setAttribute("src", "about:blank");
1206 - frame.remove();
1207 - this._desktopFrame = null;
1208 - this._desktopFrameHost = null;
1209 - this._desktopFrameLoadHandler = null;
1210 - this._desktopBridgeReady = false;
1211 - this.updateDesktopKeyboardCaptureState();
1212 - this._desktopKeepaliveHost?.remove?.();
1213 - this._desktopKeepaliveHost = null;
1214 - },
1215 -
1216 - unloadDesktopFrames() {
1217 - this.stopDesktopResizeObserver();
1218 - this.stopXpraDesktopPrime();
1219 - this.moveDesktopFrameToKeepalive();
1220 - },
1221 -
1222 - restoreDesktopFrames() {
1223 - if (!this.isDesktopHostVisible()) return;
1224 - this.attachDesktopFrame();
1225 - },
1226 -
1227 - afterDesktopHostShown() {
1228 - if (!this.hasOfficialOffice()) return;
1229 - this._desktopHostVisible = true;
1230 - this._desktopResizeKey = "";
1231 - this._desktopResizePendingKey = "";
1232 - this._desktopResizeSuspended = false;
1233 - this._desktopResizePending = false;
1234 - this.restoreDesktopFrames();
1235 - this.requestDesktopViewportSync({ force: true, frame: this.desktopFrame() });
1236 - },
1237 -
1238 - beforeDesktopHostHandoff() {
1239 - this.stopDesktopResizeObserver();
1240 - this.clearDesktopViewportSyncTimers();
1241 - this.stopXpraDesktopPrime();
1242 - this._desktopResizeKey = "";
1243 - this._desktopResizePendingKey = "";
1244 - this._desktopResizeSuspended = true;
1245 - this._desktopResizePending = true;
1246 - },
1247 -
1248 - cancelDesktopHostHandoff() {
1249 - this._desktopResizeSuspended = false;
1250 - this._desktopResizePending = false;
1251 - this.requestDesktopViewportSync({ force: true, frame: this.desktopFrame() });
1252 - },
1253 -
1254 - onDesktopFrameLoaded(event = null) {
1255 - if (event?.target?.getAttribute?.("src") === "about:blank") return;
1256 - if (!this.isDesktopHostVisible()) return;
1257 - this.error = "";
1258 - this.queueDesktopFrameFocus(event?.target || null);
1259 - this.requestDesktopViewportSync({ force: true, frame: event?.target || null });
1260 - },
1261 -
1262 - queueDesktopFrameFocus(frame = null) {
1263 - for (const delay of [0, 80, 260]) {
1264 - globalThis.setTimeout(() => {
1265 - if (!this.hasOfficialOffice()) return;
1266 - if (isEditableInputTarget(document.activeElement)) return;
1267 - this.focusDesktopFrame(frame || this.desktopFrame(), { arm: true });
1268 - }, delay);
1269 - }
1270 - },
1271 -
1272 - focusDesktopFrame(frame = null, options = {}) {
1273 - if (this._desktopFocusInProgress) return false;
1274 - const target = this.desktopFrame(frame);
1275 - if (!target) return false;
1276 - if (options.arm !== false) this._desktopKeyboardActive = true;
1277 - this._desktopFocusInProgress = true;
1278 - try {
1279 - target.setAttribute("tabindex", "0");
1280 - target.focus?.({ preventScroll: true });
1281 - target.contentWindow?.focus?.();
1282 - if (target.contentDocument?.body && !target.contentDocument.body.hasAttribute("tabindex")) {
1283 - target.contentDocument.body.tabIndex = -1;
1284 - }
1285 - target.contentDocument?.body?.focus?.({ preventScroll: true });
1286 - if (target.contentWindow?.client) target.contentWindow.client.capture_keyboard = true;
1287 - } catch {
1288 - target.focus?.({ preventScroll: true });
1289 - } finally {
1290 - this._desktopFocusInProgress = false;
1291 - }
1292 - const focused = Boolean(document.activeElement === target || target.contentDocument?.hasFocus?.());
1293 - this.updateDesktopKeyboardCaptureState(target);
1294 - return focused;
1295 - },
1296 -
1297 - updateDesktopMonitor() {
1298 - if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) {
1299 - this.stopDesktopMonitor();
1300 - this.stopDesktopResizeObserver();
1301 - this._desktopKeyboardActive = false;
1302 - this._desktopBridgeReady = false;
1303 - this.updateDesktopKeyboardCaptureState();
1304 - return;
1305 - }
1306 - const sessionId = this.session?.desktop_session_id || this.session?.session_id || "";
1307 - const tabId = this.session?.tab_id || "";
1308 - if (
1309 - sessionId
1310 - && tabId
1311 - && this._desktopHeartbeatTimer
1312 - && this._desktopHeartbeatSessionId === sessionId
1313 - && this._desktopHeartbeatTabId === tabId
1314 - ) return;
1315 - this.startDesktopMonitor();
1316 - this.startDesktopResizeObserver();
1317 - },
1318 -
1319 - startDesktopResizeObserver() {
1320 - if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) {
1321 - this.stopDesktopResizeObserver();
1322 - return;
1323 - }
1324 - const frame = this.desktopFrame();
1325 - const target = frame?.parentElement || frame;
1326 - if (!target) {
1327 - this.stopDesktopResizeObserver();
1328 - return;
1329 - }
1330 - if (this._desktopResizeCleanup && this._desktopResizeTarget === target) return;
1331 - this.stopDesktopResizeObserver();
1332 -
1333 - const resize = () => this.queueDesktopResize();
1334 - const resizeStart = () => this.suspendDesktopResize();
1335 - const resizeEnd = () => this.resumeDesktopResize();
1336 - const cleanup = [];
1337 - if (typeof ResizeObserver !== "undefined") {
1338 - const observer = new ResizeObserver(resize);
1339 - observer.observe(target);
1340 - cleanup.push(() => observer.disconnect());
1341 - }
1342 - globalThis.addEventListener?.("resize", resize);
1343 - cleanup.push(() => globalThis.removeEventListener?.("resize", resize));
1344 - globalThis.addEventListener?.("right-canvas-resize-start", resizeStart);
1345 - cleanup.push(() => globalThis.removeEventListener?.("right-canvas-resize-start", resizeStart));
1346 - globalThis.addEventListener?.("right-canvas-resize-end", resizeEnd);
1347 - cleanup.push(() => globalThis.removeEventListener?.("right-canvas-resize-end", resizeEnd));
1348 - this._desktopResizeTarget = target;
1349 - this._desktopResizeCleanup = () => cleanup.splice(0).reverse().forEach((entry) => entry());
1350 - resize();
1351 - },
1352 -
1353 - stopDesktopResizeObserver() {
1354 - if (this._desktopResizeTimer) {
1355 - globalThis.clearTimeout(this._desktopResizeTimer);
1356 - }
1357 - this._desktopResizeTimer = null;
1358 - this._desktopResizeCleanup?.();
1359 - this._desktopResizeCleanup = null;
1360 - this._desktopResizeTarget = null;
1361 - this._desktopResizeKey = "";
1362 - this._desktopResizePendingKey = "";
1363 - this._desktopResizeSuspended = false;
1364 - this._desktopResizePending = false;
1365 - },
1366 -
1367 - suspendDesktopResize() {
1368 - this._desktopResizeSuspended = true;
1369 - if (this._desktopResizeTimer) {
1370 - globalThis.clearTimeout(this._desktopResizeTimer);
1371 - this._desktopResizeTimer = null;
1372 - }
1373 - this._desktopResizePendingKey = "";
1374 - },
1375 -
1376 - resumeDesktopResize() {
1377 - const hadPendingResize = this._desktopResizePending;
1378 - this._desktopResizeSuspended = false;
1379 - this._desktopResizePending = false;
1380 - if (hadPendingResize || this.hasOfficialOffice()) {
1381 - this.queueDesktopResize({ force: true });
1382 - }
1383 - },
1384 -
1385 - shouldDeferDesktopResize() {
1386 - return Boolean(
1387 - this._desktopResizeSuspended
1388 - || document.body?.classList?.contains("right-canvas-resizing")
1389 - || document.querySelector?.(".modal-inner.office-modal.is-resizing")
1390 - );
1391 - },
1392 -
1393 - clearDesktopViewportSyncTimers() {
1394 - for (const timer of this._desktopViewportSyncTimers.splice(0)) {
1395 - globalThis.clearTimeout(timer);
1396 - }
1397 - },
1398 -
1399 - requestDesktopViewportSync(options = {}) {
1400 - if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return;
1401 - if (options.force) this.clearDesktopViewportSyncTimers();
1402 - const run = (force = false) => {
1403 - this.syncDesktopViewport({ ...options, force });
1404 - };
1405 - if (globalThis.requestAnimationFrame) {
1406 - globalThis.requestAnimationFrame(() => run(Boolean(options.force)));
1407 - } else {
1408 - globalThis.setTimeout(() => run(Boolean(options.force)), 0);
1409 - }
1410 - if (options.followup === false) return;
1411 - const timer = globalThis.setTimeout(() => {
1412 - this._desktopViewportSyncTimers = this._desktopViewportSyncTimers.filter((item) => item !== timer);
1413 - run(false);
1414 - }, options.force ? 260 : 180);
1415 - this._desktopViewportSyncTimers.push(timer);
1416 - },
1417 -
1418 - syncDesktopViewport(options = {}) {
1419 - if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return false;
1420 - const frame = this.desktopFrame(options.frame || null);
1421 - if (!frame) return false;
1422 - this.startDesktopResizeObserver();
1423 - this.primeXpraDesktopFrame({ reset: true, frame });
1424 - this.queueDesktopResize({
1425 - force: Boolean(options.force),
1426 - serverResize: options.serverResize !== false,
1427 - frame,
1428 - });
1429 - this.updateDesktopMonitor();
1430 - return true;
1431 - },
1432 -
1433 - primeXpraDesktopFrame(options = {}) {
1434 - if (options.reset) {
1435 - this.stopXpraDesktopPrime();
1436 - this._desktopPrimeAttempts = 0;
1437 - }
1438 - if (this.applyXpraDesktopFrameMode(options.frame || null)) return;
1439 - if (this._desktopPrimeAttempts >= XPRA_DESKTOP_PRIME_ATTEMPTS) return;
1440 - this._desktopPrimeAttempts += 1;
1441 - if (this._desktopPrimeTimer) globalThis.clearTimeout(this._desktopPrimeTimer);
1442 - this._desktopPrimeTimer = globalThis.setTimeout(() => {
1443 - this._desktopPrimeTimer = null;
1444 - this.primeXpraDesktopFrame();
1445 - }, XPRA_DESKTOP_PRIME_INTERVAL_MS);
1446 - },
1447 -
1448 - stopXpraDesktopPrime() {
1449 - if (this._desktopPrimeTimer) globalThis.clearTimeout(this._desktopPrimeTimer);
1450 - this._desktopPrimeTimer = null;
1451 - },
1452 -
1453 - applyXpraDesktopFrameMode(preferredFrame = null, options = {}) {
1454 - const frame = this.desktopFrame(preferredFrame);
1455 - const remoteWindow = frame?.contentWindow;
1456 - if (!remoteWindow) return false;
1457 - const requestServerResize = options.requestServerResize === true;
1458 - const requestRefresh = options.requestRefresh !== false;
1459 - try {
1460 - const remoteDocument = frame.contentDocument || remoteWindow.document;
1461 - this.installXpraDesktopFrameCss(remoteDocument);
1462 - this.installXpraDesktopFramePatches(remoteWindow, remoteDocument);
1463 - const client = remoteWindow.client;
1464 - if (!client) return false;
1465 - this.installXpraDesktopClientPatches(remoteWindow, client);
1466 - this.installXpraDesktopCursorPatches(remoteWindow, remoteDocument, client);
1467 - this.installXpraDesktopKeyboardBridge(frame, remoteWindow, remoteDocument, client);
1468 - this.installXpraDesktopClipboardBridge(frame, remoteWindow, remoteDocument, client);
1469 - const container = client.container || remoteDocument?.querySelector?.("#screen");
1470 - if (!container) return false;
1471 -
1472 - client.server_is_desktop = true;
1473 - client.server_resize_exact = true;
1474 - remoteDocument?.body?.classList?.add("desktop");
1475 -
1476 - const windows = Object.values(client.id_to_window || {});
1477 - if (!client.connected || !windows.length) return false;
1478 -
1479 - const width = Math.round(container.clientWidth || remoteWindow.innerWidth || 0);
1480 - const height = Math.round(container.clientHeight || remoteWindow.innerHeight || 0);
1481 - if (width > 0 && height > 0) {
1482 - client.desktop_width = width;
1483 - client.desktop_height = height;
1484 - }
1485 - if (requestServerResize && width > 0 && height > 0 && typeof client._screen_resized === "function") {
1486 - client.desktop_width = 0;
1487 - client.desktop_height = 0;
1488 - client.__a0AllowScreenResize = true;
1489 - try {
1490 - client._screen_resized(new remoteWindow.Event("resize"));
1491 - } finally {
1492 - client.__a0AllowScreenResize = false;
1493 - }
1494 - }
1495 -
1496 - for (const xpraWindow of windows) {
1497 - this.normalizeXpraDesktopWindow(xpraWindow, width, height);
1498 - xpraWindow.screen_resized?.();
1499 - this.normalizeXpraDesktopWindow(xpraWindow, width, height);
1500 - xpraWindow.updateCSSGeometry?.();
1501 - this.fitXpraDesktopWindowElement(xpraWindow, width, height);
1502 - this.installXpraDesktopWheelBridge(remoteWindow, xpraWindow);
1503 - if (requestRefresh && xpraWindow.wid != null) client.request_refresh?.(xpraWindow.wid);
1504 - }
1505 - this.installXpraDesktopAgentBridge(frame, remoteWindow, remoteDocument, client, container);
1506 - return true;
1507 - } catch (error) {
1508 - console.warn("Xpra desktop viewport prime skipped", error);
1509 - return false;
1510 - }
1511 - },
1512 -
1513 - installXpraDesktopAgentBridge(frame, remoteWindow, remoteDocument, client, container) {
1514 - if (!frame || !remoteWindow || !remoteDocument || !client) return null;
1515 - const store = this;
1516 - const finite = (value, fallback = 0) => {
1517 - const number = Number(value);
1518 - return Number.isFinite(number) ? number : fallback;
1519 - };
1520 - const metrics = () => {
1521 - const desktopWidth = Math.max(1, finite(client.desktop_width || container?.clientWidth || remoteWindow.innerWidth, 1));
1522 - const desktopHeight = Math.max(1, finite(client.desktop_height || container?.clientHeight || remoteWindow.innerHeight, 1));
1523 - const clientWidth = Math.max(1, finite(container?.clientWidth || remoteWindow.innerWidth, desktopWidth));
1524 - const clientHeight = Math.max(1, finite(container?.clientHeight || remoteWindow.innerHeight, desktopHeight));
1525 - return {
1526 - desktopWidth,
1527 - desktopHeight,
1528 - clientWidth,
1529 - clientHeight,
1530 - scaleX: clientWidth / desktopWidth,
1531 - scaleY: clientHeight / desktopHeight,
1532 - };
1533 - };
1534 - const bridge = frame.__agentZeroDesktopBridge || {};
1535 - Object.assign(bridge, {
1536 - ready: true,
1537 - state: async (options = {}) => {
1538 - const result = await callOffice("desktop_state", {
1539 - include_screenshot: options.includeScreenshot === true || options.include_screenshot === true,
1540 - });
1541 - store._desktopLastState = result;
1542 - return result;
1543 - },
1544 - focus: (options = {}) => store.focusDesktopFrame(frame, { ...options, arm: options.arm !== false }),
1545 - requestRefresh: () => {
1546 - for (const xpraWindow of Object.values(client.id_to_window || {})) {
1547 - if (xpraWindow?.wid != null) client.request_refresh?.(xpraWindow.wid);
1548 - }
1549 - return true;
1550 - },
1551 - desktopToClient: (x, y) => {
1552 - const value = metrics();
1553 - return {
1554 - x: Math.round(finite(x) * value.scaleX),
1555 - y: Math.round(finite(y) * value.scaleY),
1556 - scale_x: value.scaleX,
1557 - scale_y: value.scaleY,
1558 - };
1559 - },
1560 - clientToDesktop: (x, y) => {
1561 - const value = metrics();
1562 - return {
1563 - x: Math.round(finite(x) / value.scaleX),
1564 - y: Math.round(finite(y) / value.scaleY),
1565 - scale_x: value.scaleX,
1566 - scale_y: value.scaleY,
1567 - };
1568 - },
1569 - diagnostics: () => store.desktopBridgeDiagnostics(frame),
1570 - });
1571 - frame.agentZeroDesktop = bridge;
1572 - frame.__agentZeroDesktopBridge = bridge;
1573 - remoteWindow.agentZeroDesktop = bridge;
1574 - remoteWindow.__agentZeroDesktopBridge = bridge;
1575 - this._desktopBridgeReady = true;
1576 - this.updateDesktopKeyboardCaptureState(frame);
1577 - return bridge;
1578 - },
1579 -
1580 - desktopBridgeDiagnostics(frame = null) {
1581 - return {
1582 - ready: this._desktopBridgeReady,
1583 - keyboard: this.updateDesktopKeyboardCaptureState(frame),
1584 - lastStateOk: this._desktopLastState?.ok ?? null,
1585 - };
1586 - },
1587 -
1588 - updateDesktopKeyboardCaptureState(frame = null) {
1589 - const target = this.desktopFrame(frame);
1590 - const client = target?.contentWindow?.client;
1591 - const state = {
1592 - ready: Boolean(target?.__agentZeroDesktopBridge || target?.contentWindow?.__agentZeroDesktopBridge),
1593 - active: Boolean(this._desktopKeyboardActive),
1594 - capture: Boolean(client?.capture_keyboard),
1595 - focused: Boolean(target && (document.activeElement === target || target.contentDocument?.hasFocus?.())),
1596 - };
1597 - this._desktopKeyboardCaptureState = state;
1598 - return state;
1599 - },
1600 -
1601 - normalizeXpraDesktopWindow(xpraWindow, width, height) {
1602 - if (!xpraWindow) return;
1603 - const normalizedWidth = Math.max(1, Math.round(Number(width || 0)));
1604 - const normalizedHeight = Math.max(1, Math.round(Number(height || 0)));
1605 - xpraWindow.x = 0;
1606 - xpraWindow.y = 0;
1607 - xpraWindow.w = normalizedWidth;
1608 - xpraWindow.h = normalizedHeight;
1609 - xpraWindow.resizable = false;
1610 - xpraWindow.decorations = false;
1611 - xpraWindow.decorated = false;
1612 - xpraWindow.metadata = { ...(xpraWindow.metadata || {}), decorations: false };
1613 - xpraWindow._set_decorated?.(false);
1614 - xpraWindow.configure_border_class?.();
1615 - xpraWindow.leftoffset = 0;
1616 - xpraWindow.rightoffset = 0;
1617 - xpraWindow.topoffset = 0;
1618 - xpraWindow.bottomoffset = 0;
1619 - },
1620 -
1621 - fitXpraDesktopWindowElement(xpraWindow, width, height) {
1622 - const cssWidth = `${Math.max(1, Number(width || 0))}px`;
1623 - const cssHeight = `${Math.max(1, Number(height || 0))}px`;
1624 - const windowElement = xpraWindow?.div;
1625 - const canvas = xpraWindow?.canvas;
1626 - windowElement?.style?.setProperty("left", "0px", "important");
1627 - windowElement?.style?.setProperty("top", "0px", "important");
1628 - windowElement?.style?.setProperty("position", "absolute", "important");
1629 - windowElement?.style?.setProperty("width", cssWidth, "important");
1630 - windowElement?.style?.setProperty("height", cssHeight, "important");
1631 - windowElement?.style?.setProperty("transform", "none", "important");
1632 - windowElement?.style?.setProperty("margin", "0", "important");
1633 - canvas?.style?.setProperty("width", cssWidth, "important");
1634 - canvas?.style?.setProperty("height", cssHeight, "important");
1635 - canvas?.style?.setProperty("display", "block", "important");
1636 - canvas?.style?.setProperty("margin", "0", "important");
1637 - },
1638 -
1639 - installXpraDesktopWheelBridge(remoteWindow, xpraWindow) {
1640 - const canvas = xpraWindow?.canvas;
1641 - if (!remoteWindow || !canvas || canvas.__a0XpraWheelBridgeInstalled) return;
1642 - if (typeof xpraWindow.mouse_scroll_cb !== "function") return;
1643 - canvas.__a0XpraWheelBridgeInstalled = true;
1644 - canvas.addEventListener("wheel", (event) => {
1645 - event.stopImmediatePropagation?.();
1646 - event.stopPropagation?.();
1647 - event.preventDefault?.();
1648 - const normalizedEvent = this.xpraDesktopWheelEvent(remoteWindow, canvas, event);
1649 - xpraWindow.mouse_scroll_cb(normalizedEvent, xpraWindow);
1650 - }, { passive: false, capture: true });
1651 - },
1652 -
1653 - xpraDesktopWheelEvent(remoteWindow, canvas, event) {
1654 - const finite = (value, fallback = 0) => {
1655 - const number = Number(value);
1656 - return Number.isFinite(number) ? number : fallback;
1657 - };
1658 - const deltaMode = finite(event.deltaMode, 0);
1659 - const lineHeight = 16;
1660 - const pageHeight = Math.max(1, remoteWindow.innerHeight || canvas.clientHeight || 800);
1661 - const deltaScale = deltaMode === 1 ? lineHeight : deltaMode === 2 ? pageHeight : 1;
1662 - const deltaX = finite(event.deltaX) * deltaScale;
1663 - const deltaY = finite(event.deltaY) * deltaScale;
1664 - const deltaZ = finite(event.deltaZ) * deltaScale;
1665 - const wheelDeltaX = finite(event.wheelDeltaX, -deltaX);
1666 - const wheelDeltaY = finite(event.wheelDeltaY, -deltaY);
1667 - const wheelDelta = finite(event.wheelDelta, wheelDeltaY || wheelDeltaX);
1668 - const getModifierState = (key) => {
1669 - if (typeof event.getModifierState === "function") return event.getModifierState(key);
1670 - const normalizedKey = String(key || "").toLowerCase();
1671 - if (normalizedKey === "alt") return Boolean(event.altKey);
1672 - if (normalizedKey === "control") return Boolean(event.ctrlKey);
1673 - if (normalizedKey === "meta") return Boolean(event.metaKey);
1674 - if (normalizedKey === "shift") return Boolean(event.shiftKey);
1675 - return false;
1676 - };
1677 - const normalizedEvent = Object.create(event);
1678 - Object.defineProperties(normalizedEvent, {
1679 - target: { value: event.target || canvas },
1680 - currentTarget: { value: canvas },
1681 - clientX: { value: finite(event.clientX) },
1682 - clientY: { value: finite(event.clientY) },
1683 - pageX: { value: finite(event.pageX, finite(event.clientX)) },
1684 - pageY: { value: finite(event.pageY, finite(event.clientY)) },
1685 - screenX: { value: finite(event.screenX) },
1686 - screenY: { value: finite(event.screenY) },
1687 - offsetX: { value: finite(event.offsetX) },
1688 - offsetY: { value: finite(event.offsetY) },
1689 - movementX: { value: finite(event.movementX) },
1690 - movementY: { value: finite(event.movementY) },
1691 - button: { value: finite(event.button) },
1692 - buttons: { value: finite(event.buttons) },
1693 - which: { value: finite(event.which) },
1694 - detail: { value: finite(event.detail) },
1695 - deltaX: { value: deltaX },
1696 - deltaY: { value: deltaY },
1697 - deltaZ: { value: deltaZ },
1698 - deltaMode: { value: 0 },
1699 - wheelDeltaX: { value: wheelDeltaX },
1700 - wheelDeltaY: { value: wheelDeltaY },
1701 - wheelDelta: { value: wheelDelta },
1702 - altKey: { value: Boolean(event.altKey) },
1703 - ctrlKey: { value: Boolean(event.ctrlKey) },
1704 - metaKey: { value: Boolean(event.metaKey) },
1705 - shiftKey: { value: Boolean(event.shiftKey) },
1706 - getModifierState: { value: getModifierState },
1707 - preventDefault: { value: () => event.preventDefault?.() },
1708 - stopPropagation: { value: () => event.stopPropagation?.() },
1709 - stopImmediatePropagation: { value: () => event.stopImmediatePropagation?.() },
1710 - });
1711 - return normalizedEvent;
1712 - },
1713 -
1714 - installXpraDesktopFrameCss(remoteDocument) {
1715 - if (!remoteDocument || remoteDocument.getElementById("a0-xpra-desktop-frame-css")) return;
1716 - const style = remoteDocument.createElement("style");
1717 - style.id = "a0-xpra-desktop-frame-css";
1718 - style.textContent = `
1719 - html, body, #screen {
1720 - width: 100% !important;
1721 - height: 100% !important;
1722 - overflow: hidden !important;
1723 - }
1724 - #float_menu,
1725 - .windowhead,
1726 - .windowbuttons {
1727 - display: none !important;
1728 - }
1729 - #shadow_pointer {
1730 - display: none !important;
1731 - visibility: hidden !important;
1732 - opacity: 0 !important;
1733 - }
1734 - .window,
1735 - .window.border,
1736 - .window.desktop,
1737 - .undecorated,
1738 - .undecorated.border,
1739 - .undecorated.desktop {
1740 - left: 0 !important;
1741 - top: 0 !important;
1742 - position: absolute !important;
1743 - width: 100% !important;
1744 - height: 100% !important;
1745 - transform: none !important;
1746 - margin: 0 !important;
1747 - border: 0 !important;
1748 - border-radius: 0 !important;
1749 - box-shadow: none !important;
1750 - }
1751 - .window canvas,
1752 - .undecorated canvas {
1753 - display: block !important;
1754 - width: 100% !important;
1755 - height: 100% !important;
1756 - margin: 0 !important;
1757 - border: 0 !important;
1758 - border-radius: 0 !important;
1759 - box-shadow: none !important;
1760 - }
1761 - `;
1762 - remoteDocument.head?.appendChild(style);
1763 - },
1764 -
1765 - installXpraDesktopCursorPatches(remoteWindow, remoteDocument, client) {
1766 - if (!remoteWindow || !remoteDocument || !client) return;
1767 - const hideShadowPointer = () => {
1768 - const pointer = remoteDocument.getElementById?.("shadow_pointer");
1769 - pointer?.style?.setProperty("display", "none", "important");
1770 - pointer?.style?.setProperty("visibility", "hidden", "important");
1771 - pointer?.style?.setProperty("opacity", "0", "important");
1772 - };
1773 - hideShadowPointer();
1774 -
1775 - const pointerPacket = remoteWindow.PACKET_TYPES?.pointer_position || "pointer-position";
1776 - if (!client.__a0XpraDesktopCursorPatched) {
1777 - if (typeof client._process_pointer_position === "function") {
1778 - client.__a0OriginalProcessPointerPosition = client._process_pointer_position;
1779 - }
1780 - client._process_pointer_position = function patchedProcessPointerPosition(packet) {
1781 - hideShadowPointer();
1782 - this.__a0LastPointerPosition = packet;
1783 - return false;
1784 - };
1785 - client.__a0XpraDesktopCursorPatched = true;
1786 - }
1787 - if (client.packet_handlers && pointerPacket) {
1788 - client.packet_handlers[pointerPacket] = client._process_pointer_position;
1789 - }
1790 - },
1791 -
1792 - installXpraDesktopFramePatches(remoteWindow, remoteDocument) {
1793 - if (!remoteWindow || !remoteDocument) return;
1794 - remoteWindow.__a0XpraDesktopFramePatches ||= {};
1795 - const patches = remoteWindow.__a0XpraDesktopFramePatches;
1796 - if (!patches.noWindowList && typeof remoteWindow.noWindowList === "function") {
1797 - const originalNoWindowList = remoteWindow.noWindowList;
1798 - remoteWindow.noWindowList = function patchedNoWindowList(...args) {
1799 - if (!remoteDocument.querySelector("#open_windows")) return undefined;
1800 - return originalNoWindowList.apply(this, args);
1801 - };
1802 - patches.noWindowList = true;
1803 - }
1804 - if (!patches.addWindowListItem && typeof remoteWindow.addWindowListItem === "function") {
1805 - const originalAddWindowListItem = remoteWindow.addWindowListItem;
1806 - remoteWindow.addWindowListItem = function patchedAddWindowListItem(...args) {
1807 - if (!remoteDocument.querySelector("#open_windows_list")) return undefined;
1808 - return originalAddWindowListItem.apply(this, args);
1809 - };
1810 - patches.addWindowListItem = true;
1811 - }
1812 - },
1813 -
1814 - installXpraDesktopClientPatches(remoteWindow, client) {
1815 - if (!remoteWindow || !client || client.__a0XpraDesktopClientPatched) return;
1816 - if (typeof client._screen_resized === "function") {
1817 - const originalScreenResized = client._screen_resized.bind(client);
1818 - client.__a0OriginalScreenResized = originalScreenResized;
1819 - client._screen_resized = function patchedScreenResized(event) {
1820 - if (client.__a0AllowScreenResize === true) return originalScreenResized(event);
1821 - return false;
1822 - };
1823 - }
1824 - client.__a0XpraDesktopClientPatched = true;
1825 - },
1826 -
1827 - installXpraDesktopClipboardBridge(frame, remoteWindow, remoteDocument, client) {
1828 - if (!frame || !remoteWindow || !remoteDocument || !client) return;
1829 - this.ensureDesktopClipboardBridge();
1830 - if (remoteWindow.__a0XpraDesktopClipboardBridgeInstalled) return;
1831 -
1832 - const onPaste = (event) => {
1833 - this.handleDesktopPasteEvent(event, frame, remoteWindow, client);
1834 - };
1835 - const onKeydown = (event) => {
1836 - if (this.isDesktopPasteShortcut(event)) {
1837 - void this.syncHostClipboardToDesktop(frame);
1838 - }
1839 - };
1840 - remoteWindow.addEventListener("paste", onPaste, true);
1841 - remoteDocument.addEventListener("paste", onPaste, true);
1842 - remoteWindow.addEventListener("keydown", onKeydown, true);
1843 - remoteDocument.addEventListener("keydown", onKeydown, true);
1844 - remoteWindow.__a0XpraDesktopClipboardBridgeInstalled = true;
1845 - remoteWindow.__a0XpraDesktopClipboardBridgeCleanup = () => {
1846 - remoteWindow.removeEventListener("paste", onPaste, true);
1847 - remoteDocument.removeEventListener("paste", onPaste, true);
1848 - remoteWindow.removeEventListener("keydown", onKeydown, true);
1849 - remoteDocument.removeEventListener("keydown", onKeydown, true);
1850 - remoteWindow.__a0XpraDesktopClipboardBridgeInstalled = false;
1851 - };
1852 - },
1853 -
1854 - ensureDesktopClipboardBridge() {
1855 - if (this._desktopClipboardCleanup) return;
1856 -
1857 - const onPaste = (event) => {
1858 - if (!this._desktopKeyboardActive || !this.hasOfficialOffice()) return;
1859 - if (isEditableInputTarget(event.target)) return;
1860 - const frame = this.desktopFrame();
1861 - const remoteWindow = frame?.contentWindow;
1862 - const client = remoteWindow?.client;
1863 - if (!frame || !remoteWindow || !client) return;
1864 - this.handleDesktopPasteEvent(event, frame, remoteWindow, client);
1865 - };
1866 -
1867 - document.addEventListener("paste", onPaste, true);
1868 - this._desktopClipboardCleanup = () => {
1869 - document.removeEventListener("paste", onPaste, true);
1870 - this._desktopClipboardCleanup = null;
1871 - };
1872 - },
1873 -
1874 - stopDesktopClipboardBridge() {
1875 - this._desktopClipboardCleanup?.();
1876 - },
1877 -
1878 - handleDesktopPasteEvent(event, frame, remoteWindow, client) {
1879 - const text = this.desktopClipboardTextFromEvent(event);
1880 - if (!text) return false;
1881 - if (!this.syncXpraClipboardText(client, text, remoteWindow)) return false;
1882 - event.preventDefault?.();
1883 - event.stopImmediatePropagation?.();
1884 - event.stopPropagation?.();
1885 - this.focusDesktopFrame(frame, { arm: true });
1886 - return true;
1887 - },
1888 -
1889 - desktopClipboardTextFromEvent(event) {
1890 - const data = (event?.originalEvent || event)?.clipboardData;
1891 - if (!data?.getData) return "";
1892 - for (const type of ["text/plain", "text", "Text", "STRING", "UTF8_STRING"]) {
1893 - const value = data.getData(type);
1894 - if (value) return value;
1895 - }
1896 - return "";
1897 - },
1898 -
1899 - syncXpraClipboardText(client, text, remoteWindow = null) {
1900 - const value = String(text ?? "");
1901 - if (!client || !value || typeof client.send_clipboard_token !== "function") return false;
1902 - const textPlain = remoteWindow?.TEXT_PLAIN || "text/plain";
1903 - const utf8String = remoteWindow?.UTF8_STRING || "UTF8_STRING";
1904 - const utilities = remoteWindow?.Utilities;
1905 - const payload = utilities?.StringToUint8 ? utilities.StringToUint8(value) : value;
1906 - client.clipboard_enabled = true;
1907 - client.clipboard_direction = "both";
1908 - client.clipboard_buffer = value;
1909 - client.clipboard_pending = false;
1910 - client.send_clipboard_token(payload, [textPlain, utf8String, "TEXT", "STRING"]);
1911 - return true;
1912 - },
1913 -
1914 - async syncHostClipboardToDesktop(frame = null) {
1915 - const target = this.desktopFrame(frame);
1916 - const remoteWindow = target?.contentWindow;
1917 - const client = remoteWindow?.client;
1918 - if (!client || !navigator.clipboard?.readText) return false;
1919 - try {
1920 - const text = await navigator.clipboard.readText();
1921 - return this.syncXpraClipboardText(client, text, remoteWindow);
1922 - } catch {
1923 - return false;
1924 - }
1925 - },
1926 -
1927 - isDesktopPasteShortcut(event) {
1928 - const key = String(event?.key || "").toLowerCase();
1929 - return key === "v" && (event?.ctrlKey || event?.metaKey) && !event?.altKey;
1930 - },
1931 -
1932 - installXpraDesktopKeyboardBridge(frame, remoteWindow, remoteDocument, client) {
1933 - if (!frame || !remoteWindow || !remoteDocument || !client) return;
1934 - this.ensureDesktopKeyboardBridge();
1935 - frame.setAttribute("tabindex", "0");
1936 - if (remoteWindow.__a0XpraDesktopKeyboardBridgeInstalled) return;
1937 -
1938 - const activate = () => {
1939 - if (this._desktopFocusInProgress) return;
1940 - this.focusDesktopFrame(frame, { arm: true });
1941 - };
1942 - const events = ["pointerdown", "mousedown", "touchstart", "focusin"];
1943 - for (const eventName of events) {
1944 - remoteDocument.addEventListener(eventName, activate, true);
1945 - }
1946 - remoteWindow.addEventListener("focus", activate, true);
1947 - remoteWindow.__a0XpraDesktopKeyboardBridgeInstalled = true;
1948 - remoteWindow.__a0XpraDesktopKeyboardBridgeCleanup = () => {
1949 - for (const eventName of events) {
1950 - remoteDocument.removeEventListener(eventName, activate, true);
1951 - }
1952 - remoteWindow.removeEventListener("focus", activate, true);
1953 - remoteWindow.__a0XpraDesktopKeyboardBridgeInstalled = false;
1954 - };
1955 - },
1956 -
1957 - ensureDesktopKeyboardBridge() {
1958 - if (this._desktopKeyboardCleanup) return;
1959 -
1960 - const deactivateWhenOutsideDesktop = (event) => {
1961 - const target = event.target;
1962 - if (target?.closest?.(".office-desktop-wrap") || target?.matches?.("[data-office-desktop-frame]")) return;
1963 - this._desktopKeyboardActive = false;
1964 - };
1965 - const forwardKeyboardEvent = (event, pressed) => {
1966 - if (!this._desktopKeyboardActive || !this.hasOfficialOffice()) return;
1967 - if (event.defaultPrevented || isEditableInputTarget(event.target)) return;
1968 -
1969 - const frame = this.desktopFrame();
1970 - if (!frame || document.activeElement === frame) return;
1971 - const client = frame.contentWindow?.client;
1972 - const handler = pressed ? client?._keyb_onkeydown : client?._keyb_onkeyup;
1973 - if (!client?.capture_keyboard || typeof handler !== "function") return;
1974 - if (pressed && this.isDesktopPasteShortcut(event)) {
1975 - void this.syncHostClipboardToDesktop(frame);
1976 - }
1977 -
1978 - const allowDefault = handler.call(client, event);
1979 - if (!allowDefault) {
1980 - event.preventDefault();
1981 - event.stopPropagation();
1982 - }
1983 - };
1984 - const onKeydown = (event) => forwardKeyboardEvent(event, true);
1985 - const onKeyup = (event) => forwardKeyboardEvent(event, false);
1986 -
1987 - document.addEventListener("pointerdown", deactivateWhenOutsideDesktop, true);
1988 - document.addEventListener("keydown", onKeydown, true);
1989 - document.addEventListener("keyup", onKeyup, true);
1990 - this._desktopKeyboardCleanup = () => {
1991 - document.removeEventListener("pointerdown", deactivateWhenOutsideDesktop, true);
1992 - document.removeEventListener("keydown", onKeydown, true);
1993 - document.removeEventListener("keyup", onKeyup, true);
1994 - this._desktopKeyboardActive = false;
1995 - this._desktopKeyboardCleanup = null;
1996 - };
1997 - },
1998 -
1999 - stopDesktopKeyboardBridge() {
2000 - this._desktopKeyboardCleanup?.();
2001 - },
2002 -
2003 - queueDesktopResize(options = {}) {
2004 - if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return;
2005 - const token = this.session?.desktop?.token || "";
2006 - const frame = this.desktopFrame(options.frame || null);
2007 - const target = frame?.parentElement || frame;
2008 - if (!token || !target) return;
2009 - const force = Boolean(options.force);
2010 - const serverResize = options.serverResize !== false;
2011 - const rect = target.getBoundingClientRect();
2012 - const width = Math.round(rect.width);
2013 - const height = Math.round(rect.height);
2014 - if (width < 320 || height < 220) return;
2015 - const key = `${token}:${width}x${height}`;
2016 - const refreshFrameOnly = () => {
2017 - this.applyXpraDesktopFrameMode(frame, { requestServerResize: false, requestRefresh: false });
2018 - };
2019 - if (!serverResize) {
2020 - refreshFrameOnly();
2021 - return;
2022 - }
2023 - if (key === this._desktopResizeKey || key === this._desktopResizePendingKey) {
2024 - refreshFrameOnly();
2025 - return;
2026 - }
2027 - refreshFrameOnly();
2028 - if (!force && this.shouldDeferDesktopResize()) {
2029 - this._desktopResizePending = true;
2030 - return;
2031 - }
2032 - if (this._desktopResizeTimer) globalThis.clearTimeout(this._desktopResizeTimer);
2033 - this._desktopResizePendingKey = key;
2034 - this._desktopResizeTimer = globalThis.setTimeout(async () => {
2035 - this._desktopResizeTimer = null;
2036 - if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) {
2037 - if (this._desktopResizePendingKey === key) this._desktopResizePendingKey = "";
2038 - return;
2039 - }
2040 - if (!force && this.shouldDeferDesktopResize()) {
2041 - if (this._desktopResizePendingKey === key) this._desktopResizePendingKey = "";
2042 - this._desktopResizePending = true;
2043 - return;
2044 - }
2045 - try {
2046 - const params = new URLSearchParams({ token, width: String(width), height: String(height) });
2047 - const response = await fetch(`/desktop/resize?${params.toString()}`, { credentials: "same-origin" });
2048 - if (response.ok) {
2049 - const result = await response.json().catch(() => ({}));
2050 - this._desktopResizeKey = key;
2051 - const activeFrame = this.desktopFrame(frame);
2052 - const activeTarget = activeFrame?.parentElement || activeFrame;
2053 - const activeRect = activeTarget?.getBoundingClientRect?.();
2054 - const activeWidth = Math.round(activeRect?.width || 0);
2055 - const activeHeight = Math.round(activeRect?.height || 0);
2056 - if (activeWidth >= 320 && activeHeight >= 220) {
2057 - const activeKey = `${token}:${activeWidth}x${activeHeight}`;
2058 - if (activeKey !== key) {
2059 - this.queueDesktopResize({ force: true, serverResize: true, frame: activeFrame });
2060 - return;
2061 - }
2062 - }
2063 - if (result?.reload) this.reloadDesktopFrame(activeFrame || frame);
2064 - this.primeXpraDesktopFrame({ reset: true, frame: activeFrame || frame });
2065 - }
2066 - } catch (error) {
2067 - console.warn("Desktop resize skipped", error);
2068 - } finally {
2069 - if (this._desktopResizePendingKey === key) this._desktopResizePendingKey = "";
2070 - }
2071 - }, DESKTOP_RESIZE_DELAY_MS);
2072 - },
2073 -
2074 - reloadDesktopFrame(frame = null) {
2075 - const target = this.desktopFrame(frame);
2076 - if (!target) return;
2077 - const current = target.getAttribute("src") || target.src || this.officialOfficeUrl();
2078 - if (!current) return;
2079 - try {
2080 - const url = new URL(current, window.location.href);
2081 - url.searchParams.set("a0_reload", String(Date.now()));
2082 - target.setAttribute("src", `${url.pathname}${url.search}`);
2083 - } catch {
2084 - target.setAttribute("src", current);
2085 - }
2086 - },
2087 -
2088 - async handleDesktopUrlIntents(intents = []) {
2089 - const incoming = Array.isArray(intents)
2090 - ? intents.filter((intent) => intent && typeof intent === "object")
2091 - : [];
2092 - if (!incoming.length) return;
2093 - this._desktopUrlIntentQueue.push(...incoming);
2094 - if (this._desktopUrlIntentBusy) return;
2095 -
2096 - this._desktopUrlIntentBusy = true;
2097 - try {
2098 - while (this._desktopUrlIntentQueue.length) {
2099 - const intent = this._desktopUrlIntentQueue.shift();
2100 - await this.openDesktopUrlIntent(intent);
2101 - }
2102 - } finally {
2103 - this._desktopUrlIntentBusy = false;
2104 - }
2105 - },
2106 -
2107 - async openDesktopUrlIntent(intent = {}) {
2108 - const url = String(intent?.url || "").trim();
2109 - const destination = this.browserDestinationForDesktopUrl();
2110 - if (destination === "canvas") {
2111 - await this.openBrowserCanvasForDesktopUrl(url);
2112 - } else {
2113 - await this.openBrowserModalForDesktopUrl(url);
2114 - }
2115 - this.setMessage(url ? "Opened link in Browser" : "Opened Browser");
2116 - },
2117 -
2118 - browserDestinationForDesktopUrl() {
2119 - if (this.isDesktopInModal()) return "canvas";
2120 - return "modal";
2121 - },
2122 -
2123 - isDesktopInModal() {
2124 - if (isModalPathOpen(OFFICE_MODAL_PATH)) return true;
2125 - const modalDesktop = Array.from(document.querySelectorAll(".office-panel"))
2126 - .some((panel) => panel.closest?.(".modal") && panel.querySelector?.("[data-office-desktop-frame]"));
2127 - if (modalDesktop) return true;
2128 - if (rightCanvasStore?.isOpen && rightCanvasStore.activeSurfaceId === "office") return false;
2129 - return this._mode === "modal";
2130 - },
2131 -
2132 - async openBrowserCanvasForDesktopUrl(url = "") {
2133 - if (rightCanvasStore?.isMobileMode) {
2134 - await this.openBrowserModalForDesktopUrl(url);
2135 - return;
2136 - }
2137 - const payload = { url, source: "desktop-url" };
2138 - let opened = false;
2139 - if (isModalPathOpen(BROWSER_MODAL_PATH)) {
2140 - opened = await rightCanvasStore.dockSurface?.("browser", {
2141 - ...payload,
2142 - modalPath: BROWSER_MODAL_PATH,
2143 - sourceModalPath: BROWSER_MODAL_PATH,
2144 - });
2145 - } else {
2146 - opened = await rightCanvasStore.open?.("browser", payload);
2147 - }
2148 - if (!opened) {
2149 - await this.openBrowserModalForDesktopUrl(url);
2150 - return;
2151 - }
2152 - if (browserStore?.openUrlIntent) {
2153 - await browserStore.openUrlIntent(url);
2154 - }
2155 - },
2156 -
2157 - async openBrowserModalForDesktopUrl(url = "") {
2158 - if (rightCanvasStore?.isOpen && rightCanvasStore.activeSurfaceId === "browser") {
2159 - await rightCanvasStore.openModalSurface?.("browser", { modalPath: BROWSER_MODAL_PATH });
2160 - } else {
2161 - const openModal = globalThis.ensureModalOpen || globalThis.openModal;
2162 - const modalPromise = openModal?.(BROWSER_MODAL_PATH);
2163 - if (modalPromise?.catch) {
2164 - modalPromise.catch((error) => console.error("Browser modal open failed", error));
2165 - }
2166 - }
2167 - const panel = await waitForElementByPredicate(() => browserPanelForMode("modal"));
2168 - if (panel && browserStore?.onOpen) {
2169 - await browserStore.onOpen(panel, { mode: "modal" });
2170 - }
2171 - if (browserStore?.openUrlIntent) {
2172 - await browserStore.openUrlIntent(url);
2173 - }
2174 - },
2175 -
2176 - startDesktopMonitor() {
2177 - this.stopDesktopMonitor();
2178 - if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return;
2179 - const tabId = this.session?.tab_id || "";
2180 - const sessionId = this.session?.desktop_session_id || this.session?.session_id || "";
2181 - if (!tabId || !sessionId) return;
2182 - this._desktopHeartbeatSessionId = sessionId;
2183 - this._desktopHeartbeatTabId = tabId;
2184 - this._desktopHeartbeatMisses = 0;
2185 -
2186 - const tick = async () => {
2187 - if (!this.session || this.session.tab_id !== tabId || !this.hasOfficialOffice() || !this.isDesktopHostVisible()) return;
2188 - try {
2189 - const response = await callOffice("desktop_sync", {
2190 - desktop_session_id: sessionId,
2191 - file_id: this.session.file_id || "",
2192 - });
2193 - if (response?.intentional_shutdown || response?.shutdown) {
2194 - await this.handleIntentionalDesktopShutdown(response);
2195 - return;
2196 - }
2197 - if (response?.ok === false) throw new Error(response.error || "Desktop session closed.");
2198 - this._desktopHeartbeatMisses = 0;
2199 - await this.handleDesktopUrlIntents(response?.url_intents);
2200 - if (response?.document) {
2201 - const document = normalizeDocument(response.document);
2202 - this.replaceActiveSession({
2203 - ...this.session,
2204 - document,
2205 - path: document.path || this.session.path,
2206 - file_id: document.file_id || this.session.file_id,
2207 - version: document.version || this.session.version,
2208 - });
2209 - }
2210 - } catch {
2211 - if (!this.session || this.session.tab_id !== tabId) return;
2212 - this._desktopHeartbeatMisses += 1;
2213 - if (this._desktopHeartbeatMisses >= 2) {
2214 - await this.handleOfficialOfficeClosed(tabId);
2215 - }
2216 - }
2217 - };
2218 -
2219 - this._desktopHeartbeatTimer = globalThis.setInterval(tick, DESKTOP_HEARTBEAT_MS);
2220 - globalThis.setTimeout(tick, Math.min(1200, DESKTOP_HEARTBEAT_MS));
2221 - },
2222 -
2223 - stopDesktopMonitor() {
2224 - if (this._desktopHeartbeatTimer) {
2225 - globalThis.clearInterval(this._desktopHeartbeatTimer);
2226 - }
2227 - this._desktopHeartbeatTimer = null;
2228 - this._desktopHeartbeatSessionId = "";
2229 - this._desktopHeartbeatTabId = "";
2230 - this._desktopHeartbeatMisses = 0;
2231 - },
2232 -
2233 - async handleOfficialOfficeClosed(tabId) {
2234 - if (this._desktopIntentionalShutdown) return;
2235 - const tab = this.tabs.find((item) => item.tab_id === tabId);
2236 - const hiddenDesktopDocument = !tab && this.session?.tab_id === tabId && this.isDesktopOfficeDocument(this.session)
2237 - ? this.session
2238 - : null;
2239 - const target = tab || hiddenDesktopDocument;
2240 - if (!target || target._desktopClosed) return;
2241 - target._desktopClosed = true;
2242 - this.stopDesktopMonitor();
2243 - this.stopDesktopResizeObserver();
2244 - this.stopXpraDesktopPrime();
2245 - this.message = "Desktop is restarting";
2246 - await this.ensureDesktopSession({
2247 - force: true,
2248 - select: this.activeTabId === tabId || Boolean(hiddenDesktopDocument),
2249 - message: "Desktop is restarting",
2250 - });
2251 - target._desktopClosed = false;
2252 - await this.refresh();
605 + return this.tabs.filter((tab) => this.hasActiveFile(tab));
606 },
607
608 defaultTitle(kind, fmt) {
@@ -2276,7 +629,6 @@ const model = {
629 tabIcon(tab = {}) {
630 tab = tab || {};
631 const ext = String(tab.extension || tab.document?.extension || "").toLowerCase();
2279 - if (this.isDesktopSession(tab)) return "desktop_windows";
632 if (ext === "md") return "article";
633 if (ext === "odt" || ext === "docx") return "description";
634 if (ext === "ods" || ext === "xlsx") return "table_chart";
@@ -2284,6 +636,17 @@ const model = {
636 return "draft";
637 },
638
639 + async openActiveInDesktop() {
640 + const target = this.session?.document || this.session;
641 + if (!target?.path && !target?.file_id) return;
642 + await openSurface("desktop", {
643 + path: target.path || "",
644 + file_id: target.file_id || "",
645 + refresh: true,
646 + source: "office-explicit-action",
647 + });
648 + },
649 +
650 async runNewMenuAction(action = "") {
651 const normalized = String(action || "").trim().toLowerCase();
652 if (normalized === "open") return await this.openFileBrowser();
@@ -2297,7 +660,7 @@ const model = {
660 installHeaderNewMenu(header = null) {
661 if (!header || header.querySelector(".office-header-actions")) return () => {};
662
2300 - const root = globalThis.document.createElement("div");
663 + const root = document.createElement("div");
664 root.className = "office-header-actions";
665 root.innerHTML = `
666 <button type="button" class="office-header-new-button" aria-haspopup="menu" aria-expanded="false">
@@ -2358,12 +721,10 @@ const model = {
721 await this.runNewMenuAction(action);
722 });
723 }
2361 - globalThis.document.addEventListener("click", onDocumentClick);
2362 - globalThis.document.addEventListener("keydown", onDocumentKeydown);
724 + document.addEventListener("click", onDocumentClick);
725 + document.addEventListener("keydown", onDocumentKeydown);
726
2364 - const firstHeaderAction = header.querySelector(
2365 - ".modal-surface-switcher, .modal-dock-button, .office-modal-focus-button, .modal-close",
2366 - );
727 + const firstHeaderAction = header.querySelector(".modal-close");
728 if (firstHeaderAction) {
729 firstHeaderAction.insertAdjacentElement("beforebegin", root);
730 } else {
@@ -2373,278 +734,28 @@ const model = {
734 setOpen(false);
735 return () => {
736 button?.removeEventListener("click", onButtonClick);
2376 - globalThis.document.removeEventListener("click", onDocumentClick);
2377 - globalThis.document.removeEventListener("keydown", onDocumentKeydown);
737 + document.removeEventListener("click", onDocumentClick);
738 + document.removeEventListener("keydown", onDocumentKeydown);
739 root.remove();
740 };
741 },
742
2382 - setupFloatingModal(element = null) {
2383 - const root = element || globalThis.document?.querySelector(".office-panel");
2384 - const modal = root?.closest?.(".modal");
743 + setupDocumentModal(element = null) {
744 + const root = element || document.querySelector(".office-panel");
745 const inner = root?.closest?.(".modal-inner");
2386 - const body = root?.closest?.(".modal-bd");
746 const header = inner?.querySelector?.(".modal-header");
2388 - if (!inner || !body || !header || inner.dataset.officeModalReady === "1") return;
2389 -
747 + if (!inner || !header || inner.dataset.officeModalReady === "1") return;
748 inner.dataset.officeModalReady = "1";
2391 - modal?.classList?.add("modal-floating", "modal-no-backdrop");
2392 - inner.classList.add("office-modal", "modal-no-backdrop");
2393 - body.classList.add("office-modal-body");
2394 - header.style.cursor = "move";
2395 -
2396 - const inset = 8;
2397 - const minWidth = 720;
2398 - const minHeight = 520;
2399 - const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
2400 - const cleanup = [];
2401 - let beforeFocusBounds = null;
2402 - let dragging = false;
2403 - let resizing = false;
2404 - let pointerId = 0;
2405 - let startX = 0;
2406 - let startY = 0;
2407 - let startLeft = 0;
2408 - let startTop = 0;
2409 - let startWidth = 0;
2410 - let startHeight = 0;
2411 - let resizeMode = "";
2412 -
2413 - const newMenuCleanup = this.installHeaderNewMenu(header);
2414 -
2415 - const currentBounds = () => {
2416 - const rect = inner.getBoundingClientRect();
2417 - return {
2418 - left: rect.left,
2419 - top: rect.top,
2420 - width: rect.width,
2421 - height: rect.height,
2422 - };
2423 - };
2424 -
2425 - const normalizedBounds = (bounds) => {
2426 - const maxWidth = Math.max(320, globalThis.innerWidth - inset * 2);
2427 - const maxHeight = Math.max(320, globalThis.innerHeight - inset * 2);
2428 - const safeMinWidth = Math.min(minWidth, maxWidth);
2429 - const safeMinHeight = Math.min(minHeight, maxHeight);
2430 - const width = clamp(bounds.width, safeMinWidth, maxWidth);
2431 - const height = clamp(bounds.height, safeMinHeight, maxHeight);
2432 - return {
2433 - width,
2434 - height,
2435 - left: clamp(bounds.left, inset, Math.max(inset, globalThis.innerWidth - width - inset)),
2436 - top: clamp(bounds.top, inset, Math.max(inset, globalThis.innerHeight - height - inset)),
2437 - };
2438 - };
2439 -
2440 - const setBounds = (bounds) => {
2441 - const next = normalizedBounds(bounds);
2442 - inner.style.position = "fixed";
2443 - inner.style.transform = "none";
2444 - inner.style.left = `${Math.round(next.left)}px`;
2445 - inner.style.top = `${Math.round(next.top)}px`;
2446 - inner.style.width = `${Math.round(next.width)}px`;
2447 - inner.style.height = `${Math.round(next.height)}px`;
2448 - inner.style.right = "auto";
2449 - inner.style.bottom = "auto";
2450 - inner.style.margin = "0";
2451 - };
2452 -
2453 - const ensurePosition = () => {
2454 - setBounds(currentBounds());
2455 - };
2456 -
2457 - const shield = globalThis.document.createElement("div");
2458 - shield.className = "office-modal-input-shield";
2459 - inner.appendChild(shield);
2460 - cleanup.push(() => shield.remove());
2461 -
2462 - const setShield = (visible, cursor = "") => {
2463 - shield.style.display = visible ? "block" : "none";
2464 - shield.style.cursor = cursor;
2465 - };
2466 -
2467 - const focusButton = globalThis.document.createElement("button");
2468 - focusButton.type = "button";
2469 - focusButton.className = "modal-dock-button office-modal-focus-button";
2470 - focusButton.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">fullscreen</span>';
2471 - const updateFocusButton = (active) => {
2472 - const label = active ? "Restore size" : "Focus mode";
2473 - focusButton.setAttribute("aria-label", label);
2474 - focusButton.querySelector(".material-symbols-outlined").textContent = active ? "fullscreen_exit" : "fullscreen";
2475 - };
2476 - updateFocusButton(false);
2477 - const closeButton = inner.querySelector(".modal-close");
2478 - if (closeButton) {
2479 - closeButton.insertAdjacentElement("beforebegin", focusButton);
2480 - } else {
2481 - header.appendChild(focusButton);
2482 - }
2483 - cleanup.push(() => focusButton.remove());
2484 -
2485 - const setFocusMode = (enabled) => {
2486 - ensurePosition();
2487 - if (enabled) {
2488 - beforeFocusBounds = currentBounds();
2489 - inner.classList.add("is-focus-mode");
2490 - setBounds({
2491 - left: inset,
2492 - top: inset,
2493 - width: globalThis.innerWidth - inset * 2,
2494 - height: globalThis.innerHeight - inset * 2,
2495 - });
2496 - updateFocusButton(true);
2497 - return;
2498 - }
2499 - inner.classList.remove("is-focus-mode");
2500 - setBounds(beforeFocusBounds || currentBounds());
2501 - beforeFocusBounds = null;
2502 - updateFocusButton(false);
2503 - };
2504 -
2505 - const onFocusClick = () => setFocusMode(!inner.classList.contains("is-focus-mode"));
2506 - focusButton.addEventListener("click", onFocusClick);
2507 - cleanup.push(() => focusButton.removeEventListener("click", onFocusClick));
2508 -
2509 - const onPointerDown = (event) => {
2510 - if (event.button !== 0) return;
2511 - if (event.target?.closest?.("button,a,input,textarea,select")) return;
2512 - if (inner.classList.contains("is-focus-mode")) return;
2513 - ensurePosition();
2514 - const rect = inner.getBoundingClientRect();
2515 - dragging = true;
2516 - pointerId = event.pointerId;
2517 - startX = event.clientX;
2518 - startY = event.clientY;
2519 - startLeft = rect.left;
2520 - startTop = rect.top;
2521 - startWidth = rect.width;
2522 - startHeight = rect.height;
2523 - inner.classList.add("is-dragging");
2524 - setShield(true, "move");
2525 - header.setPointerCapture?.(pointerId);
2526 - event.preventDefault();
2527 - };
2528 -
2529 - const onPointerMove = (event) => {
2530 - if (!dragging || event.pointerId !== pointerId) return;
2531 - setBounds({
2532 - left: startLeft + event.clientX - startX,
2533 - top: startTop + event.clientY - startY,
2534 - width: startWidth,
2535 - height: startHeight,
2536 - });
2537 - };
2538 -
2539 - const onPointerUp = (event) => {
2540 - if (!dragging || event.pointerId !== pointerId) return;
2541 - dragging = false;
2542 - inner.classList.remove("is-dragging");
2543 - setShield(false);
2544 - header.releasePointerCapture?.(pointerId);
2545 - };
2546 -
2547 - const createResizeHandle = (mode) => {
2548 - const handle = globalThis.document.createElement("div");
2549 - handle.className = `office-modal-resizer is-${mode}`;
2550 - handle.dataset.officeResize = mode;
2551 - inner.appendChild(handle);
2552 - cleanup.push(() => handle.remove());
2553 - return handle;
2554 - };
2555 -
2556 - const onResizeDown = (event) => {
2557 - if (event.button !== 0 || inner.classList.contains("is-focus-mode")) return;
2558 - ensurePosition();
2559 - const rect = inner.getBoundingClientRect();
2560 - resizing = true;
2561 - resizeMode = event.currentTarget.dataset.officeResize || "";
2562 - pointerId = event.pointerId;
2563 - startX = event.clientX;
2564 - startY = event.clientY;
2565 - startLeft = rect.left;
2566 - startTop = rect.top;
2567 - startWidth = rect.width;
2568 - startHeight = rect.height;
2569 - inner.classList.add("is-resizing");
2570 - this.suspendDesktopResize();
2571 - setShield(true, resizeMode === "right" ? "ew-resize" : resizeMode === "bottom" ? "ns-resize" : "nwse-resize");
2572 - event.currentTarget.setPointerCapture?.(pointerId);
2573 - event.preventDefault();
2574 - event.stopPropagation();
2575 - };
2576 -
2577 - const onResizeMove = (event) => {
2578 - if (!resizing || event.pointerId !== pointerId) return;
2579 - const dx = event.clientX - startX;
2580 - const dy = event.clientY - startY;
2581 - setBounds({
2582 - left: startLeft,
2583 - top: startTop,
2584 - width: resizeMode === "bottom" ? startWidth : startWidth + dx,
2585 - height: resizeMode === "right" ? startHeight : startHeight + dy,
2586 - });
2587 - };
2588 -
2589 - const onResizeUp = (event) => {
2590 - if (!resizing || event.pointerId !== pointerId) return;
2591 - resizing = false;
2592 - resizeMode = "";
2593 - inner.classList.remove("is-resizing");
2594 - setShield(false);
2595 - event.currentTarget.releasePointerCapture?.(pointerId);
2596 - this.resumeDesktopResize();
2597 - };
2598 -
2599 - header.addEventListener("pointerdown", onPointerDown);
2600 - header.addEventListener("pointermove", onPointerMove);
2601 - header.addEventListener("pointerup", onPointerUp);
2602 - header.addEventListener("pointercancel", onPointerUp);
2603 - cleanup.push(() => header.removeEventListener("pointerdown", onPointerDown));
2604 - cleanup.push(() => header.removeEventListener("pointermove", onPointerMove));
2605 - cleanup.push(() => header.removeEventListener("pointerup", onPointerUp));
2606 - cleanup.push(() => header.removeEventListener("pointercancel", onPointerUp));
2607 -
2608 - for (const mode of ["right", "bottom", "corner"]) {
2609 - const handle = createResizeHandle(mode);
2610 - handle.addEventListener("pointerdown", onResizeDown);
2611 - handle.addEventListener("pointermove", onResizeMove);
2612 - handle.addEventListener("pointerup", onResizeUp);
2613 - handle.addEventListener("pointercancel", onResizeUp);
2614 - cleanup.push(() => handle.removeEventListener("pointerdown", onResizeDown));
2615 - cleanup.push(() => handle.removeEventListener("pointermove", onResizeMove));
2616 - cleanup.push(() => handle.removeEventListener("pointerup", onResizeUp));
2617 - cleanup.push(() => handle.removeEventListener("pointercancel", onResizeUp));
2618 - }
2619 -
2620 - const onWindowResize = () => {
2621 - if (inner.classList.contains("is-focus-mode")) {
2622 - setBounds({
2623 - left: inset,
2624 - top: inset,
2625 - width: globalThis.innerWidth - inset * 2,
2626 - height: globalThis.innerHeight - inset * 2,
2627 - });
2628 - return;
2629 - }
2630 - ensurePosition();
2631 - };
2632 - globalThis.addEventListener("resize", onWindowResize);
2633 - cleanup.push(() => globalThis.removeEventListener("resize", onWindowResize));
2634 -
2635 - if (globalThis.requestAnimationFrame) {
2636 - globalThis.requestAnimationFrame(ensurePosition);
2637 - } else {
2638 - globalThis.setTimeout(ensurePosition, 0);
2639 - }
2640 - this._floatingCleanup = () => {
2641 - newMenuCleanup?.();
2642 - cleanup.splice(0).reverse().forEach((entry) => entry());
2643 - modal?.classList?.remove("modal-floating", "modal-no-backdrop");
2644 - inner.classList.remove("is-dragging", "is-resizing", "is-focus-mode");
2645 - this._desktopResizeSuspended = false;
2646 - this._desktopResizePending = false;
749 + inner.classList.add("office-modal");
750 + this._headerCleanup = () => {
751 delete inner.dataset.officeModalReady;
752 + inner.classList.remove("office-modal");
753 + };
754 + const menuCleanup = this.installHeaderNewMenu(header);
755 + const previousCleanup = this._headerCleanup;
756 + this._headerCleanup = () => {
757 + menuCleanup?.();
758 + previousCleanup?.();
759 };
760 },
761 };
plugins/_office/webui/thumbnail.jpg
Binary files a/plugins/_office/webui/thumbnail.jpg and b/plugins/_office/webui/thumbnail.jpg differ