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);