From f1b014feb366d09e56cf9fbcdab003d79e70cc9e Mon Sep 17 00:00:00 2001
From: Alessandro <155005371+3clyp50@users.noreply.github.com>
Date: Sun, 26 Apr 2026 19:32:50 +0200
Subject: [PATCH] Automatic canvas handoffs
- Auto-open Office and Browser canvas surfaces from fresh tool results, including history/result messages.
- Preserve Browser target IDs when focusing a canvas session from tool output.
- Convert substantial response-style artifacts into Office documents at runtime, without relying only on prompt compliance.
- Attach Office artifact metadata to the completed response log so the canvas opens without leaving a dangling Processing group.
- Polish Office UX by removing the inactive version-history action, showing only the healthy dot, and improving Collabora blank-load recovery with browser state cleanup.
- Deduplicate auto-open events and ignore stale results.
---
.../browser-tool-handler.js | 88 +++++-
.../register-browser.js | 7 +-
.../auto-open-browser-results.js | 149 ++++++++++
plugins/_browser/webui/browser-store.js | 3 +-
.../_20_document_response_affordance.py | 109 ++++++++
.../document-artifact-handler.js | 85 +++++-
.../auto-open-document-results.js | 138 ++++++++++
.../_office/helpers/document_affordance.py | 256 ++++++++++++++++++
plugins/_office/webui/office-panel.html | 13 +-
plugins/_office/webui/office-store.js | 69 ++++-
10 files changed, 882 insertions(+), 35 deletions(-)
create mode 100644 plugins/_browser/extensions/webui/set_messages_after_loop/auto-open-browser-results.js
create mode 100644 plugins/_office/extensions/python/tool_execute_after/_20_document_response_affordance.py
create mode 100644 plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js
create mode 100644 plugins/_office/helpers/document_affordance.py
diff --git a/plugins/_browser/extensions/webui/get_tool_message_handler/browser-tool-handler.js b/plugins/_browser/extensions/webui/get_tool_message_handler/browser-tool-handler.js
index 62be2815f..52e089b94 100644
--- a/plugins/_browser/extensions/webui/get_tool_message_handler/browser-tool-handler.js
+++ b/plugins/_browser/extensions/webui/get_tool_message_handler/browser-tool-handler.js
@@ -11,6 +11,8 @@ import {
} from "/js/messages.js";
const BROWSER_MODAL = "/plugins/_browser/webui/main.html";
+const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
+const autoOpenedBrowsers = new Set();
export default async function registerBrowserToolHandler(extData) {
if (extData?.tool_name === "browser") {
@@ -18,6 +20,69 @@ export default async function registerBrowserToolHandler(extData) {
}
}
+async function openBrowserCanvas(payload = {}) {
+ const canvas = globalThis.Alpine?.store?.("rightCanvas")
+ || (await import("/components/canvas/right-canvas-store.js")).store;
+ if (canvas) {
+ await canvas.open("browser", payload);
+ return;
+ }
+ if (window.ensureModalOpen) {
+ await window.ensureModalOpen(BROWSER_MODAL);
+ return;
+ }
+ await window.openModal?.(BROWSER_MODAL);
+}
+
+function parseBrowserResult(content) {
+ if (!content || typeof content !== "string") return {};
+ try {
+ const parsed = JSON.parse(content);
+ return parsed && typeof parsed === "object" ? parsed : {};
+ } catch {
+ return {};
+ }
+}
+
+function browserIdFromResult(result = {}, kvps = {}) {
+ return (
+ result.id
+ || result.browser_id
+ || result.state?.id
+ || result.last_interacted_browser_id
+ || kvps.browser_id
+ || null
+ );
+}
+
+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 shouldAutoOpenBrowser(args, result) {
+ if (!isFreshToolMessage(args?.timestamp)) return false;
+ const action = String(args?.kvps?.action || "").trim().toLowerCase().replace("-", "_");
+ if (["list", "content", "detail", "close", "close_all"].includes(action)) return false;
+ return Boolean(browserIdFromResult(result, args?.kvps || {}) || action === "open" || action === "navigate");
+}
+
+function autoOpenBrowserCanvas(args, result) {
+ if (!shouldAutoOpenBrowser(args, result)) return;
+ const kvps = args?.kvps || {};
+ const browserId = browserIdFromResult(result, kvps);
+ const key = `${args.id || ""}:${kvps.action || ""}:${browserId || ""}:${result.currentUrl || result.state?.currentUrl || kvps.url || ""}`;
+ const persistedKey = `a0.browser.autoOpened.${key}`;
+ if (autoOpenedBrowsers.has(key) || sessionStorage.getItem(persistedKey)) return;
+ autoOpenedBrowsers.add(key);
+ sessionStorage.setItem(persistedKey, "1");
+ requestAnimationFrame(() => {
+ void openBrowserCanvas({ browserId, source: "tool" });
+ });
+}
+
function drawBrowserTool({
id,
type,
@@ -28,27 +93,18 @@ function drawBrowserTool({
agentno = 0,
...additional
}) {
+ const args = arguments[0];
const title = cleanStepTitle(heading);
const displayKvps = { ...kvps };
const headerLabels = [
kvps?._tool_name && { label: kvps._tool_name, class: "tool-name-badge" },
].filter(Boolean);
const contentText = String(content ?? "");
+ const browserResult = parseBrowserResult(contentText);
const browserButton = createActionButton(
"visibility",
"Browser",
- () => {
- const canvas = globalThis.Alpine?.store?.("rightCanvas");
- if (canvas) {
- void canvas.open("browser");
- return;
- }
- if (window.ensureModalOpen) {
- void window.ensureModalOpen(BROWSER_MODAL);
- return;
- }
- void window.openModal?.(BROWSER_MODAL);
- },
+ () => openBrowserCanvas({ browserId: browserIdFromResult(browserResult, kvps), source: "tool" }),
);
browserButton.setAttribute("title", "Open Browser");
browserButton.setAttribute("aria-label", "Open Browser");
@@ -60,7 +116,7 @@ function drawBrowserTool({
actionButtons.push(
createActionButton("detail", "", () =>
stepDetailStore.showStepDetail(
- buildDetailPayload(arguments[0], { headerLabels }),
+ buildDetailPayload(args, { headerLabels }),
),
),
createActionButton("speak", "", () => speechStore.speak(contentText)),
@@ -68,7 +124,7 @@ function drawBrowserTool({
);
}
- return drawProcessStep({
+ const result = drawProcessStep({
id,
title,
code: "WWW",
@@ -76,6 +132,8 @@ function drawBrowserTool({
kvps: displayKvps,
content,
actionButtons: actionButtons.filter(Boolean),
- log: arguments[0],
+ log: args,
});
+ autoOpenBrowserCanvas(args, browserResult);
+ return result;
}
diff --git a/plugins/_browser/extensions/webui/right_canvas_register_surfaces/register-browser.js b/plugins/_browser/extensions/webui/right_canvas_register_surfaces/register-browser.js
index ee3c81011..8b513bcda 100644
--- a/plugins/_browser/extensions/webui/right_canvas_register_surfaces/register-browser.js
+++ b/plugins/_browser/extensions/webui/right_canvas_register_surfaces/register-browser.js
@@ -24,11 +24,14 @@ export default async function registerBrowserSurface(canvas) {
icon: "language",
order: 10,
modalPath: "/plugins/_browser/webui/main.html",
- async open() {
+ async open(payload = {}) {
const panel = await waitForElement('[data-surface-id="browser"] .browser-panel');
const browser = globalThis.Alpine?.store?.("browserPage");
if (panel && browser?.onOpen) {
- await browser.onOpen(panel, { mode: "canvas" });
+ await browser.onOpen(panel, {
+ mode: "canvas",
+ browserId: payload.browserId || payload.browser_id || null,
+ });
}
},
async close() {
diff --git a/plugins/_browser/extensions/webui/set_messages_after_loop/auto-open-browser-results.js b/plugins/_browser/extensions/webui/set_messages_after_loop/auto-open-browser-results.js
new file mode 100644
index 000000000..b0fbf7bdd
--- /dev/null
+++ b/plugins/_browser/extensions/webui/set_messages_after_loop/auto-open-browser-results.js
@@ -0,0 +1,149 @@
+const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
+const BROWSER_MODAL = "/plugins/_browser/webui/main.html";
+const autoOpenedBrowsers = new Set();
+
+export default async function autoOpenBrowserResults(context) {
+ if (!context?.results?.length || context.historyEmpty) return;
+
+ for (const { args } of context.results) {
+ const payload = getToolResultPayload(args);
+ if (getToolName(payload) !== "browser") continue;
+
+ const result = parseMaybeJson(payload.tool_result) || {};
+ if (!shouldAutoOpen(args, payload, result)) continue;
+
+ const browserId = getBrowserId(payload, result);
+ const key = [
+ args?.id || "",
+ browserId || "",
+ result.currentUrl || result.state?.currentUrl || payload.url || "",
+ ].join(":");
+ const persistedKey = `a0.browser.autoOpened.${key}`;
+ if (hasOpened(key, persistedKey)) continue;
+
+ requestAnimationFrame(() => {
+ void openBrowserCanvas({ browserId, source: "tool-result" });
+ });
+ }
+}
+
+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",
+ "action",
+ "browser_id",
+ "browserId",
+ "url",
+ "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 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 shouldAutoOpen(args = {}, payload = {}, result = {}) {
+ if (!isFresh(args.timestamp, payload.last_modified || result.last_modified)) return false;
+
+ const action = String(payload.action || "").trim().toLowerCase().replace("-", "_");
+ if (["list", "content", "detail", "close", "close_all"].includes(action)) return false;
+
+ return Boolean(
+ getBrowserId(payload, result)
+ || action === "open"
+ || action === "navigate"
+ || result.currentUrl
+ || result.state?.currentUrl,
+ );
+}
+
+function getBrowserId(payload = {}, result = {}) {
+ return (
+ result.id
+ || result.browser_id
+ || result.state?.id
+ || result.last_interacted_browser_id
+ || payload.browser_id
+ || payload.browserId
+ || 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 (autoOpenedBrowsers.has(key)) return true;
+ autoOpenedBrowsers.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 openBrowserCanvas(payload = {}) {
+ const canvas = globalThis.Alpine?.store?.("rightCanvas")
+ || (await import("/components/canvas/right-canvas-store.js")).store;
+ if (canvas) {
+ await canvas.open("browser", payload);
+ return;
+ }
+
+ if (window.ensureModalOpen) {
+ await window.ensureModalOpen(BROWSER_MODAL);
+ return;
+ }
+ await window.openModal?.(BROWSER_MODAL);
+}
diff --git a/plugins/_browser/webui/browser-store.js b/plugins/_browser/webui/browser-store.js
index 58232938c..dce7ef08f 100644
--- a/plugins/_browser/webui/browser-store.js
+++ b/plugins/_browser/webui/browser-store.js
@@ -299,6 +299,7 @@ const model = {
async onOpen(element = null, options = {}) {
this.loading = true;
this.error = "";
+ const requestedBrowserId = this.normalizeBrowserId(options.browserId ?? options.browser_id);
this._mode = options?.mode === "modal" ? "modal" : "canvas";
if (this._mode === "modal") {
this.setupFloatingModal(element);
@@ -308,7 +309,7 @@ const model = {
this.contextId = this.resolveContextId();
try {
await this.refreshStatus();
- await this.connectViewer();
+ await this.connectViewer({ browserId: requestedBrowserId });
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
} finally {
diff --git a/plugins/_office/extensions/python/tool_execute_after/_20_document_response_affordance.py b/plugins/_office/extensions/python/tool_execute_after/_20_document_response_affordance.py
new file mode 100644
index 000000000..133ea4931
--- /dev/null
+++ b/plugins/_office/extensions/python/tool_execute_after/_20_document_response_affordance.py
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+from helpers import files
+from helpers.extension import Extension
+from helpers.print_style import PrintStyle
+from helpers.tool import Response
+from plugins._office.helpers import document_affordance, wopi_store
+
+
+class DocumentResponseAffordance(Extension):
+ async def execute(
+ self,
+ tool_name: str = "",
+ response: Response | None = None,
+ **kwargs: Any,
+ ):
+ if tool_name != "response" or not self.agent or response is None:
+ return
+
+ tool = self.agent.loop_data.current_tool
+ if not tool:
+ return
+
+ text = str(tool.args.get("text") or tool.args.get("message") or response.message or "").strip()
+ user_message = self.agent.last_user_message.content if self.agent.last_user_message else ""
+ decision = document_affordance.decide_response_artifact(user_message, text)
+ if decision is None:
+ return
+
+ try:
+ doc = wopi_store.create_document(
+ kind=decision.kind,
+ title=decision.title,
+ fmt=decision.fmt,
+ content=decision.content,
+ )
+ except Exception as exc:
+ PrintStyle().error(f"Office document affordance failed: {exc}")
+ return
+
+ payload = {
+ "ok": True,
+ "message": "Created document artifact from response.",
+ "document": public_doc(doc),
+ }
+ additional = document_additional(doc)
+ content = json.dumps(payload, indent=2, ensure_ascii=False)
+
+ self.agent.hist_add_tool_result("document_artifact", content, **additional)
+
+ display_path = display_workspace_path(doc["path"])
+ note = document_affordance.format_created_response(doc["basename"], display_path)
+ response.message = note
+ tool.args["text"] = note
+ tool.args["message"] = note
+
+ log_item = self.agent.loop_data.params_temporary.get("log_item_response")
+ if log_item:
+ log_item.update(
+ content=note,
+ kvps={
+ "action": "create",
+ "kind": decision.kind,
+ "title": decision.title,
+ "format": decision.fmt,
+ "_tool_name": "document_artifact",
+ **additional,
+ },
+ )
+
+
+def public_doc(doc: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "file_id": doc["file_id"],
+ "path": display_workspace_path(doc["path"]),
+ "basename": doc["basename"],
+ "extension": doc["extension"],
+ "size": doc["size"],
+ "version": wopi_store.item_version(doc),
+ "last_modified": doc["last_modified"],
+ "exists": Path(doc["path"]).exists(),
+ }
+
+
+def document_additional(doc: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "_tool_name": "document_artifact",
+ "canvas_surface": "office",
+ "file_id": doc["file_id"],
+ "title": doc["basename"],
+ "format": doc["extension"],
+ "path": display_workspace_path(doc["path"]),
+ "version": wopi_store.item_version(doc),
+ }
+
+
+def display_workspace_path(path: str) -> str:
+ base = Path(files.get_base_dir()).resolve(strict=False)
+ resolved = Path(path).resolve(strict=False)
+ if str(base).startswith("/a0"):
+ return str(resolved)
+ try:
+ return "/a0/" + str(resolved.relative_to(base)).lstrip("/")
+ except ValueError:
+ return str(path)
diff --git a/plugins/_office/extensions/webui/get_tool_message_handler/document-artifact-handler.js b/plugins/_office/extensions/webui/get_tool_message_handler/document-artifact-handler.js
index 76b349a1a..d30e9a6fe 100644
--- a/plugins/_office/extensions/webui/get_tool_message_handler/document-artifact-handler.js
+++ b/plugins/_office/extensions/webui/get_tool_message_handler/document-artifact-handler.js
@@ -10,6 +10,9 @@ 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;
@@ -26,6 +29,71 @@ async function openOfficeCanvas(kvps = {}) {
});
}
+function parseDocumentResult(content) {
+ if (!content || typeof content !== "string") return {};
+ try {
+ const parsed = JSON.parse(content);
+ return parsed && typeof parsed === "object" ? parsed : {};
+ } catch {
+ return {};
+ }
+}
+
+function documentFromArgs(args, result = {}) {
+ const kvps = args?.kvps || {};
+ const document = result.document && typeof result.document === "object"
+ ? result.document
+ : {};
+ return {
+ file_id: kvps.file_id || document.file_id || "",
+ path: kvps.path || document.path || "",
+ title: kvps.title || kvps.basename || document.basename || "",
+ format: kvps.format || kvps.extension || document.extension || "",
+ version: kvps.version || document.version || "",
+ };
+}
+
+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"].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,
@@ -40,26 +108,25 @@ function drawDocumentArtifactTool({
const title = cleanStepTitle(heading);
const displayKvps = { ...kvps };
const contentText = String(content ?? "");
+ const documentResult = parseDocumentResult(contentText);
+ const document = documentFromArgs(args, documentResult);
const headerLabels = [
kvps?._tool_name && { label: kvps._tool_name, class: "tool-name-badge" },
- kvps?.format && { label: String(kvps.format).toUpperCase(), class: "tool-name-badge" },
+ document?.format && { label: String(document.format).toUpperCase(), class: "tool-name-badge" },
].filter(Boolean);
const actionButtons = [
- createActionButton("description", "Office", () => openOfficeCanvas(kvps)),
+ createActionButton("description", "Office", () => openOfficeCanvas(document)),
];
- if (kvps?.path) {
+ if (document?.path) {
actionButtons.push(
- createActionButton("content_copy", "Path", () => copyToClipboard(kvps.path)),
+ createActionButton("content_copy", "Path", () => copyToClipboard(document.path)),
);
}
if (contentText.trim()) {
actionButtons.push(
- createActionButton("history", "Versions", () =>
- stepDetailStore.showStepDetail(buildDetailPayload(args, { headerLabels })),
- ),
createActionButton("detail", "", () =>
stepDetailStore.showStepDetail(buildDetailPayload(args, { headerLabels })),
),
@@ -68,7 +135,7 @@ function drawDocumentArtifactTool({
);
}
- return drawProcessStep({
+ const result = drawProcessStep({
id,
title,
code: "DOC",
@@ -78,4 +145,6 @@ function drawDocumentArtifactTool({
actionButtons: actionButtons.filter(Boolean),
log: args,
});
+ autoOpenOfficeCanvas(args);
+ return result;
}
diff --git a/plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js b/plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js
new file mode 100644
index 000000000..3ed030d3d
--- /dev/null
+++ b/plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js
@@ -0,0 +1,138 @@
+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 (!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",
+ "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 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",
+ });
+}
diff --git a/plugins/_office/helpers/document_affordance.py b/plugins/_office/helpers/document_affordance.py
new file mode 100644
index 000000000..e59751b83
--- /dev/null
+++ b/plugins/_office/helpers/document_affordance.py
@@ -0,0 +1,256 @@
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import Any
+
+
+MIN_ARTIFACT_CHARS = 700
+MIN_ARTIFACT_WORDS = 110
+MIN_EXPLICIT_ARTIFACT_CHARS = 240
+MIN_EXPLICIT_ARTIFACT_WORDS = 35
+
+CREATE_TERMS = {
+ "write",
+ "draft",
+ "compose",
+ "create",
+ "generate",
+ "prepare",
+ "produce",
+ "make",
+ "build",
+ "author",
+}
+
+DOCUMENT_TERMS = {
+ "article",
+ "brief",
+ "contract",
+ "cv",
+ "doc",
+ "document",
+ "docx",
+ "draft",
+ "essay",
+ "guide",
+ "letter",
+ "manual",
+ "memo",
+ "policy",
+ "proposal",
+ "report",
+ "resume",
+ "spec",
+ "story",
+ "whitepaper",
+}
+
+SPREADSHEET_TERMS = {
+ "budget",
+ "excel",
+ "sheet",
+ "spreadsheet",
+ "table",
+ "workbook",
+ "xlsx",
+}
+
+PRESENTATION_TERMS = {
+ "deck",
+ "ppt",
+ "pptx",
+ "presentation",
+ "slide",
+ "slides",
+}
+
+CHAT_ONLY_TERMS = {
+ "answer in chat",
+ "in chat",
+ "just answer",
+ "just reply",
+ "no file",
+ "no files",
+}
+
+SKIP_RESPONSE_PREFIXES = (
+ "i can't",
+ "i cannot",
+ "i'm sorry",
+ "sorry,",
+ "i can help",
+)
+
+
+@dataclass(frozen=True)
+class ArtifactDecision:
+ kind: str
+ fmt: str
+ title: str
+ content: str
+ reason: str
+
+
+def decide_response_artifact(user_message: Any, response_text: str) -> ArtifactDecision | None:
+ user_text = flatten_text(user_message).strip()
+ response_text = str(response_text or "").strip()
+ if not user_text or not response_text:
+ return None
+
+ lowered_user = normalize_text(user_text)
+ lowered_response = normalize_text(response_text[:240])
+ if any(term in lowered_user for term in CHAT_ONLY_TERMS):
+ return None
+ if lowered_response.startswith(SKIP_RESPONSE_PREFIXES):
+ return None
+ if looks_like_tool_or_status_response(response_text):
+ return None
+
+ kind, fmt, explicit_artifact = infer_kind_and_format(lowered_user)
+ if not explicit_artifact and not has_document_creation_intent(lowered_user):
+ return None
+
+ if not is_substantial(response_text, explicit_artifact):
+ return None
+
+ title = infer_title(user_text, response_text, kind)
+ return ArtifactDecision(
+ kind=kind,
+ fmt=fmt,
+ title=title,
+ content=response_text,
+ reason="explicit" if explicit_artifact else "document_intent",
+ )
+
+
+def flatten_text(value: Any) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, str):
+ return value
+ if isinstance(value, dict):
+ preferred_keys = ("user_message", "user_intervention", "message", "content", "text")
+ skipped_keys = {*preferred_keys, "attachments", "system_message", "raw_content"}
+ preferred = []
+ for key in preferred_keys:
+ if key in value:
+ preferred.append(flatten_text(value[key]))
+ remaining = [
+ flatten_text(child)
+ for key, child in value.items()
+ if key not in skipped_keys
+ ]
+ return "\n".join(part for part in [*preferred, *remaining] if part)
+ if isinstance(value, (list, tuple, set)):
+ return "\n".join(part for item in value if (part := flatten_text(item)))
+ return str(value)
+
+
+def normalize_text(value: str) -> str:
+ return re.sub(r"\s+", " ", value.lower()).strip()
+
+
+def infer_kind_and_format(lowered_user: str) -> tuple[str, str, bool]:
+ explicit = False
+ if has_any(lowered_user, PRESENTATION_TERMS):
+ explicit = True
+ return "presentation", "pptx", explicit
+ if has_any(lowered_user, SPREADSHEET_TERMS):
+ explicit = True
+ return "spreadsheet", "xlsx", explicit
+ if has_any(lowered_user, DOCUMENT_TERMS):
+ explicit = True
+ return "document", "docx", explicit
+
+
+def has_document_creation_intent(lowered_user: str) -> bool:
+ return has_any(lowered_user, CREATE_TERMS) and has_any(
+ lowered_user,
+ DOCUMENT_TERMS | SPREADSHEET_TERMS | PRESENTATION_TERMS,
+ )
+
+
+def has_any(text: str, terms: set[str]) -> bool:
+ return any(re.search(rf"\b{re.escape(term)}\b", text) for term in terms)
+
+
+def is_substantial(text: str, explicit_artifact: bool) -> bool:
+ word_count = len(re.findall(r"\w+", text))
+ char_count = len(text)
+ if explicit_artifact:
+ return char_count >= MIN_EXPLICIT_ARTIFACT_CHARS and word_count >= MIN_EXPLICIT_ARTIFACT_WORDS
+ return char_count >= MIN_ARTIFACT_CHARS and word_count >= MIN_ARTIFACT_WORDS
+
+
+def looks_like_tool_or_status_response(text: str) -> bool:
+ stripped = text.strip()
+ if stripped.startswith("{") and '"tool_name"' in stripped[:300]:
+ return True
+ if "/a0/usr/workdir/documents/" in stripped:
+ return True
+ return False
+
+
+def infer_title(user_text: str, response_text: str, kind: str) -> str:
+ response_title = title_from_response(response_text)
+ if response_title:
+ return response_title
+
+ request_title = title_from_request(user_text)
+ if request_title:
+ return request_title
+
+ return {
+ "spreadsheet": "Spreadsheet",
+ "presentation": "Presentation",
+ }.get(kind, "Document")
+
+
+def title_from_response(response_text: str) -> str:
+ for raw_line in response_text.splitlines()[:8]:
+ line = raw_line.strip()
+ if not line:
+ continue
+ for pattern in (
+ r"^#{1,3}\s+(.+?)\s*$",
+ r"^\*\*(.+?)\*\*\s*$",
+ r"^__(.+?)__\s*$",
+ ):
+ match = re.match(pattern, line)
+ if match:
+ return clean_title(match.group(1))
+ if len(line) <= 80 and not line.endswith((".", "?", "!", ":")):
+ return clean_title(line)
+ break
+ return ""
+
+
+def title_from_request(user_text: str) -> str:
+ text = re.sub(r"\s+", " ", user_text).strip()
+ quoted = re.search(r"[\"'“”](.{4,90}?)[\"'“”]", text)
+ if quoted:
+ return clean_title(quoted.group(1))
+
+ cleaned = re.sub(
+ r"\b(write|draft|compose|create|generate|prepare|produce|make|build|author)\b",
+ "",
+ text,
+ flags=re.IGNORECASE,
+ )
+ cleaned = re.sub(r"\b(a|an|the|new|for me|please|docx|document|file)\b", "", cleaned, flags=re.IGNORECASE)
+ cleaned = clean_title(cleaned)
+ return cleaned if 4 <= len(cleaned) <= 80 else ""
+
+
+def clean_title(value: str) -> str:
+ value = re.sub(r"[*_`#>\[\]{}]", "", value)
+ value = re.sub(r"\s+", " ", value).strip(" .:-")
+ return value[:90].strip(" .:-")
+
+
+def format_created_response(basename: str, path: str) -> str:
+ return (
+ f"Created **{basename}** and opened it in the Office canvas.\n\n"
+ f"Path: `{path}`"
+ )
diff --git a/plugins/_office/webui/office-panel.html b/plugins/_office/webui/office-panel.html
index 510142c74..5f8143e78 100644
--- a/plugins/_office/webui/office-panel.html
+++ b/plugins/_office/webui/office-panel.html
@@ -30,16 +30,14 @@
class="office-health-pill"
:class="`is-${$store.office.status?.state || 'unknown'}`"
:title="$store.office.status?.message || 'Office status'"
+ :aria-label="$store.office.status?.message || 'Office status'"
>
-
+
-
@@ -254,6 +252,13 @@
background: color-mix(in srgb, var(--color-panel) 64%, transparent);
}
+ .office-health-pill.is-healthy {
+ width: 28px;
+ min-width: 28px;
+ padding: 0;
+ gap: 0;
+ }
+
.office-health-dot {
width: 7px;
height: 7px;
diff --git a/plugins/_office/webui/office-store.js b/plugins/_office/webui/office-store.js
index 69b9dcd2a..8f24f7e6b 100644
--- a/plugins/_office/webui/office-store.js
+++ b/plugins/_office/webui/office-store.js
@@ -2,6 +2,9 @@ import { createStore } from "/js/AlpineStore.js";
import { callJsonApi } from "/js/api.js";
const FRAME_NAME_PREFIX = "a0-office-frame";
+const COLLABORA_STATE_VERSION = "2026-04-26.1";
+const COLLABORA_STATE_MARKER = "a0.office.collaboraStateVersion";
+const SERVICE_WORKER_CLEANUP_MARKER = "a0.office.serviceWorkerCleanupReloaded";
function makeFrameName() {
const id = globalThis.crypto?.randomUUID?.()
@@ -131,6 +134,7 @@ const model = {
this.error = "";
this.message = "";
try {
+ await this.prepareBrowserHostForEditor();
const response = await callJsonApi("/plugins/_office/office_session", payload);
if (!response?.ok) {
this.error = response?.error || "Office session could not be opened.";
@@ -210,6 +214,7 @@ const model = {
if (!this.session || this.frameReady || this._frameRecoveryTried) return;
this._frameRecoveryTried = true;
this._frameAttempt += 1;
+ this.resetCollaboraBrowserState({ force: true });
this.message = "Still opening the editor... trying a fresh editor load.";
await this.submitFrame();
this._frameTimer = setTimeout(() => {
@@ -269,11 +274,6 @@ const model = {
this.clearFrameTimers();
},
- async showVersions() {
- if (!this.session?.file_id) return;
- this.message = "Version history is available through the document_artifact tool.";
- },
-
onPostMessage(event) {
if (!this.session) return;
if (!this.isAllowedFrameOrigin(event.origin)) return;
@@ -444,6 +444,65 @@ const model = {
this._floatingCleanup = null;
if (element) this._root = element;
},
+
+ async prepareBrowserHostForEditor() {
+ await this.cleanupLegacyOfficeServiceWorkers();
+ this.resetCollaboraBrowserState();
+ },
+
+ async cleanupLegacyOfficeServiceWorkers() {
+ const serviceWorker = globalThis.navigator?.serviceWorker;
+ if (!serviceWorker?.getRegistrations) return;
+ let removedController = false;
+ try {
+ const registrations = await serviceWorker.getRegistrations();
+ const currentOrigin = globalThis.location.origin;
+ const officePath = "/office/";
+ for (const registration of registrations) {
+ const scope = new URL(registration.scope);
+ if (scope.origin !== currentOrigin) continue;
+ const scopePath = scope.pathname.endsWith("/") ? scope.pathname : `${scope.pathname}/`;
+ const affectsOffice = scopePath === "/" || scopePath.startsWith(officePath) || officePath.startsWith(scopePath);
+ if (!affectsOffice) continue;
+ const scriptUrl = registration.active?.scriptURL || "";
+ if (scriptUrl.endsWith("/js/sw.js") && scopePath === "/js/") continue;
+ removedController = await registration.unregister() || removedController;
+ }
+ const controllerUrl = serviceWorker.controller?.scriptURL || "";
+ if (removedController && controllerUrl.startsWith(currentOrigin)) {
+ const alreadyReloaded = sessionStorage.getItem(SERVICE_WORKER_CLEANUP_MARKER) === "1";
+ if (!alreadyReloaded) {
+ sessionStorage.setItem(SERVICE_WORKER_CLEANUP_MARKER, "1");
+ globalThis.location.reload();
+ }
+ }
+ } catch (error) {
+ console.warn("Office service worker cleanup skipped", error);
+ }
+ },
+
+ resetCollaboraBrowserState(options = {}) {
+ const force = Boolean(options.force);
+ try {
+ if (!force && localStorage.getItem(COLLABORA_STATE_MARKER) === COLLABORA_STATE_VERSION) {
+ return;
+ }
+ const exactKeys = new Set([
+ "UIDefaults",
+ "WSDFeedbackCount",
+ "WSDFeedbackTimestamp",
+ ]);
+ const collaboraKeyPattern = /^(text|spreadsheet|presentation|drawing)\.[A-Za-z0-9_.-]+$/;
+ for (const key of Object.keys(localStorage)) {
+ if (exactKeys.has(key) || collaboraKeyPattern.test(key)) {
+ localStorage.removeItem(key);
+ }
+ }
+ localStorage.setItem(COLLABORA_STATE_MARKER, COLLABORA_STATE_VERSION);
+ } catch (error) {
+ console.warn("Office browser state cleanup skipped", error);
+ }
+ },
};
export const store = createStore("office", model);