Harden Office canvas sync and PPTX output

Sync document_artifact results into an already-open Office canvas without auto-opening a closed canvas. Generate PPTX artifacts through the Office plugin writer so PowerPoint decks open in Impress with visible multi-slide content. Add focused regression coverage for canvas sync behavior and PPTX slide creation.

Alessandro committed May 2, 2026 at 15:28 UTC 3466160e4ce5c46b9a01a5f5796746746d02dd9a
6 files changed +370 -32
plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js
+121 -2
@@ -1,3 +1,122 @@
1 -export default async function autoOpenDocumentResults(_context) {
2 - return;
1 +const SYNC_WINDOW_MS = 10 * 60 * 1000;
2 +const syncedDocumentResults = new Set();
3 +
4 +export default async function syncDocumentResultsIntoOpenCanvas(context) {
5 + if (!context?.results?.length || context.historyEmpty) return;
6 + if (!isOfficeCanvasAlreadyOpen()) return;
7 +
8 + for (const { args } of context.results) {
9 + const payload = getDocumentPayload(args);
10 + if (getToolName(payload) !== "document_artifact") continue;
11 + if (!shouldSyncOpenOfficeCanvas(args, payload)) continue;
12 +
13 + const document = payload.document && typeof payload.document === "object" ? payload.document : {};
14 + const path = payload.path || document.path || "";
15 + const fileId = payload.file_id || document.file_id || "";
16 + if (!path && !fileId) continue;
17 +
18 + const key = [
19 + args?.id || "",
20 + payload.action || "",
21 + fileId || "",
22 + path || "",
23 + payload.version || document.version || "",
24 + ].join(":");
25 + if (syncedDocumentResults.has(key)) continue;
26 + syncedDocumentResults.add(key);
27 +
28 + globalThis.setTimeout(async () => {
29 + if (!isOfficeCanvasAlreadyOpen()) return;
30 + const office = globalThis.Alpine?.store?.("office");
31 + if (!office || isDirtySameDocument(office, { path, file_id: fileId })) return;
32 + await office.openSession?.({
33 + path,
34 + file_id: fileId,
35 + source: "tool-result-sync",
36 + });
37 + }, 0);
38 + }
39 +}
40 +
41 +function getDocumentPayload(args = {}) {
42 + const contentPayload = parseMaybeJson(args.content);
43 + const kvpsPayload = args.kvps && typeof args.kvps === "object"
44 + ? args.kvps
45 + : parseMaybeJson(args.kvps);
46 + return {
47 + ...pickPayloadFields(args),
48 + ...(contentPayload || {}),
49 + ...(kvpsPayload || {}),
50 + };
51 +}
52 +
53 +function pickPayloadFields(args = {}) {
54 + const payload = {};
55 + for (const key of [
56 + "_tool_name",
57 + "tool_name",
58 + "action",
59 + "file_id",
60 + "path",
61 + "version",
62 + "last_modified",
63 + ]) {
64 + if (args[key] != null && args[key] !== "") payload[key] = args[key];
65 + }
66 + return payload;
67 +}
68 +
69 +function getToolName(payload = {}) {
70 + return String(payload._tool_name || payload.tool_name || "").trim();
71 +}
72 +
73 +function shouldSyncOpenOfficeCanvas(args = {}, payload = {}) {
74 + if (!isFresh(args.timestamp, payload.last_modified || payload.document?.last_modified)) return false;
75 + const action = String(payload.action || "").trim().toLowerCase().replace("-", "_");
76 + return ["create", "open", "edit", "restore_version"].includes(action);
77 +}
78 +
79 +function isOfficeCanvasAlreadyOpen() {
80 + const canvas = globalThis.Alpine?.store?.("rightCanvas");
81 + return Boolean(canvas?.isOpen && canvas?.activeSurfaceId === "office");
82 +}
83 +
84 +function isDirtySameDocument(office, document = {}) {
85 + if (!office?.dirty || !office?.session) return false;
86 + const path = String(document.path || "");
87 + const fileId = String(document.file_id || "");
88 + return Boolean(
89 + (fileId && office.session.file_id === fileId)
90 + || (path && office.session.path === path),
91 + );
92 +}
93 +
94 +function isFresh(...timestamps) {
95 + const now = Date.now();
96 + for (const value of timestamps) {
97 + const time = parseTimestamp(value);
98 + if (time && now - time < SYNC_WINDOW_MS) return true;
99 + }
100 + return false;
101 +}
102 +
103 +function parseTimestamp(value) {
104 + if (!value) return 0;
105 + if (typeof value === "number") return value > 1e12 ? value : value * 1000;
106 + const parsed = Date.parse(String(value));
107 + return Number.isFinite(parsed) ? parsed : 0;
108 +}
109 +
110 +function parseMaybeJson(value) {
111 + if (!value) return null;
112 + if (typeof value === "object") return value;
113 + if (typeof value !== "string") return null;
114 + const trimmed = value.trim();
115 + if (!trimmed.startsWith("{")) return null;
116 + try {
117 + const parsed = JSON.parse(trimmed);
118 + return parsed && typeof parsed === "object" ? parsed : null;
119 + } catch {
120 + return null;
121 + }
122 }
plugins/_office/helpers/artifact_editor.py
+2 -20
@@ -10,7 +10,7 @@ from typing import Any
10 from xml.sax.saxutils import escape
11 import xml.etree.ElementTree as ET
12
13 -from plugins._office.helpers import document_store
13 +from plugins._office.helpers import document_store, pptx_writer
14
15
16 W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
@@ -1205,25 +1205,7 @@ def _slide_from_mapping(value: dict[str, Any]) -> dict[str, Any]:
1205
1206
1207 def _pptx_from_slides(slides: list[dict[str, Any]]) -> bytes:
1208 - if not slides:
1209 - slides = [{"title": "Presentation", "bullets": []}]
1210 -
1211 - files: dict[str, str | bytes] = {
1212 - "[Content_Types].xml": _pptx_content_types(len(slides)),
1213 - "_rels/.rels": (
1214 - '<?xml version="1.0" encoding="UTF-8"?>'
1215 - f'<Relationships xmlns="{REL_NS}">'
1216 - '<Relationship Id="rId1" '
1217 - 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
1218 - 'Target="ppt/presentation.xml"/>'
1219 - "</Relationships>"
1220 - ),
1221 - "ppt/_rels/presentation.xml.rels": _pptx_presentation_rels(len(slides)),
1222 - "ppt/presentation.xml": _pptx_presentation_xml(len(slides)),
1223 - }
1224 - for index, slide in enumerate(slides, start=1):
1225 - files[f"ppt/slides/slide{index}.xml"] = _pptx_slide_xml(slide)
1226 - return _zip_map(files)
1208 + return pptx_writer.pptx_from_slides(slides)
1209
1210
1211 def _pptx_content_types(count: int) -> str:
plugins/_office/helpers/document_store.py
+2 -8
@@ -17,6 +17,7 @@ from typing import Any
17 from xml.sax.saxutils import escape
18
19 from helpers import files
20 +from plugins._office.helpers import pptx_writer
21
22
23 PLUGIN_NAME = "_office"
@@ -829,11 +830,4 @@ def _column_name(index: int) -> str:
830
831
832 def _pptx(title: str, content: str) -> bytes:
832 - subtitle = content.splitlines()[0] if content.splitlines() else ""
833 - return _zip_bytes({
834 - "[Content_Types].xml": """<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/><Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/></Types>""",
835 - "_rels/.rels": """<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/></Relationships>""",
836 - "ppt/_rels/presentation.xml.rels": """<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/></Relationships>""",
837 - "ppt/presentation.xml": """<?xml version="1.0" encoding="UTF-8"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst><p:sldSz cx="9144000" cy="5143500"/></p:presentation>""",
838 - "ppt/slides/slide1.xml": f"""<?xml version="1.0" encoding="UTF-8"?><p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/><p:sp><p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>{escape(title)}</a:t></a:r></a:p><a:p><a:r><a:t>{escape(subtitle)}</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>""",
839 - })
833 + return pptx_writer.pptx_from_text(title, content)
plugins/_office/helpers/pptx_writer.py new
+225
@@ -0,0 +1,225 @@
1 +from __future__ import annotations
2 +
3 +import io
4 +import json
5 +import re
6 +import zipfile
7 +from typing import Any
8 +from xml.sax.saxutils import escape
9 +
10 +
11 +A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
12 +P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
13 +R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
14 +CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
15 +REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
16 +
17 +
18 +def pptx_from_text(title: str, content: str) -> bytes:
19 + return pptx_from_slides(slides_from_text(title, content))
20 +
21 +
22 +def slides_from_text(title: str, content: str) -> list[dict[str, Any]]:
23 + normalized = normalize_slides(content)
24 + document_title = str(title or "Presentation").strip() or "Presentation"
25 + if not normalized:
26 + return [{"title": document_title, "bullets": []}]
27 +
28 + text = str(content or "")
29 + if len(normalized) == 1 and "---" not in text:
30 + slide = normalized[0]
31 + slide_title = str(slide.get("title") or "").strip()
32 + if slide_title and slide_title.casefold() != document_title.casefold():
33 + return [{"title": document_title, "bullets": [slide_title, *slide.get("bullets", [])]}]
34 + return normalized
35 +
36 +
37 +def normalize_slides(value: Any) -> list[dict[str, Any]]:
38 + if value is None:
39 + return []
40 + if isinstance(value, str):
41 + stripped = value.strip()
42 + if not stripped:
43 + return []
44 + if stripped.startswith("[") or stripped.startswith("{"):
45 + return normalize_slides(json.loads(stripped))
46 + chunks = re.split(r"(?m)^\s*---+\s*$", stripped)
47 + result = []
48 + for chunk in chunks:
49 + lines = [_clean_slide_line(line) for line in chunk.splitlines() if line.strip()]
50 + lines = [line for line in lines if line]
51 + if lines:
52 + result.append({"title": lines[0], "bullets": lines[1:]})
53 + return result
54 + if isinstance(value, dict):
55 + return [_slide_from_mapping(value)]
56 + if isinstance(value, list):
57 + result = []
58 + for item in value:
59 + if isinstance(item, dict):
60 + result.append(_slide_from_mapping(item))
61 + elif isinstance(item, str):
62 + result.extend(normalize_slides(item))
63 + elif isinstance(item, (list, tuple)):
64 + lines = [_clean_slide_line(part) for part in item if str(part).strip()]
65 + if lines:
66 + result.append({"title": lines[0], "bullets": lines[1:]})
67 + else:
68 + result.append({"title": str(item), "bullets": []})
69 + return result
70 + return [{"title": str(value), "bullets": []}]
71 +
72 +
73 +def pptx_from_slides(slides: list[dict[str, Any]]) -> bytes:
74 + normalized = normalize_slides(slides)
75 + if not normalized:
76 + normalized = [{"title": "Presentation", "bullets": []}]
77 + try:
78 + return _pptx_from_slides_with_python_pptx(normalized)
79 + except Exception:
80 + return _pptx_from_slides_ooxml(normalized)
81 +
82 +
83 +def _slide_from_mapping(value: dict[str, Any]) -> dict[str, Any]:
84 + title = _clean_slide_line(value.get("title") or value.get("heading") or "Slide")
85 + bullets = value.get("bullets")
86 + if bullets is None:
87 + body = value.get("body") or value.get("content") or ""
88 + bullets = [_clean_slide_line(line) for line in str(body).splitlines() if line.strip()]
89 + elif isinstance(bullets, str):
90 + bullets = [_clean_slide_line(line) for line in bullets.splitlines() if line.strip()]
91 + else:
92 + bullets = [_clean_slide_line(item) for item in bullets]
93 + return {"title": title or "Slide", "bullets": [bullet for bullet in bullets if bullet]}
94 +
95 +
96 +def _clean_slide_line(value: Any) -> str:
97 + line = str(value or "").strip()
98 + line = re.sub(r"^\s{0,3}#{1,6}\s+", "", line)
99 + line = re.sub(r"^\s*(?:[-*•]|\d+[.)])\s+", "", line)
100 + return line.strip()
101 +
102 +
103 +def _pptx_from_slides_with_python_pptx(slides: list[dict[str, Any]]) -> bytes:
104 + from pptx import Presentation # type: ignore
105 + from pptx.util import Inches # type: ignore
106 +
107 + presentation = Presentation()
108 + for slide_spec in slides:
109 + layout = presentation.slide_layouts[1] if len(presentation.slide_layouts) > 1 else presentation.slide_layouts[0]
110 + slide = presentation.slides.add_slide(layout)
111 + title = str(slide_spec.get("title") or "Slide")
112 + bullets = [str(item) for item in slide_spec.get("bullets") or []]
113 +
114 + if slide.shapes.title:
115 + slide.shapes.title.text = title
116 + else:
117 + title_box = slide.shapes.add_textbox(Inches(0.6), Inches(0.35), Inches(8.8), Inches(0.8))
118 + title_box.text_frame.text = title
119 +
120 + body_shape = slide.placeholders[1] if len(slide.placeholders) > 1 else None
121 + if body_shape is None:
122 + body_shape = slide.shapes.add_textbox(Inches(0.85), Inches(1.45), Inches(8.35), Inches(4.55))
123 +
124 + text_frame = body_shape.text_frame
125 + text_frame.clear()
126 + if not bullets:
127 + text_frame.text = ""
128 + continue
129 +
130 + for index, bullet in enumerate(bullets):
131 + paragraph = text_frame.paragraphs[0] if index == 0 else text_frame.add_paragraph()
132 + paragraph.text = bullet
133 + paragraph.level = 0
134 +
135 + buffer = io.BytesIO()
136 + presentation.save(buffer)
137 + return buffer.getvalue()
138 +
139 +
140 +def _pptx_from_slides_ooxml(slides: list[dict[str, Any]]) -> bytes:
141 + files: dict[str, str | bytes] = {
142 + "[Content_Types].xml": _pptx_content_types(len(slides)),
143 + "_rels/.rels": (
144 + '<?xml version="1.0" encoding="UTF-8"?>'
145 + f'<Relationships xmlns="{REL_NS}">'
146 + '<Relationship Id="rId1" '
147 + 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
148 + 'Target="ppt/presentation.xml"/>'
149 + "</Relationships>"
150 + ),
151 + "ppt/_rels/presentation.xml.rels": _pptx_presentation_rels(len(slides)),
152 + "ppt/presentation.xml": _pptx_presentation_xml(len(slides)),
153 + }
154 + for index, slide in enumerate(slides, start=1):
155 + files[f"ppt/slides/slide{index}.xml"] = _pptx_slide_xml(slide)
156 + return _zip_map(files)
157 +
158 +
159 +def _pptx_content_types(count: int) -> str:
160 + overrides = [
161 + '<Override PartName="/ppt/presentation.xml" '
162 + 'ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>'
163 + ]
164 + for index in range(1, count + 1):
165 + overrides.append(
166 + f'<Override PartName="/ppt/slides/slide{index}.xml" '
167 + 'ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>'
168 + )
169 + return (
170 + '<?xml version="1.0" encoding="UTF-8"?>'
171 + f'<Types xmlns="{CT_NS}">'
172 + '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
173 + '<Default Extension="xml" ContentType="application/xml"/>'
174 + + "".join(overrides)
175 + + "</Types>"
176 + )
177 +
178 +
179 +def _pptx_presentation_rels(count: int) -> str:
180 + rels = []
181 + for index in range(1, count + 1):
182 + rels.append(
183 + f'<Relationship Id="rId{index}" '
184 + 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" '
185 + f'Target="slides/slide{index}.xml"/>'
186 + )
187 + return '<?xml version="1.0" encoding="UTF-8"?>' + f'<Relationships xmlns="{REL_NS}">' + "".join(rels) + "</Relationships>"
188 +
189 +
190 +def _pptx_presentation_xml(count: int) -> str:
191 + slide_ids = "".join(f'<p:sldId id="{255 + index}" r:id="rId{index}"/>' for index in range(1, count + 1))
192 + return (
193 + '<?xml version="1.0" encoding="UTF-8"?>'
194 + f'<p:presentation xmlns:p="{P_NS}" xmlns:r="{R_NS}">'
195 + f"<p:sldIdLst>{slide_ids}</p:sldIdLst>"
196 + '<p:sldSz cx="9144000" cy="5143500"/>'
197 + "</p:presentation>"
198 + )
199 +
200 +
201 +def _pptx_slide_xml(slide: dict[str, Any]) -> str:
202 + title = str(slide.get("title") or "Slide")
203 + bullets = [str(item) for item in slide.get("bullets") or []]
204 + paragraphs = [title, *bullets]
205 + text = "".join(f"<a:p><a:r><a:t>{escape(item)}</a:t></a:r></a:p>" for item in paragraphs)
206 + return (
207 + '<?xml version="1.0" encoding="UTF-8"?>'
208 + f'<p:sld xmlns:a="{A_NS}" xmlns:p="{P_NS}">'
209 + "<p:cSld><p:spTree>"
210 + '<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>'
211 + "<p:grpSpPr/>"
212 + '<p:sp><p:nvSpPr><p:cNvPr id="2" name="Content"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>'
213 + f"<p:txBody><a:bodyPr/><a:lstStyle/>{text}</p:txBody>"
214 + "</p:sp>"
215 + "</p:spTree></p:cSld>"
216 + "</p:sld>"
217 + )
218 +
219 +
220 +def _zip_map(files_map: dict[str, str | bytes]) -> bytes:
221 + buffer = io.BytesIO()
222 + with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
223 + for name, value in files_map.items():
224 + archive.writestr(name, value.encode("utf-8") if isinstance(value, str) else value)
225 + return buffer.getvalue()
tests/test_office_canvas_setup.py
+5 -1
@@ -321,7 +321,11 @@ def test_right_canvas_requires_explicit_open_and_is_absent_on_mobile():
321 assert "body.right-canvas-mobile-mode .right-canvas" in canvas_css
322 assert "display: none !important" in canvas_css
323 assert "autoOpenOfficeCanvas" not in handler
324 - assert "requestAnimationFrame" not in after_loop
324 + assert "isOfficeCanvasAlreadyOpen" in after_loop
325 + assert 'canvas?.isOpen && canvas?.activeSurfaceId === "office"' in after_loop
326 + assert "office.openSession?.(" in after_loop
327 + assert 'source: "tool-result-sync"' in after_loop
328 + assert 'rightCanvas.open' not in after_loop
329
330
331 def test_office_skills_preserve_markdown_first_and_opt_in_desktop_policy():
tests/test_office_document_store.py
+15 -1
@@ -112,7 +112,21 @@ def test_xlsx_and_pptx_creation_and_direct_edits_still_work(office_state):
112 assert rows[1][1] == 12500
113 assert rows[2][0] == "Research"
114
115 - deck = document_store.create_document("presentation", "Roadmap", "pptx", "Initial")
115 + deck = document_store.create_document(
116 + "presentation",
117 + "Roadmap",
118 + "pptx",
119 + "Roadmap\nLaunch sequence\n\n---\n\nNext\nPolish rollout",
120 + )
121 + created_deck_read = artifact_editor.read_artifact(deck)
122 + with zipfile.ZipFile(deck["path"]) as archive:
123 + created_slide_names = [name for name in archive.namelist() if name.startswith("ppt/slides/slide") and name.endswith(".xml")]
124 +
125 + assert created_deck_read["slide_count"] == 2
126 + assert created_deck_read["slides"][0]["title"] == "Roadmap"
127 + assert created_deck_read["slides"][1]["title"] == "Next"
128 + assert len(created_slide_names) == 2
129 +
130 updated_deck, deck_payload = artifact_editor.edit_artifact(
131 deck,
132 operation="set_slides",