mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-05-19 16:31:30 +00:00
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:
parent
10a6cd28c6
commit
24dd548ebf
9 changed files with 2495 additions and 1625 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue