feat(office-ui): introduce the Desktop document canvas

Rework the Office canvas into the Desktop surface, with Markdown editing for text documents and official LibreOffice/Xpra sessions for DOCX, XLSX, and PPTX. The panel now presents Desktop-oriented actions, named header buttons, persistent session tabs, adaptive modal/canvas sizing, and fast client-side Xpra frame fitting during resize.

Stop auto-opening the canvas from document tool results, hide the canvas on mobile-width layouts, and emit resize lifecycle events so embedded desktop surfaces can pause expensive work while the user drags.
This commit is contained in:
Alessandro 2026-05-02 12:20:54 +02:00
parent 10a6cd28c6
commit 24dd548ebf
9 changed files with 2495 additions and 1625 deletions

View file

@ -10,9 +10,6 @@ import {
drawProcessStep,
} from "/js/messages.js";
const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
const autoOpenedDocuments = new Set();
export default async function registerDocumentArtifactHandler(extData) {
if (extData?.tool_name === "document_artifact") {
extData.handler = drawDocumentArtifactTool;
@ -25,6 +22,7 @@ async function openOfficeCanvas(kvps = {}) {
await canvas?.open?.("office", {
path: kvps.path || "",
file_id: kvps.file_id || "",
refresh: true,
source: "tool",
});
}
@ -50,50 +48,10 @@ function documentFromArgs(args, result = {}) {
title: kvps.title || kvps.basename || document.basename || "",
format: kvps.format || kvps.extension || document.extension || "",
version: kvps.version || document.version || "",
last_modified: kvps.last_modified || document.last_modified || "",
};
}
function shouldAutoOpenDocument(args, document) {
const kvps = args?.kvps || {};
if (kvps.canvas_surface && kvps.canvas_surface !== "office") return false;
if (!document?.path) return false;
const action = String(kvps.action || "").trim().toLowerCase();
if (["status", "version_history", "inspect", "read", "extract"].includes(action)) return false;
return isFreshToolMessage(args?.timestamp);
}
function isFreshToolMessage(timestamp) {
const value = Number(timestamp);
if (!Number.isFinite(value) || value <= 0) return true;
const messageMs = value > 10_000_000_000 ? value : value * 1000;
return Math.abs(Date.now() - messageMs) <= AUTO_OPEN_WINDOW_MS;
}
function autoOpenOfficeCanvas(args) {
const document = documentFromArgs(args, parseDocumentResult(args?.content));
if (!shouldAutoOpenDocument(args, document)) return;
const key = `${args.id || ""}:${document.file_id || ""}:${document.path || ""}:${document.version || ""}`;
const persistedKey = `a0.office.autoOpened.${key}`;
if (hasOpenedDocument(key, persistedKey)) return;
requestAnimationFrame(() => {
void openOfficeCanvas(document);
});
}
function hasOpenedDocument(key, persistedKey) {
if (autoOpenedDocuments.has(key)) return true;
autoOpenedDocuments.add(key);
try {
if (sessionStorage.getItem(persistedKey)) return true;
sessionStorage.setItem(persistedKey, "1");
} catch {
// Best-effort persistence; the in-memory guard still prevents repeat opens.
}
return false;
}
function drawDocumentArtifactTool({
id,
type,
@ -116,7 +74,7 @@ function drawDocumentArtifactTool({
].filter(Boolean);
const actionButtons = [
createActionButton("description", "Office", () => openOfficeCanvas(document)),
createActionButton("desktop_windows", "Desktop", () => openOfficeCanvas(document)),
];
if (document?.path) {
@ -145,6 +103,5 @@ function drawDocumentArtifactTool({
actionButtons: actionButtons.filter(Boolean),
log: args,
});
autoOpenOfficeCanvas(args);
return result;
}

View file

@ -1,3 +1,7 @@
import { store as officeStore } from "/plugins/_office/webui/office-store.js";
void officeStore;
function waitForElement(selector, timeoutMs = 3000) {
const found = document.querySelector(selector);
if (found) return Promise.resolve(found);
@ -20,8 +24,8 @@ function waitForElement(selector, timeoutMs = 3000) {
export default async function registerOfficeSurface(canvas) {
canvas.registerSurface({
id: "office",
title: "Office",
icon: "description",
title: "Desktop",
icon: "desktop_windows",
order: 20,
modalPath: "/plugins/_office/webui/main.html",
async open(payload = {}) {
@ -30,9 +34,9 @@ export default async function registerOfficeSurface(canvas) {
await office?.onMount?.(panel, { mode: "canvas" });
await office?.onOpen?.(payload);
},
async close() {
async close(payload = {}) {
const office = globalThis.Alpine?.store?.("office");
office?.beforeHostHidden?.();
office?.beforeHostHidden?.({ unloadDesktop: payload?.reason === "mobile" });
},
});
}

View file

@ -1,145 +1,3 @@
const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
const autoOpenedDocuments = new Set();
export default async function autoOpenDocumentResults(context) {
if (!context?.results?.length || context.historyEmpty) return;
for (const { args } of context.results) {
const payload = getToolResultPayload(args);
if (getToolName(payload) !== "document_artifact") continue;
const document = getDocumentPayload(payload);
if (!document?.path) continue;
if (payload.canvas_surface && payload.canvas_surface !== "office") continue;
if (isReadOnlyAction(payload)) continue;
if (!isFresh(args?.timestamp, document.last_modified)) continue;
const key = [
args?.id || "",
document.file_id || "",
document.path,
document.version || "",
].join(":");
const persistedKey = `a0.office.autoOpened.${key}`;
if (hasOpened(key, persistedKey)) continue;
requestAnimationFrame(() => {
void openOfficeCanvas(document);
});
}
}
function getToolResultPayload(args = {}) {
const topLevelPayload = pickPayloadFields(args);
const contentPayload = parseMaybeJson(args.content);
const kvpsPayload = parseMaybeJson(args.kvps);
return {
...topLevelPayload,
...(contentPayload || {}),
...(kvpsPayload || {}),
};
}
function pickPayloadFields(args = {}) {
const payload = {};
for (const key of [
"_tool_name",
"tool_name",
"tool_result",
"canvas_surface",
"action",
"file_id",
"path",
"title",
"basename",
"format",
"extension",
"version",
"last_modified",
]) {
if (args[key] != null && args[key] !== "") payload[key] = args[key];
}
return payload;
}
function getToolName(payload = {}) {
return String(payload._tool_name || payload.tool_name || "").trim();
}
function getDocumentPayload(payload = {}) {
const result = parseMaybeJson(payload.tool_result) || {};
const document = result.document && typeof result.document === "object"
? result.document
: {};
return {
file_id: payload.file_id || document.file_id || "",
path: payload.path || document.path || "",
title: payload.title || payload.basename || document.basename || "",
format: payload.format || payload.extension || document.extension || "",
version: payload.version || document.version || "",
last_modified: payload.last_modified || document.last_modified || "",
};
}
function isReadOnlyAction(payload = {}) {
const action = String(payload.action || "").trim().toLowerCase();
return ["status", "version_history", "inspect", "read", "extract"].includes(action);
}
function parseMaybeJson(value) {
if (!value) return null;
if (typeof value === "object") return value;
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed.startsWith("{")) return null;
try {
const parsed = JSON.parse(trimmed);
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}
function isFresh(timestamp, fallbackTimestamp) {
const messageMs = toMs(timestamp) || toMs(fallbackTimestamp);
if (!messageMs) return true;
return Math.abs(Date.now() - messageMs) <= AUTO_OPEN_WINDOW_MS;
}
function toMs(value) {
if (value == null || value === "") return 0;
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) {
return numeric > 10_000_000_000 ? numeric : numeric * 1000;
}
const parsed = Date.parse(String(value));
return Number.isFinite(parsed) ? parsed : 0;
}
function hasOpened(key, persistedKey) {
if (autoOpenedDocuments.has(key)) return true;
autoOpenedDocuments.add(key);
try {
if (sessionStorage.getItem(persistedKey)) return true;
sessionStorage.setItem(persistedKey, "1");
} catch {
// Best-effort persistence; the in-memory guard still prevents repeat opens.
}
return false;
}
async function openOfficeCanvas(document) {
const canvas = globalThis.Alpine?.store?.("rightCanvas")
|| (await import("/components/canvas/right-canvas-store.js")).store;
await canvas?.open?.("office", {
path: document.path || "",
file_id: document.file_id || "",
source: "tool-result",
});
export default async function autoOpenDocumentResults(_context) {
return;
}

View file

@ -2,11 +2,14 @@
class="office-modal modal-no-backdrop"
data-canvas-surface="office"
data-canvas-modal-path="/plugins/_office/webui/main.html"
data-canvas-dock-title="Open Office in canvas"
data-canvas-dock-title="Open Desktop in canvas"
data-canvas-dock-icon="dock_to_right"
>
<head>
<title>Office</title>
<title>Desktop</title>
<script type="module">
import { store } from "/plugins/_office/webui/office-store.js";
</script>
</head>
<body class="office-modal-body">
<x-component path="/plugins/_office/webui/office-panel.html" mode="modal"></x-component>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -51,11 +51,6 @@ const model = {
await callJsExtensions("right_canvas_register_surfaces", this);
this._registering = false;
this.ensureActiveSurface();
if (this.isOpen && this.activeSurfaceId) {
globalThis.requestAnimationFrame?.(() => {
void this.open(this.activeSurfaceId, this._lastPayloadBySurface[this.activeSurfaceId] || {});
});
}
}
},
@ -103,6 +98,9 @@ const model = {
if (!surface) {
return false;
}
if (this.isMobileMode && !surface.actionOnly) {
return false;
}
if (typeof surface.canOpen === "function" && surface.canOpen(payload) === false) {
return false;
}
@ -143,6 +141,9 @@ const model = {
},
async dockSurface(surfaceId, payload = {}) {
if (this.isMobileMode) {
return false;
}
const surface = this.getSurface(surfaceId);
if (!surface) {
return false;
@ -228,6 +229,9 @@ const model = {
},
async toggleCanvas() {
if (this.isMobileMode) {
return false;
}
if (this.isOpen) {
await this.close();
return false;
@ -256,6 +260,7 @@ const model = {
if (this.isOverlayMode || this.isMobileMode || !this.isOpen) return;
if (event.button !== 0) return;
event.preventDefault();
this.dispatchResizeEvent("right-canvas-resize-start");
const onPointerMove = (moveEvent) => {
const nextWidth = viewportWidth() - moveEvent.clientX;
@ -264,13 +269,29 @@ const model = {
const onPointerUp = () => {
globalThis.removeEventListener("pointermove", onPointerMove);
globalThis.removeEventListener("pointerup", onPointerUp);
globalThis.removeEventListener("pointercancel", onPointerUp);
document.body.classList.remove("right-canvas-resizing");
this.persist();
this.dispatchResizeEvent("right-canvas-resize-end");
};
document.body.classList.add("right-canvas-resizing");
globalThis.addEventListener("pointermove", onPointerMove);
globalThis.addEventListener("pointerup", onPointerUp);
globalThis.addEventListener("pointercancel", onPointerUp);
},
dispatchResizeEvent(name) {
try {
globalThis.dispatchEvent(new CustomEvent(name, {
detail: {
width: this.width,
activeSurfaceId: this.activeSurfaceId,
},
}));
} catch {
// Resize events are an optimization hook for embedded surfaces.
}
},
persist() {
@ -292,7 +313,7 @@ const model = {
this.width = this.defaultWidth();
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
this.isOpen = Boolean(saved.isOpen);
this.isOpen = false;
this.activeSurfaceId = String(saved.activeSurfaceId || "");
if (saved.width) this.width = Number(saved.width);
} catch (error) {
@ -303,14 +324,28 @@ const model = {
updateLayoutMode() {
const width = viewportWidth();
const wasMobileMode = this.isMobileMode;
this.isOverlayMode = width < DESKTOP_BREAKPOINT;
this.isMobileMode = width <= MOBILE_BREAKPOINT;
if (this.isMobileMode) {
const wasOpen = this.isOpen;
const surface = wasOpen ? this.currentSurface() : null;
const payload = this._lastPayloadBySurface[this.activeSurfaceId] || {};
this.isOpen = false;
if (surface && wasOpen) {
globalThis.setTimeout?.(() => {
surface.close?.({ ...payload, reason: "mobile" });
}, 0);
}
} else if (wasMobileMode && this.width <= MIN_WIDTH) {
this.width = this.defaultWidth();
}
},
applyLayoutState() {
this.updateLayoutMode();
document.documentElement.style.setProperty("--right-canvas-width", `${this.width}px`);
document.body.classList.toggle("right-canvas-open", this.isOpen);
document.body.classList.toggle("right-canvas-open", this.isOpen && !this.isMobileMode);
document.body.classList.toggle("right-canvas-overlay-mode", this.isOverlayMode);
document.body.classList.toggle("right-canvas-mobile-mode", this.isMobileMode);
},
@ -347,6 +382,10 @@ const model = {
activeTitle() {
return this.currentSurface()?.title || "Canvas";
},
shouldRender() {
return !this.isMobileMode;
},
};
export const store = createStore("rightCanvas", model);

View file

@ -299,35 +299,9 @@ body.right-canvas-overlay-mode .right-canvas-resize-handle {
display: none;
}
body.right-canvas-mobile-mode .right-canvas {
left: 0;
width: 100vw !important;
max-width: 100vw;
min-width: 100vw;
border-left: 0;
}
body.right-canvas-mobile-mode .right-canvas.is-closed {
transform: translateX(100%);
}
body.right-canvas-mobile-mode .right-canvas,
body.right-canvas-mobile-mode .right-canvas-rail {
display: flex;
}
body.right-canvas-mobile-mode .right-canvas.is-open .right-canvas-rail {
display: none;
}
body.right-canvas-mobile-mode .right-canvas-header {
min-height: 48px;
padding: 7px 8px 0;
}
body.right-canvas-mobile-mode .right-canvas-tab {
min-width: 42px;
justify-content: center;
padding: 0 9px;
display: none !important;
}
@media (max-width: 480px) {

View file

@ -17,6 +17,7 @@
'is-mobile': $store.rightCanvas.isMobileMode
}"
:style="$store.rightCanvas.widthStyle()"
x-show="$store.rightCanvas.shouldRender()"
x-init="$store.rightCanvas.init($el)"
x-effect="$store.rightCanvas.applyLayoutState()"
aria-label="Universal Canvas"