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.

Alessandro committed Apr 26, 2026 at 19:32 UTC f1b014feb366d09e56cf9fbcdab003d79e70cc9e
10 files changed +882 -35
plugins/_browser/extensions/webui/get_tool_message_handler/browser-tool-handler.js
+73 -15
@@ -11,6 +11,8 @@ import {
11 } from "/js/messages.js";
12
13 const BROWSER_MODAL = "/plugins/_browser/webui/main.html";
14 +const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
15 +const autoOpenedBrowsers = new Set();
16
17 export default async function registerBrowserToolHandler(extData) {
18 if (extData?.tool_name === "browser") {
@@ -18,6 +20,69 @@ export default async function registerBrowserToolHandler(extData) {
20 }
21 }
22
23 +async function openBrowserCanvas(payload = {}) {
24 + const canvas = globalThis.Alpine?.store?.("rightCanvas")
25 + || (await import("/components/canvas/right-canvas-store.js")).store;
26 + if (canvas) {
27 + await canvas.open("browser", payload);
28 + return;
29 + }
30 + if (window.ensureModalOpen) {
31 + await window.ensureModalOpen(BROWSER_MODAL);
32 + return;
33 + }
34 + await window.openModal?.(BROWSER_MODAL);
35 +}
36 +
37 +function parseBrowserResult(content) {
38 + if (!content || typeof content !== "string") return {};
39 + try {
40 + const parsed = JSON.parse(content);
41 + return parsed && typeof parsed === "object" ? parsed : {};
42 + } catch {
43 + return {};
44 + }
45 +}
46 +
47 +function browserIdFromResult(result = {}, kvps = {}) {
48 + return (
49 + result.id
50 + || result.browser_id
51 + || result.state?.id
52 + || result.last_interacted_browser_id
53 + || kvps.browser_id
54 + || null
55 + );
56 +}
57 +
58 +function isFreshToolMessage(timestamp) {
59 + const value = Number(timestamp);
60 + if (!Number.isFinite(value) || value <= 0) return true;
61 + const messageMs = value > 10_000_000_000 ? value : value * 1000;
62 + return Math.abs(Date.now() - messageMs) <= AUTO_OPEN_WINDOW_MS;
63 +}
64 +
65 +function shouldAutoOpenBrowser(args, result) {
66 + if (!isFreshToolMessage(args?.timestamp)) return false;
67 + const action = String(args?.kvps?.action || "").trim().toLowerCase().replace("-", "_");
68 + if (["list", "content", "detail", "close", "close_all"].includes(action)) return false;
69 + return Boolean(browserIdFromResult(result, args?.kvps || {}) || action === "open" || action === "navigate");
70 +}
71 +
72 +function autoOpenBrowserCanvas(args, result) {
73 + if (!shouldAutoOpenBrowser(args, result)) return;
74 + const kvps = args?.kvps || {};
75 + const browserId = browserIdFromResult(result, kvps);
76 + const key = `${args.id || ""}:${kvps.action || ""}:${browserId || ""}:${result.currentUrl || result.state?.currentUrl || kvps.url || ""}`;
77 + const persistedKey = `a0.browser.autoOpened.${key}`;
78 + if (autoOpenedBrowsers.has(key) || sessionStorage.getItem(persistedKey)) return;
79 + autoOpenedBrowsers.add(key);
80 + sessionStorage.setItem(persistedKey, "1");
81 + requestAnimationFrame(() => {
82 + void openBrowserCanvas({ browserId, source: "tool" });
83 + });
84 +}
85 +
86 function drawBrowserTool({
87 id,
88 type,
@@ -28,27 +93,18 @@ function drawBrowserTool({
93 agentno = 0,
94 ...additional
95 }) {
96 + const args = arguments[0];
97 const title = cleanStepTitle(heading);
98 const displayKvps = { ...kvps };
99 const headerLabels = [
100 kvps?._tool_name && { label: kvps._tool_name, class: "tool-name-badge" },
101 ].filter(Boolean);
102 const contentText = String(content ?? "");
103 + const browserResult = parseBrowserResult(contentText);
104 const browserButton = createActionButton(
105 "visibility",
106 "Browser",
40 - () => {
41 - const canvas = globalThis.Alpine?.store?.("rightCanvas");
42 - if (canvas) {
43 - void canvas.open("browser");
44 - return;
45 - }
46 - if (window.ensureModalOpen) {
47 - void window.ensureModalOpen(BROWSER_MODAL);
48 - return;
49 - }
50 - void window.openModal?.(BROWSER_MODAL);
51 - },
107 + () => openBrowserCanvas({ browserId: browserIdFromResult(browserResult, kvps), source: "tool" }),
108 );
109 browserButton.setAttribute("title", "Open Browser");
110 browserButton.setAttribute("aria-label", "Open Browser");
@@ -60,7 +116,7 @@ function drawBrowserTool({
116 actionButtons.push(
117 createActionButton("detail", "", () =>
118 stepDetailStore.showStepDetail(
63 - buildDetailPayload(arguments[0], { headerLabels }),
119 + buildDetailPayload(args, { headerLabels }),
120 ),
121 ),
122 createActionButton("speak", "", () => speechStore.speak(contentText)),
@@ -68,7 +124,7 @@ function drawBrowserTool({
124 );
125 }
126
71 - return drawProcessStep({
127 + const result = drawProcessStep({
128 id,
129 title,
130 code: "WWW",
@@ -76,6 +132,8 @@ function drawBrowserTool({
132 kvps: displayKvps,
133 content,
134 actionButtons: actionButtons.filter(Boolean),
79 - log: arguments[0],
135 + log: args,
136 });
137 + autoOpenBrowserCanvas(args, browserResult);
138 + return result;
139 }
plugins/_browser/extensions/webui/right_canvas_register_surfaces/register-browser.js
+5 -2
@@ -24,11 +24,14 @@ export default async function registerBrowserSurface(canvas) {
24 icon: "language",
25 order: 10,
26 modalPath: "/plugins/_browser/webui/main.html",
27 - async open() {
27 + async open(payload = {}) {
28 const panel = await waitForElement('[data-surface-id="browser"] .browser-panel');
29 const browser = globalThis.Alpine?.store?.("browserPage");
30 if (panel && browser?.onOpen) {
31 - await browser.onOpen(panel, { mode: "canvas" });
31 + await browser.onOpen(panel, {
32 + mode: "canvas",
33 + browserId: payload.browserId || payload.browser_id || null,
34 + });
35 }
36 },
37 async close() {
plugins/_browser/extensions/webui/set_messages_after_loop/auto-open-browser-results.js new
+149
@@ -0,0 +1,149 @@
1 +const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
2 +const BROWSER_MODAL = "/plugins/_browser/webui/main.html";
3 +const autoOpenedBrowsers = new Set();
4 +
5 +export default async function autoOpenBrowserResults(context) {
6 + if (!context?.results?.length || context.historyEmpty) return;
7 +
8 + for (const { args } of context.results) {
9 + const payload = getToolResultPayload(args);
10 + if (getToolName(payload) !== "browser") continue;
11 +
12 + const result = parseMaybeJson(payload.tool_result) || {};
13 + if (!shouldAutoOpen(args, payload, result)) continue;
14 +
15 + const browserId = getBrowserId(payload, result);
16 + const key = [
17 + args?.id || "",
18 + browserId || "",
19 + result.currentUrl || result.state?.currentUrl || payload.url || "",
20 + ].join(":");
21 + const persistedKey = `a0.browser.autoOpened.${key}`;
22 + if (hasOpened(key, persistedKey)) continue;
23 +
24 + requestAnimationFrame(() => {
25 + void openBrowserCanvas({ browserId, source: "tool-result" });
26 + });
27 + }
28 +}
29 +
30 +function getToolResultPayload(args = {}) {
31 + const topLevelPayload = pickPayloadFields(args);
32 + const contentPayload = parseMaybeJson(args.content);
33 + const kvpsPayload = parseMaybeJson(args.kvps);
34 + return {
35 + ...topLevelPayload,
36 + ...(contentPayload || {}),
37 + ...(kvpsPayload || {}),
38 + };
39 +}
40 +
41 +function pickPayloadFields(args = {}) {
42 + const payload = {};
43 + for (const key of [
44 + "_tool_name",
45 + "tool_name",
46 + "tool_result",
47 + "action",
48 + "browser_id",
49 + "browserId",
50 + "url",
51 + "last_modified",
52 + ]) {
53 + if (args[key] != null && args[key] !== "") payload[key] = args[key];
54 + }
55 + return payload;
56 +}
57 +
58 +function getToolName(payload = {}) {
59 + return String(payload._tool_name || payload.tool_name || "").trim();
60 +}
61 +
62 +function parseMaybeJson(value) {
63 + if (!value) return null;
64 + if (typeof value === "object") return value;
65 + if (typeof value !== "string") return null;
66 +
67 + const trimmed = value.trim();
68 + if (!trimmed.startsWith("{")) return null;
69 + try {
70 + const parsed = JSON.parse(trimmed);
71 + return parsed && typeof parsed === "object" ? parsed : null;
72 + } catch {
73 + return null;
74 + }
75 +}
76 +
77 +function shouldAutoOpen(args = {}, payload = {}, result = {}) {
78 + if (!isFresh(args.timestamp, payload.last_modified || result.last_modified)) return false;
79 +
80 + const action = String(payload.action || "").trim().toLowerCase().replace("-", "_");
81 + if (["list", "content", "detail", "close", "close_all"].includes(action)) return false;
82 +
83 + return Boolean(
84 + getBrowserId(payload, result)
85 + || action === "open"
86 + || action === "navigate"
87 + || result.currentUrl
88 + || result.state?.currentUrl,
89 + );
90 +}
91 +
92 +function getBrowserId(payload = {}, result = {}) {
93 + return (
94 + result.id
95 + || result.browser_id
96 + || result.state?.id
97 + || result.last_interacted_browser_id
98 + || payload.browser_id
99 + || payload.browserId
100 + || null
101 + );
102 +}
103 +
104 +function isFresh(timestamp, fallbackTimestamp) {
105 + const messageMs = toMs(timestamp) || toMs(fallbackTimestamp);
106 + if (!messageMs) return true;
107 + return Math.abs(Date.now() - messageMs) <= AUTO_OPEN_WINDOW_MS;
108 +}
109 +
110 +function toMs(value) {
111 + if (value == null || value === "") return 0;
112 +
113 + const numeric = Number(value);
114 + if (Number.isFinite(numeric) && numeric > 0) {
115 + return numeric > 10_000_000_000 ? numeric : numeric * 1000;
116 + }
117 +
118 + const parsed = Date.parse(String(value));
119 + return Number.isFinite(parsed) ? parsed : 0;
120 +}
121 +
122 +function hasOpened(key, persistedKey) {
123 + if (autoOpenedBrowsers.has(key)) return true;
124 + autoOpenedBrowsers.add(key);
125 +
126 + try {
127 + if (sessionStorage.getItem(persistedKey)) return true;
128 + sessionStorage.setItem(persistedKey, "1");
129 + } catch {
130 + // Best-effort persistence; the in-memory guard still prevents repeat opens.
131 + }
132 +
133 + return false;
134 +}
135 +
136 +async function openBrowserCanvas(payload = {}) {
137 + const canvas = globalThis.Alpine?.store?.("rightCanvas")
138 + || (await import("/components/canvas/right-canvas-store.js")).store;
139 + if (canvas) {
140 + await canvas.open("browser", payload);
141 + return;
142 + }
143 +
144 + if (window.ensureModalOpen) {
145 + await window.ensureModalOpen(BROWSER_MODAL);
146 + return;
147 + }
148 + await window.openModal?.(BROWSER_MODAL);
149 +}
plugins/_browser/webui/browser-store.js
+2 -1
@@ -299,6 +299,7 @@ const model = {
299 async onOpen(element = null, options = {}) {
300 this.loading = true;
301 this.error = "";
302 + const requestedBrowserId = this.normalizeBrowserId(options.browserId ?? options.browser_id);
303 this._mode = options?.mode === "modal" ? "modal" : "canvas";
304 if (this._mode === "modal") {
305 this.setupFloatingModal(element);
@@ -308,7 +309,7 @@ const model = {
309 this.contextId = this.resolveContextId();
310 try {
311 await this.refreshStatus();
311 - await this.connectViewer();
312 + await this.connectViewer({ browserId: requestedBrowserId });
313 } catch (error) {
314 this.error = error instanceof Error ? error.message : String(error);
315 } finally {
plugins/_office/extensions/python/tool_execute_after/_20_document_response_affordance.py new
+109
@@ -0,0 +1,109 @@
1 +from __future__ import annotations
2 +
3 +import json
4 +from pathlib import Path
5 +from typing import Any
6 +
7 +from helpers import files
8 +from helpers.extension import Extension
9 +from helpers.print_style import PrintStyle
10 +from helpers.tool import Response
11 +from plugins._office.helpers import document_affordance, wopi_store
12 +
13 +
14 +class DocumentResponseAffordance(Extension):
15 + async def execute(
16 + self,
17 + tool_name: str = "",
18 + response: Response | None = None,
19 + **kwargs: Any,
20 + ):
21 + if tool_name != "response" or not self.agent or response is None:
22 + return
23 +
24 + tool = self.agent.loop_data.current_tool
25 + if not tool:
26 + return
27 +
28 + text = str(tool.args.get("text") or tool.args.get("message") or response.message or "").strip()
29 + user_message = self.agent.last_user_message.content if self.agent.last_user_message else ""
30 + decision = document_affordance.decide_response_artifact(user_message, text)
31 + if decision is None:
32 + return
33 +
34 + try:
35 + doc = wopi_store.create_document(
36 + kind=decision.kind,
37 + title=decision.title,
38 + fmt=decision.fmt,
39 + content=decision.content,
40 + )
41 + except Exception as exc:
42 + PrintStyle().error(f"Office document affordance failed: {exc}")
43 + return
44 +
45 + payload = {
46 + "ok": True,
47 + "message": "Created document artifact from response.",
48 + "document": public_doc(doc),
49 + }
50 + additional = document_additional(doc)
51 + content = json.dumps(payload, indent=2, ensure_ascii=False)
52 +
53 + self.agent.hist_add_tool_result("document_artifact", content, **additional)
54 +
55 + display_path = display_workspace_path(doc["path"])
56 + note = document_affordance.format_created_response(doc["basename"], display_path)
57 + response.message = note
58 + tool.args["text"] = note
59 + tool.args["message"] = note
60 +
61 + log_item = self.agent.loop_data.params_temporary.get("log_item_response")
62 + if log_item:
63 + log_item.update(
64 + content=note,
65 + kvps={
66 + "action": "create",
67 + "kind": decision.kind,
68 + "title": decision.title,
69 + "format": decision.fmt,
70 + "_tool_name": "document_artifact",
71 + **additional,
72 + },
73 + )
74 +
75 +
76 +def public_doc(doc: dict[str, Any]) -> dict[str, Any]:
77 + return {
78 + "file_id": doc["file_id"],
79 + "path": display_workspace_path(doc["path"]),
80 + "basename": doc["basename"],
81 + "extension": doc["extension"],
82 + "size": doc["size"],
83 + "version": wopi_store.item_version(doc),
84 + "last_modified": doc["last_modified"],
85 + "exists": Path(doc["path"]).exists(),
86 + }
87 +
88 +
89 +def document_additional(doc: dict[str, Any]) -> dict[str, Any]:
90 + return {
91 + "_tool_name": "document_artifact",
92 + "canvas_surface": "office",
93 + "file_id": doc["file_id"],
94 + "title": doc["basename"],
95 + "format": doc["extension"],
96 + "path": display_workspace_path(doc["path"]),
97 + "version": wopi_store.item_version(doc),
98 + }
99 +
100 +
101 +def display_workspace_path(path: str) -> str:
102 + base = Path(files.get_base_dir()).resolve(strict=False)
103 + resolved = Path(path).resolve(strict=False)
104 + if str(base).startswith("/a0"):
105 + return str(resolved)
106 + try:
107 + return "/a0/" + str(resolved.relative_to(base)).lstrip("/")
108 + except ValueError:
109 + return str(path)
plugins/_office/extensions/webui/get_tool_message_handler/document-artifact-handler.js
+77 -8
@@ -10,6 +10,9 @@ import {
10 drawProcessStep,
11 } from "/js/messages.js";
12
13 +const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
14 +const autoOpenedDocuments = new Set();
15 +
16 export default async function registerDocumentArtifactHandler(extData) {
17 if (extData?.tool_name === "document_artifact") {
18 extData.handler = drawDocumentArtifactTool;
@@ -26,6 +29,71 @@ async function openOfficeCanvas(kvps = {}) {
29 });
30 }
31
32 +function parseDocumentResult(content) {
33 + if (!content || typeof content !== "string") return {};
34 + try {
35 + const parsed = JSON.parse(content);
36 + return parsed && typeof parsed === "object" ? parsed : {};
37 + } catch {
38 + return {};
39 + }
40 +}
41 +
42 +function documentFromArgs(args, result = {}) {
43 + const kvps = args?.kvps || {};
44 + const document = result.document && typeof result.document === "object"
45 + ? result.document
46 + : {};
47 + return {
48 + file_id: kvps.file_id || document.file_id || "",
49 + path: kvps.path || document.path || "",
50 + title: kvps.title || kvps.basename || document.basename || "",
51 + format: kvps.format || kvps.extension || document.extension || "",
52 + version: kvps.version || document.version || "",
53 + };
54 +}
55 +
56 +function shouldAutoOpenDocument(args, document) {
57 + const kvps = args?.kvps || {};
58 + if (kvps.canvas_surface && kvps.canvas_surface !== "office") return false;
59 + if (!document?.path) return false;
60 + const action = String(kvps.action || "").trim().toLowerCase();
61 + if (["status", "version_history", "inspect"].includes(action)) return false;
62 + return isFreshToolMessage(args?.timestamp);
63 +}
64 +
65 +function isFreshToolMessage(timestamp) {
66 + const value = Number(timestamp);
67 + if (!Number.isFinite(value) || value <= 0) return true;
68 + const messageMs = value > 10_000_000_000 ? value : value * 1000;
69 + return Math.abs(Date.now() - messageMs) <= AUTO_OPEN_WINDOW_MS;
70 +}
71 +
72 +function autoOpenOfficeCanvas(args) {
73 + const document = documentFromArgs(args, parseDocumentResult(args?.content));
74 + if (!shouldAutoOpenDocument(args, document)) return;
75 + const key = `${args.id || ""}:${document.file_id || ""}:${document.path || ""}:${document.version || ""}`;
76 + const persistedKey = `a0.office.autoOpened.${key}`;
77 + if (hasOpenedDocument(key, persistedKey)) return;
78 + requestAnimationFrame(() => {
79 + void openOfficeCanvas(document);
80 + });
81 +}
82 +
83 +function hasOpenedDocument(key, persistedKey) {
84 + if (autoOpenedDocuments.has(key)) return true;
85 + autoOpenedDocuments.add(key);
86 +
87 + try {
88 + if (sessionStorage.getItem(persistedKey)) return true;
89 + sessionStorage.setItem(persistedKey, "1");
90 + } catch {
91 + // Best-effort persistence; the in-memory guard still prevents repeat opens.
92 + }
93 +
94 + return false;
95 +}
96 +
97 function drawDocumentArtifactTool({
98 id,
99 type,
@@ -40,26 +108,25 @@ function drawDocumentArtifactTool({
108 const title = cleanStepTitle(heading);
109 const displayKvps = { ...kvps };
110 const contentText = String(content ?? "");
111 + const documentResult = parseDocumentResult(contentText);
112 + const document = documentFromArgs(args, documentResult);
113 const headerLabels = [
114 kvps?._tool_name && { label: kvps._tool_name, class: "tool-name-badge" },
45 - kvps?.format && { label: String(kvps.format).toUpperCase(), class: "tool-name-badge" },
115 + document?.format && { label: String(document.format).toUpperCase(), class: "tool-name-badge" },
116 ].filter(Boolean);
117
118 const actionButtons = [
49 - createActionButton("description", "Office", () => openOfficeCanvas(kvps)),
119 + createActionButton("description", "Office", () => openOfficeCanvas(document)),
120 ];
121
52 - if (kvps?.path) {
122 + if (document?.path) {
123 actionButtons.push(
54 - createActionButton("content_copy", "Path", () => copyToClipboard(kvps.path)),
124 + createActionButton("content_copy", "Path", () => copyToClipboard(document.path)),
125 );
126 }
127
128 if (contentText.trim()) {
129 actionButtons.push(
60 - createActionButton("history", "Versions", () =>
61 - stepDetailStore.showStepDetail(buildDetailPayload(args, { headerLabels })),
62 - ),
130 createActionButton("detail", "", () =>
131 stepDetailStore.showStepDetail(buildDetailPayload(args, { headerLabels })),
132 ),
@@ -68,7 +135,7 @@ function drawDocumentArtifactTool({
135 );
136 }
137
71 - return drawProcessStep({
138 + const result = drawProcessStep({
139 id,
140 title,
141 code: "DOC",
@@ -78,4 +145,6 @@ function drawDocumentArtifactTool({
145 actionButtons: actionButtons.filter(Boolean),
146 log: args,
147 });
148 + autoOpenOfficeCanvas(args);
149 + return result;
150 }
plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js new
+138
@@ -0,0 +1,138 @@
1 +const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
2 +const autoOpenedDocuments = new Set();
3 +
4 +export default async function autoOpenDocumentResults(context) {
5 + if (!context?.results?.length || context.historyEmpty) return;
6 +
7 + for (const { args } of context.results) {
8 + const payload = getToolResultPayload(args);
9 + if (getToolName(payload) !== "document_artifact") continue;
10 +
11 + const document = getDocumentPayload(payload);
12 + if (!document?.path) continue;
13 + if (payload.canvas_surface && payload.canvas_surface !== "office") continue;
14 + if (!isFresh(args?.timestamp, document.last_modified)) continue;
15 +
16 + const key = [
17 + args?.id || "",
18 + document.file_id || "",
19 + document.path,
20 + document.version || "",
21 + ].join(":");
22 + const persistedKey = `a0.office.autoOpened.${key}`;
23 + if (hasOpened(key, persistedKey)) continue;
24 +
25 + requestAnimationFrame(() => {
26 + void openOfficeCanvas(document);
27 + });
28 + }
29 +}
30 +
31 +function getToolResultPayload(args = {}) {
32 + const topLevelPayload = pickPayloadFields(args);
33 + const contentPayload = parseMaybeJson(args.content);
34 + const kvpsPayload = parseMaybeJson(args.kvps);
35 + return {
36 + ...topLevelPayload,
37 + ...(contentPayload || {}),
38 + ...(kvpsPayload || {}),
39 + };
40 +}
41 +
42 +function pickPayloadFields(args = {}) {
43 + const payload = {};
44 + for (const key of [
45 + "_tool_name",
46 + "tool_name",
47 + "tool_result",
48 + "canvas_surface",
49 + "file_id",
50 + "path",
51 + "title",
52 + "basename",
53 + "format",
54 + "extension",
55 + "version",
56 + "last_modified",
57 + ]) {
58 + if (args[key] != null && args[key] !== "") payload[key] = args[key];
59 + }
60 + return payload;
61 +}
62 +
63 +function getToolName(payload = {}) {
64 + return String(payload._tool_name || payload.tool_name || "").trim();
65 +}
66 +
67 +function getDocumentPayload(payload = {}) {
68 + const result = parseMaybeJson(payload.tool_result) || {};
69 + const document = result.document && typeof result.document === "object"
70 + ? result.document
71 + : {};
72 +
73 + return {
74 + file_id: payload.file_id || document.file_id || "",
75 + path: payload.path || document.path || "",
76 + title: payload.title || payload.basename || document.basename || "",
77 + format: payload.format || payload.extension || document.extension || "",
78 + version: payload.version || document.version || "",
79 + last_modified: payload.last_modified || document.last_modified || "",
80 + };
81 +}
82 +
83 +function parseMaybeJson(value) {
84 + if (!value) return null;
85 + if (typeof value === "object") return value;
86 + if (typeof value !== "string") return null;
87 +
88 + const trimmed = value.trim();
89 + if (!trimmed.startsWith("{")) return null;
90 + try {
91 + const parsed = JSON.parse(trimmed);
92 + return parsed && typeof parsed === "object" ? parsed : null;
93 + } catch {
94 + return null;
95 + }
96 +}
97 +
98 +function isFresh(timestamp, fallbackTimestamp) {
99 + const messageMs = toMs(timestamp) || toMs(fallbackTimestamp);
100 + if (!messageMs) return true;
101 + return Math.abs(Date.now() - messageMs) <= AUTO_OPEN_WINDOW_MS;
102 +}
103 +
104 +function toMs(value) {
105 + if (value == null || value === "") return 0;
106 +
107 + const numeric = Number(value);
108 + if (Number.isFinite(numeric) && numeric > 0) {
109 + return numeric > 10_000_000_000 ? numeric : numeric * 1000;
110 + }
111 +
112 + const parsed = Date.parse(String(value));
113 + return Number.isFinite(parsed) ? parsed : 0;
114 +}
115 +
116 +function hasOpened(key, persistedKey) {
117 + if (autoOpenedDocuments.has(key)) return true;
118 + autoOpenedDocuments.add(key);
119 +
120 + try {
121 + if (sessionStorage.getItem(persistedKey)) return true;
122 + sessionStorage.setItem(persistedKey, "1");
123 + } catch {
124 + // Best-effort persistence; the in-memory guard still prevents repeat opens.
125 + }
126 +
127 + return false;
128 +}
129 +
130 +async function openOfficeCanvas(document) {
131 + const canvas = globalThis.Alpine?.store?.("rightCanvas")
132 + || (await import("/components/canvas/right-canvas-store.js")).store;
133 + await canvas?.open?.("office", {
134 + path: document.path || "",
135 + file_id: document.file_id || "",
136 + source: "tool-result",
137 + });
138 +}
plugins/_office/helpers/document_affordance.py new
+256
@@ -0,0 +1,256 @@
1 +from __future__ import annotations
2 +
3 +import re
4 +from dataclasses import dataclass
5 +from typing import Any
6 +
7 +
8 +MIN_ARTIFACT_CHARS = 700
9 +MIN_ARTIFACT_WORDS = 110
10 +MIN_EXPLICIT_ARTIFACT_CHARS = 240
11 +MIN_EXPLICIT_ARTIFACT_WORDS = 35
12 +
13 +CREATE_TERMS = {
14 + "write",
15 + "draft",
16 + "compose",
17 + "create",
18 + "generate",
19 + "prepare",
20 + "produce",
21 + "make",
22 + "build",
23 + "author",
24 +}
25 +
26 +DOCUMENT_TERMS = {
27 + "article",
28 + "brief",
29 + "contract",
30 + "cv",
31 + "doc",
32 + "document",
33 + "docx",
34 + "draft",
35 + "essay",
36 + "guide",
37 + "letter",
38 + "manual",
39 + "memo",
40 + "policy",
41 + "proposal",
42 + "report",
43 + "resume",
44 + "spec",
45 + "story",
46 + "whitepaper",
47 +}
48 +
49 +SPREADSHEET_TERMS = {
50 + "budget",
51 + "excel",
52 + "sheet",
53 + "spreadsheet",
54 + "table",
55 + "workbook",
56 + "xlsx",
57 +}
58 +
59 +PRESENTATION_TERMS = {
60 + "deck",
61 + "ppt",
62 + "pptx",
63 + "presentation",
64 + "slide",
65 + "slides",
66 +}
67 +
68 +CHAT_ONLY_TERMS = {
69 + "answer in chat",
70 + "in chat",
71 + "just answer",
72 + "just reply",
73 + "no file",
74 + "no files",
75 +}
76 +
77 +SKIP_RESPONSE_PREFIXES = (
78 + "i can't",
79 + "i cannot",
80 + "i'm sorry",
81 + "sorry,",
82 + "i can help",
83 +)
84 +
85 +
86 +@dataclass(frozen=True)
87 +class ArtifactDecision:
88 + kind: str
89 + fmt: str
90 + title: str
91 + content: str
92 + reason: str
93 +
94 +
95 +def decide_response_artifact(user_message: Any, response_text: str) -> ArtifactDecision | None:
96 + user_text = flatten_text(user_message).strip()
97 + response_text = str(response_text or "").strip()
98 + if not user_text or not response_text:
99 + return None
100 +
101 + lowered_user = normalize_text(user_text)
102 + lowered_response = normalize_text(response_text[:240])
103 + if any(term in lowered_user for term in CHAT_ONLY_TERMS):
104 + return None
105 + if lowered_response.startswith(SKIP_RESPONSE_PREFIXES):
106 + return None
107 + if looks_like_tool_or_status_response(response_text):
108 + return None
109 +
110 + kind, fmt, explicit_artifact = infer_kind_and_format(lowered_user)
111 + if not explicit_artifact and not has_document_creation_intent(lowered_user):
112 + return None
113 +
114 + if not is_substantial(response_text, explicit_artifact):
115 + return None
116 +
117 + title = infer_title(user_text, response_text, kind)
118 + return ArtifactDecision(
119 + kind=kind,
120 + fmt=fmt,
121 + title=title,
122 + content=response_text,
123 + reason="explicit" if explicit_artifact else "document_intent",
124 + )
125 +
126 +
127 +def flatten_text(value: Any) -> str:
128 + if value is None:
129 + return ""
130 + if isinstance(value, str):
131 + return value
132 + if isinstance(value, dict):
133 + preferred_keys = ("user_message", "user_intervention", "message", "content", "text")
134 + skipped_keys = {*preferred_keys, "attachments", "system_message", "raw_content"}
135 + preferred = []
136 + for key in preferred_keys:
137 + if key in value:
138 + preferred.append(flatten_text(value[key]))
139 + remaining = [
140 + flatten_text(child)
141 + for key, child in value.items()
142 + if key not in skipped_keys
143 + ]
144 + return "\n".join(part for part in [*preferred, *remaining] if part)
145 + if isinstance(value, (list, tuple, set)):
146 + return "\n".join(part for item in value if (part := flatten_text(item)))
147 + return str(value)
148 +
149 +
150 +def normalize_text(value: str) -> str:
151 + return re.sub(r"\s+", " ", value.lower()).strip()
152 +
153 +
154 +def infer_kind_and_format(lowered_user: str) -> tuple[str, str, bool]:
155 + explicit = False
156 + if has_any(lowered_user, PRESENTATION_TERMS):
157 + explicit = True
158 + return "presentation", "pptx", explicit
159 + if has_any(lowered_user, SPREADSHEET_TERMS):
160 + explicit = True
161 + return "spreadsheet", "xlsx", explicit
162 + if has_any(lowered_user, DOCUMENT_TERMS):
163 + explicit = True
164 + return "document", "docx", explicit
165 +
166 +
167 +def has_document_creation_intent(lowered_user: str) -> bool:
168 + return has_any(lowered_user, CREATE_TERMS) and has_any(
169 + lowered_user,
170 + DOCUMENT_TERMS | SPREADSHEET_TERMS | PRESENTATION_TERMS,
171 + )
172 +
173 +
174 +def has_any(text: str, terms: set[str]) -> bool:
175 + return any(re.search(rf"\b{re.escape(term)}\b", text) for term in terms)
176 +
177 +
178 +def is_substantial(text: str, explicit_artifact: bool) -> bool:
179 + word_count = len(re.findall(r"\w+", text))
180 + char_count = len(text)
181 + if explicit_artifact:
182 + return char_count >= MIN_EXPLICIT_ARTIFACT_CHARS and word_count >= MIN_EXPLICIT_ARTIFACT_WORDS
183 + return char_count >= MIN_ARTIFACT_CHARS and word_count >= MIN_ARTIFACT_WORDS
184 +
185 +
186 +def looks_like_tool_or_status_response(text: str) -> bool:
187 + stripped = text.strip()
188 + if stripped.startswith("{") and '"tool_name"' in stripped[:300]:
189 + return True
190 + if "/a0/usr/workdir/documents/" in stripped:
191 + return True
192 + return False
193 +
194 +
195 +def infer_title(user_text: str, response_text: str, kind: str) -> str:
196 + response_title = title_from_response(response_text)
197 + if response_title:
198 + return response_title
199 +
200 + request_title = title_from_request(user_text)
201 + if request_title:
202 + return request_title
203 +
204 + return {
205 + "spreadsheet": "Spreadsheet",
206 + "presentation": "Presentation",
207 + }.get(kind, "Document")
208 +
209 +
210 +def title_from_response(response_text: str) -> str:
211 + for raw_line in response_text.splitlines()[:8]:
212 + line = raw_line.strip()
213 + if not line:
214 + continue
215 + for pattern in (
216 + r"^#{1,3}\s+(.+?)\s*$",
217 + r"^\*\*(.+?)\*\*\s*$",
218 + r"^__(.+?)__\s*$",
219 + ):
220 + match = re.match(pattern, line)
221 + if match:
222 + return clean_title(match.group(1))
223 + if len(line) <= 80 and not line.endswith((".", "?", "!", ":")):
224 + return clean_title(line)
225 + break
226 + return ""
227 +
228 +
229 +def title_from_request(user_text: str) -> str:
230 + text = re.sub(r"\s+", " ", user_text).strip()
231 + quoted = re.search(r"[\"'“”](.{4,90}?)[\"'“”]", text)
232 + if quoted:
233 + return clean_title(quoted.group(1))
234 +
235 + cleaned = re.sub(
236 + r"\b(write|draft|compose|create|generate|prepare|produce|make|build|author)\b",
237 + "",
238 + text,
239 + flags=re.IGNORECASE,
240 + )
241 + cleaned = re.sub(r"\b(a|an|the|new|for me|please|docx|document|file)\b", "", cleaned, flags=re.IGNORECASE)
242 + cleaned = clean_title(cleaned)
243 + return cleaned if 4 <= len(cleaned) <= 80 else ""
244 +
245 +
246 +def clean_title(value: str) -> str:
247 + value = re.sub(r"[*_`#>\[\]{}]", "", value)
248 + value = re.sub(r"\s+", " ", value).strip(" .:-")
249 + return value[:90].strip(" .:-")
250 +
251 +
252 +def format_created_response(basename: str, path: str) -> str:
253 + return (
254 + f"Created **{basename}** and opened it in the Office canvas.\n\n"
255 + f"Path: `{path}`"
256 + )
plugins/_office/webui/office-panel.html
+9 -4
@@ -30,16 +30,14 @@
30 class="office-health-pill"
31 :class="`is-${$store.office.status?.state || 'unknown'}`"
32 :title="$store.office.status?.message || 'Office status'"
33 + :aria-label="$store.office.status?.message || 'Office status'"
34 >
35 <span class="office-health-dot"></span>
35 - <span x-text="$store.office.status?.state || 'status'"></span>
36 + <span x-show="$store.office.status?.state !== 'healthy'" x-text="$store.office.status?.state || 'status'"></span>
37 </span>
38 <button type="button" class="office-icon-button" title="Save" @click="$store.office.save()" :disabled="!$store.office.session">
39 <span class="material-symbols-outlined">save</span>
40 </button>
40 - <button type="button" class="office-icon-button" title="Versions" @click="$store.office.showVersions()" :disabled="!$store.office.session">
41 - <span class="material-symbols-outlined">history</span>
42 - </button>
41 <button type="button" class="office-icon-button" title="Refresh status" @click="$store.office.refresh()">
42 <span class="material-symbols-outlined">refresh</span>
43 </button>
@@ -254,6 +252,13 @@
252 background: color-mix(in srgb, var(--color-panel) 64%, transparent);
253 }
254
255 + .office-health-pill.is-healthy {
256 + width: 28px;
257 + min-width: 28px;
258 + padding: 0;
259 + gap: 0;
260 + }
261 +
262 .office-health-dot {
263 width: 7px;
264 height: 7px;
plugins/_office/webui/office-store.js
+64 -5
@@ -2,6 +2,9 @@ import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi } from "/js/api.js";
3
4 const FRAME_NAME_PREFIX = "a0-office-frame";
5 +const COLLABORA_STATE_VERSION = "2026-04-26.1";
6 +const COLLABORA_STATE_MARKER = "a0.office.collaboraStateVersion";
7 +const SERVICE_WORKER_CLEANUP_MARKER = "a0.office.serviceWorkerCleanupReloaded";
8
9 function makeFrameName() {
10 const id = globalThis.crypto?.randomUUID?.()
@@ -131,6 +134,7 @@ const model = {
134 this.error = "";
135 this.message = "";
136 try {
137 + await this.prepareBrowserHostForEditor();
138 const response = await callJsonApi("/plugins/_office/office_session", payload);
139 if (!response?.ok) {
140 this.error = response?.error || "Office session could not be opened.";
@@ -210,6 +214,7 @@ const model = {
214 if (!this.session || this.frameReady || this._frameRecoveryTried) return;
215 this._frameRecoveryTried = true;
216 this._frameAttempt += 1;
217 + this.resetCollaboraBrowserState({ force: true });
218 this.message = "Still opening the editor... trying a fresh editor load.";
219 await this.submitFrame();
220 this._frameTimer = setTimeout(() => {
@@ -269,11 +274,6 @@ const model = {
274 this.clearFrameTimers();
275 },
276
272 - async showVersions() {
273 - if (!this.session?.file_id) return;
274 - this.message = "Version history is available through the document_artifact tool.";
275 - },
276 -
277 onPostMessage(event) {
278 if (!this.session) return;
279 if (!this.isAllowedFrameOrigin(event.origin)) return;
@@ -444,6 +444,65 @@ const model = {
444 this._floatingCleanup = null;
445 if (element) this._root = element;
446 },
447 +
448 + async prepareBrowserHostForEditor() {
449 + await this.cleanupLegacyOfficeServiceWorkers();
450 + this.resetCollaboraBrowserState();
451 + },
452 +
453 + async cleanupLegacyOfficeServiceWorkers() {
454 + const serviceWorker = globalThis.navigator?.serviceWorker;
455 + if (!serviceWorker?.getRegistrations) return;
456 + let removedController = false;
457 + try {
458 + const registrations = await serviceWorker.getRegistrations();
459 + const currentOrigin = globalThis.location.origin;
460 + const officePath = "/office/";
461 + for (const registration of registrations) {
462 + const scope = new URL(registration.scope);
463 + if (scope.origin !== currentOrigin) continue;
464 + const scopePath = scope.pathname.endsWith("/") ? scope.pathname : `${scope.pathname}/`;
465 + const affectsOffice = scopePath === "/" || scopePath.startsWith(officePath) || officePath.startsWith(scopePath);
466 + if (!affectsOffice) continue;
467 + const scriptUrl = registration.active?.scriptURL || "";
468 + if (scriptUrl.endsWith("/js/sw.js") && scopePath === "/js/") continue;
469 + removedController = await registration.unregister() || removedController;
470 + }
471 + const controllerUrl = serviceWorker.controller?.scriptURL || "";
472 + if (removedController && controllerUrl.startsWith(currentOrigin)) {
473 + const alreadyReloaded = sessionStorage.getItem(SERVICE_WORKER_CLEANUP_MARKER) === "1";
474 + if (!alreadyReloaded) {
475 + sessionStorage.setItem(SERVICE_WORKER_CLEANUP_MARKER, "1");
476 + globalThis.location.reload();
477 + }
478 + }
479 + } catch (error) {
480 + console.warn("Office service worker cleanup skipped", error);
481 + }
482 + },
483 +
484 + resetCollaboraBrowserState(options = {}) {
485 + const force = Boolean(options.force);
486 + try {
487 + if (!force && localStorage.getItem(COLLABORA_STATE_MARKER) === COLLABORA_STATE_VERSION) {
488 + return;
489 + }
490 + const exactKeys = new Set([
491 + "UIDefaults",
492 + "WSDFeedbackCount",
493 + "WSDFeedbackTimestamp",
494 + ]);
495 + const collaboraKeyPattern = /^(text|spreadsheet|presentation|drawing)\.[A-Za-z0-9_.-]+$/;
496 + for (const key of Object.keys(localStorage)) {
497 + if (exactKeys.has(key) || collaboraKeyPattern.test(key)) {
498 + localStorage.removeItem(key);
499 + }
500 + }
501 + localStorage.setItem(COLLABORA_STATE_MARKER, COLLABORA_STATE_VERSION);
502 + } catch (error) {
503 + console.warn("Office browser state cleanup skipped", error);
504 + }
505 + },
506 };
507
508 export const store = createStore("office", model);