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.
This commit is contained in:
Alessandro 2026-04-26 19:32:50 +02:00
parent 370ac9b878
commit f1b014feb3
10 changed files with 882 additions and 35 deletions

View file

@ -11,6 +11,8 @@ import {
} from "/js/messages.js";
const BROWSER_MODAL = "/plugins/_browser/webui/main.html";
const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
const autoOpenedBrowsers = new Set();
export default async function registerBrowserToolHandler(extData) {
if (extData?.tool_name === "browser") {
@ -18,6 +20,69 @@ export default async function registerBrowserToolHandler(extData) {
}
}
async function openBrowserCanvas(payload = {}) {
const canvas = globalThis.Alpine?.store?.("rightCanvas")
|| (await import("/components/canvas/right-canvas-store.js")).store;
if (canvas) {
await canvas.open("browser", payload);
return;
}
if (window.ensureModalOpen) {
await window.ensureModalOpen(BROWSER_MODAL);
return;
}
await window.openModal?.(BROWSER_MODAL);
}
function parseBrowserResult(content) {
if (!content || typeof content !== "string") return {};
try {
const parsed = JSON.parse(content);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function browserIdFromResult(result = {}, kvps = {}) {
return (
result.id
|| result.browser_id
|| result.state?.id
|| result.last_interacted_browser_id
|| kvps.browser_id
|| null
);
}
function isFreshToolMessage(timestamp) {
const value = Number(timestamp);
if (!Number.isFinite(value) || value <= 0) return true;
const messageMs = value > 10_000_000_000 ? value : value * 1000;
return Math.abs(Date.now() - messageMs) <= AUTO_OPEN_WINDOW_MS;
}
function shouldAutoOpenBrowser(args, result) {
if (!isFreshToolMessage(args?.timestamp)) return false;
const action = String(args?.kvps?.action || "").trim().toLowerCase().replace("-", "_");
if (["list", "content", "detail", "close", "close_all"].includes(action)) return false;
return Boolean(browserIdFromResult(result, args?.kvps || {}) || action === "open" || action === "navigate");
}
function autoOpenBrowserCanvas(args, result) {
if (!shouldAutoOpenBrowser(args, result)) return;
const kvps = args?.kvps || {};
const browserId = browserIdFromResult(result, kvps);
const key = `${args.id || ""}:${kvps.action || ""}:${browserId || ""}:${result.currentUrl || result.state?.currentUrl || kvps.url || ""}`;
const persistedKey = `a0.browser.autoOpened.${key}`;
if (autoOpenedBrowsers.has(key) || sessionStorage.getItem(persistedKey)) return;
autoOpenedBrowsers.add(key);
sessionStorage.setItem(persistedKey, "1");
requestAnimationFrame(() => {
void openBrowserCanvas({ browserId, source: "tool" });
});
}
function drawBrowserTool({
id,
type,
@ -28,27 +93,18 @@ function drawBrowserTool({
agentno = 0,
...additional
}) {
const args = arguments[0];
const title = cleanStepTitle(heading);
const displayKvps = { ...kvps };
const headerLabels = [
kvps?._tool_name && { label: kvps._tool_name, class: "tool-name-badge" },
].filter(Boolean);
const contentText = String(content ?? "");
const browserResult = parseBrowserResult(contentText);
const browserButton = createActionButton(
"visibility",
"Browser",
() => {
const canvas = globalThis.Alpine?.store?.("rightCanvas");
if (canvas) {
void canvas.open("browser");
return;
}
if (window.ensureModalOpen) {
void window.ensureModalOpen(BROWSER_MODAL);
return;
}
void window.openModal?.(BROWSER_MODAL);
},
() => openBrowserCanvas({ browserId: browserIdFromResult(browserResult, kvps), source: "tool" }),
);
browserButton.setAttribute("title", "Open Browser");
browserButton.setAttribute("aria-label", "Open Browser");
@ -60,7 +116,7 @@ function drawBrowserTool({
actionButtons.push(
createActionButton("detail", "", () =>
stepDetailStore.showStepDetail(
buildDetailPayload(arguments[0], { headerLabels }),
buildDetailPayload(args, { headerLabels }),
),
),
createActionButton("speak", "", () => speechStore.speak(contentText)),
@ -68,7 +124,7 @@ function drawBrowserTool({
);
}
return drawProcessStep({
const result = drawProcessStep({
id,
title,
code: "WWW",
@ -76,6 +132,8 @@ function drawBrowserTool({
kvps: displayKvps,
content,
actionButtons: actionButtons.filter(Boolean),
log: arguments[0],
log: args,
});
autoOpenBrowserCanvas(args, browserResult);
return result;
}

View file

@ -24,11 +24,14 @@ export default async function registerBrowserSurface(canvas) {
icon: "language",
order: 10,
modalPath: "/plugins/_browser/webui/main.html",
async open() {
async open(payload = {}) {
const panel = await waitForElement('[data-surface-id="browser"] .browser-panel');
const browser = globalThis.Alpine?.store?.("browserPage");
if (panel && browser?.onOpen) {
await browser.onOpen(panel, { mode: "canvas" });
await browser.onOpen(panel, {
mode: "canvas",
browserId: payload.browserId || payload.browser_id || null,
});
}
},
async close() {

View file

@ -0,0 +1,149 @@
const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
const BROWSER_MODAL = "/plugins/_browser/webui/main.html";
const autoOpenedBrowsers = new Set();
export default async function autoOpenBrowserResults(context) {
if (!context?.results?.length || context.historyEmpty) return;
for (const { args } of context.results) {
const payload = getToolResultPayload(args);
if (getToolName(payload) !== "browser") continue;
const result = parseMaybeJson(payload.tool_result) || {};
if (!shouldAutoOpen(args, payload, result)) continue;
const browserId = getBrowserId(payload, result);
const key = [
args?.id || "",
browserId || "",
result.currentUrl || result.state?.currentUrl || payload.url || "",
].join(":");
const persistedKey = `a0.browser.autoOpened.${key}`;
if (hasOpened(key, persistedKey)) continue;
requestAnimationFrame(() => {
void openBrowserCanvas({ browserId, source: "tool-result" });
});
}
}
function getToolResultPayload(args = {}) {
const topLevelPayload = pickPayloadFields(args);
const contentPayload = parseMaybeJson(args.content);
const kvpsPayload = parseMaybeJson(args.kvps);
return {
...topLevelPayload,
...(contentPayload || {}),
...(kvpsPayload || {}),
};
}
function pickPayloadFields(args = {}) {
const payload = {};
for (const key of [
"_tool_name",
"tool_name",
"tool_result",
"action",
"browser_id",
"browserId",
"url",
"last_modified",
]) {
if (args[key] != null && args[key] !== "") payload[key] = args[key];
}
return payload;
}
function getToolName(payload = {}) {
return String(payload._tool_name || payload.tool_name || "").trim();
}
function parseMaybeJson(value) {
if (!value) return null;
if (typeof value === "object") return value;
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed.startsWith("{")) return null;
try {
const parsed = JSON.parse(trimmed);
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}
function shouldAutoOpen(args = {}, payload = {}, result = {}) {
if (!isFresh(args.timestamp, payload.last_modified || result.last_modified)) return false;
const action = String(payload.action || "").trim().toLowerCase().replace("-", "_");
if (["list", "content", "detail", "close", "close_all"].includes(action)) return false;
return Boolean(
getBrowserId(payload, result)
|| action === "open"
|| action === "navigate"
|| result.currentUrl
|| result.state?.currentUrl,
);
}
function getBrowserId(payload = {}, result = {}) {
return (
result.id
|| result.browser_id
|| result.state?.id
|| result.last_interacted_browser_id
|| payload.browser_id
|| payload.browserId
|| null
);
}
function isFresh(timestamp, fallbackTimestamp) {
const messageMs = toMs(timestamp) || toMs(fallbackTimestamp);
if (!messageMs) return true;
return Math.abs(Date.now() - messageMs) <= AUTO_OPEN_WINDOW_MS;
}
function toMs(value) {
if (value == null || value === "") return 0;
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) {
return numeric > 10_000_000_000 ? numeric : numeric * 1000;
}
const parsed = Date.parse(String(value));
return Number.isFinite(parsed) ? parsed : 0;
}
function hasOpened(key, persistedKey) {
if (autoOpenedBrowsers.has(key)) return true;
autoOpenedBrowsers.add(key);
try {
if (sessionStorage.getItem(persistedKey)) return true;
sessionStorage.setItem(persistedKey, "1");
} catch {
// Best-effort persistence; the in-memory guard still prevents repeat opens.
}
return false;
}
async function openBrowserCanvas(payload = {}) {
const canvas = globalThis.Alpine?.store?.("rightCanvas")
|| (await import("/components/canvas/right-canvas-store.js")).store;
if (canvas) {
await canvas.open("browser", payload);
return;
}
if (window.ensureModalOpen) {
await window.ensureModalOpen(BROWSER_MODAL);
return;
}
await window.openModal?.(BROWSER_MODAL);
}

View file

@ -299,6 +299,7 @@ const model = {
async onOpen(element = null, options = {}) {
this.loading = true;
this.error = "";
const requestedBrowserId = this.normalizeBrowserId(options.browserId ?? options.browser_id);
this._mode = options?.mode === "modal" ? "modal" : "canvas";
if (this._mode === "modal") {
this.setupFloatingModal(element);
@ -308,7 +309,7 @@ const model = {
this.contextId = this.resolveContextId();
try {
await this.refreshStatus();
await this.connectViewer();
await this.connectViewer({ browserId: requestedBrowserId });
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
} finally {

View file

@ -0,0 +1,109 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from helpers import files
from helpers.extension import Extension
from helpers.print_style import PrintStyle
from helpers.tool import Response
from plugins._office.helpers import document_affordance, wopi_store
class DocumentResponseAffordance(Extension):
async def execute(
self,
tool_name: str = "",
response: Response | None = None,
**kwargs: Any,
):
if tool_name != "response" or not self.agent or response is None:
return
tool = self.agent.loop_data.current_tool
if not tool:
return
text = str(tool.args.get("text") or tool.args.get("message") or response.message or "").strip()
user_message = self.agent.last_user_message.content if self.agent.last_user_message else ""
decision = document_affordance.decide_response_artifact(user_message, text)
if decision is None:
return
try:
doc = wopi_store.create_document(
kind=decision.kind,
title=decision.title,
fmt=decision.fmt,
content=decision.content,
)
except Exception as exc:
PrintStyle().error(f"Office document affordance failed: {exc}")
return
payload = {
"ok": True,
"message": "Created document artifact from response.",
"document": public_doc(doc),
}
additional = document_additional(doc)
content = json.dumps(payload, indent=2, ensure_ascii=False)
self.agent.hist_add_tool_result("document_artifact", content, **additional)
display_path = display_workspace_path(doc["path"])
note = document_affordance.format_created_response(doc["basename"], display_path)
response.message = note
tool.args["text"] = note
tool.args["message"] = note
log_item = self.agent.loop_data.params_temporary.get("log_item_response")
if log_item:
log_item.update(
content=note,
kvps={
"action": "create",
"kind": decision.kind,
"title": decision.title,
"format": decision.fmt,
"_tool_name": "document_artifact",
**additional,
},
)
def public_doc(doc: dict[str, Any]) -> dict[str, Any]:
return {
"file_id": doc["file_id"],
"path": display_workspace_path(doc["path"]),
"basename": doc["basename"],
"extension": doc["extension"],
"size": doc["size"],
"version": wopi_store.item_version(doc),
"last_modified": doc["last_modified"],
"exists": Path(doc["path"]).exists(),
}
def document_additional(doc: dict[str, Any]) -> dict[str, Any]:
return {
"_tool_name": "document_artifact",
"canvas_surface": "office",
"file_id": doc["file_id"],
"title": doc["basename"],
"format": doc["extension"],
"path": display_workspace_path(doc["path"]),
"version": wopi_store.item_version(doc),
}
def display_workspace_path(path: str) -> str:
base = Path(files.get_base_dir()).resolve(strict=False)
resolved = Path(path).resolve(strict=False)
if str(base).startswith("/a0"):
return str(resolved)
try:
return "/a0/" + str(resolved.relative_to(base)).lstrip("/")
except ValueError:
return str(path)

View file

@ -10,6 +10,9 @@ import {
drawProcessStep,
} from "/js/messages.js";
const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
const autoOpenedDocuments = new Set();
export default async function registerDocumentArtifactHandler(extData) {
if (extData?.tool_name === "document_artifact") {
extData.handler = drawDocumentArtifactTool;
@ -26,6 +29,71 @@ async function openOfficeCanvas(kvps = {}) {
});
}
function parseDocumentResult(content) {
if (!content || typeof content !== "string") return {};
try {
const parsed = JSON.parse(content);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function documentFromArgs(args, result = {}) {
const kvps = args?.kvps || {};
const document = result.document && typeof result.document === "object"
? result.document
: {};
return {
file_id: kvps.file_id || document.file_id || "",
path: kvps.path || document.path || "",
title: kvps.title || kvps.basename || document.basename || "",
format: kvps.format || kvps.extension || document.extension || "",
version: kvps.version || document.version || "",
};
}
function shouldAutoOpenDocument(args, document) {
const kvps = args?.kvps || {};
if (kvps.canvas_surface && kvps.canvas_surface !== "office") return false;
if (!document?.path) return false;
const action = String(kvps.action || "").trim().toLowerCase();
if (["status", "version_history", "inspect"].includes(action)) return false;
return isFreshToolMessage(args?.timestamp);
}
function isFreshToolMessage(timestamp) {
const value = Number(timestamp);
if (!Number.isFinite(value) || value <= 0) return true;
const messageMs = value > 10_000_000_000 ? value : value * 1000;
return Math.abs(Date.now() - messageMs) <= AUTO_OPEN_WINDOW_MS;
}
function autoOpenOfficeCanvas(args) {
const document = documentFromArgs(args, parseDocumentResult(args?.content));
if (!shouldAutoOpenDocument(args, document)) return;
const key = `${args.id || ""}:${document.file_id || ""}:${document.path || ""}:${document.version || ""}`;
const persistedKey = `a0.office.autoOpened.${key}`;
if (hasOpenedDocument(key, persistedKey)) return;
requestAnimationFrame(() => {
void openOfficeCanvas(document);
});
}
function hasOpenedDocument(key, persistedKey) {
if (autoOpenedDocuments.has(key)) return true;
autoOpenedDocuments.add(key);
try {
if (sessionStorage.getItem(persistedKey)) return true;
sessionStorage.setItem(persistedKey, "1");
} catch {
// Best-effort persistence; the in-memory guard still prevents repeat opens.
}
return false;
}
function drawDocumentArtifactTool({
id,
type,
@ -40,26 +108,25 @@ function drawDocumentArtifactTool({
const title = cleanStepTitle(heading);
const displayKvps = { ...kvps };
const contentText = String(content ?? "");
const documentResult = parseDocumentResult(contentText);
const document = documentFromArgs(args, documentResult);
const headerLabels = [
kvps?._tool_name && { label: kvps._tool_name, class: "tool-name-badge" },
kvps?.format && { label: String(kvps.format).toUpperCase(), class: "tool-name-badge" },
document?.format && { label: String(document.format).toUpperCase(), class: "tool-name-badge" },
].filter(Boolean);
const actionButtons = [
createActionButton("description", "Office", () => openOfficeCanvas(kvps)),
createActionButton("description", "Office", () => openOfficeCanvas(document)),
];
if (kvps?.path) {
if (document?.path) {
actionButtons.push(
createActionButton("content_copy", "Path", () => copyToClipboard(kvps.path)),
createActionButton("content_copy", "Path", () => copyToClipboard(document.path)),
);
}
if (contentText.trim()) {
actionButtons.push(
createActionButton("history", "Versions", () =>
stepDetailStore.showStepDetail(buildDetailPayload(args, { headerLabels })),
),
createActionButton("detail", "", () =>
stepDetailStore.showStepDetail(buildDetailPayload(args, { headerLabels })),
),
@ -68,7 +135,7 @@ function drawDocumentArtifactTool({
);
}
return drawProcessStep({
const result = drawProcessStep({
id,
title,
code: "DOC",
@ -78,4 +145,6 @@ function drawDocumentArtifactTool({
actionButtons: actionButtons.filter(Boolean),
log: args,
});
autoOpenOfficeCanvas(args);
return result;
}

View file

@ -0,0 +1,138 @@
const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
const autoOpenedDocuments = new Set();
export default async function autoOpenDocumentResults(context) {
if (!context?.results?.length || context.historyEmpty) return;
for (const { args } of context.results) {
const payload = getToolResultPayload(args);
if (getToolName(payload) !== "document_artifact") continue;
const document = getDocumentPayload(payload);
if (!document?.path) continue;
if (payload.canvas_surface && payload.canvas_surface !== "office") continue;
if (!isFresh(args?.timestamp, document.last_modified)) continue;
const key = [
args?.id || "",
document.file_id || "",
document.path,
document.version || "",
].join(":");
const persistedKey = `a0.office.autoOpened.${key}`;
if (hasOpened(key, persistedKey)) continue;
requestAnimationFrame(() => {
void openOfficeCanvas(document);
});
}
}
function getToolResultPayload(args = {}) {
const topLevelPayload = pickPayloadFields(args);
const contentPayload = parseMaybeJson(args.content);
const kvpsPayload = parseMaybeJson(args.kvps);
return {
...topLevelPayload,
...(contentPayload || {}),
...(kvpsPayload || {}),
};
}
function pickPayloadFields(args = {}) {
const payload = {};
for (const key of [
"_tool_name",
"tool_name",
"tool_result",
"canvas_surface",
"file_id",
"path",
"title",
"basename",
"format",
"extension",
"version",
"last_modified",
]) {
if (args[key] != null && args[key] !== "") payload[key] = args[key];
}
return payload;
}
function getToolName(payload = {}) {
return String(payload._tool_name || payload.tool_name || "").trim();
}
function getDocumentPayload(payload = {}) {
const result = parseMaybeJson(payload.tool_result) || {};
const document = result.document && typeof result.document === "object"
? result.document
: {};
return {
file_id: payload.file_id || document.file_id || "",
path: payload.path || document.path || "",
title: payload.title || payload.basename || document.basename || "",
format: payload.format || payload.extension || document.extension || "",
version: payload.version || document.version || "",
last_modified: payload.last_modified || document.last_modified || "",
};
}
function parseMaybeJson(value) {
if (!value) return null;
if (typeof value === "object") return value;
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed.startsWith("{")) return null;
try {
const parsed = JSON.parse(trimmed);
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}
function isFresh(timestamp, fallbackTimestamp) {
const messageMs = toMs(timestamp) || toMs(fallbackTimestamp);
if (!messageMs) return true;
return Math.abs(Date.now() - messageMs) <= AUTO_OPEN_WINDOW_MS;
}
function toMs(value) {
if (value == null || value === "") return 0;
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) {
return numeric > 10_000_000_000 ? numeric : numeric * 1000;
}
const parsed = Date.parse(String(value));
return Number.isFinite(parsed) ? parsed : 0;
}
function hasOpened(key, persistedKey) {
if (autoOpenedDocuments.has(key)) return true;
autoOpenedDocuments.add(key);
try {
if (sessionStorage.getItem(persistedKey)) return true;
sessionStorage.setItem(persistedKey, "1");
} catch {
// Best-effort persistence; the in-memory guard still prevents repeat opens.
}
return false;
}
async function openOfficeCanvas(document) {
const canvas = globalThis.Alpine?.store?.("rightCanvas")
|| (await import("/components/canvas/right-canvas-store.js")).store;
await canvas?.open?.("office", {
path: document.path || "",
file_id: document.file_id || "",
source: "tool-result",
});
}

View file

@ -0,0 +1,256 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any
MIN_ARTIFACT_CHARS = 700
MIN_ARTIFACT_WORDS = 110
MIN_EXPLICIT_ARTIFACT_CHARS = 240
MIN_EXPLICIT_ARTIFACT_WORDS = 35
CREATE_TERMS = {
"write",
"draft",
"compose",
"create",
"generate",
"prepare",
"produce",
"make",
"build",
"author",
}
DOCUMENT_TERMS = {
"article",
"brief",
"contract",
"cv",
"doc",
"document",
"docx",
"draft",
"essay",
"guide",
"letter",
"manual",
"memo",
"policy",
"proposal",
"report",
"resume",
"spec",
"story",
"whitepaper",
}
SPREADSHEET_TERMS = {
"budget",
"excel",
"sheet",
"spreadsheet",
"table",
"workbook",
"xlsx",
}
PRESENTATION_TERMS = {
"deck",
"ppt",
"pptx",
"presentation",
"slide",
"slides",
}
CHAT_ONLY_TERMS = {
"answer in chat",
"in chat",
"just answer",
"just reply",
"no file",
"no files",
}
SKIP_RESPONSE_PREFIXES = (
"i can't",
"i cannot",
"i'm sorry",
"sorry,",
"i can help",
)
@dataclass(frozen=True)
class ArtifactDecision:
kind: str
fmt: str
title: str
content: str
reason: str
def decide_response_artifact(user_message: Any, response_text: str) -> ArtifactDecision | None:
user_text = flatten_text(user_message).strip()
response_text = str(response_text or "").strip()
if not user_text or not response_text:
return None
lowered_user = normalize_text(user_text)
lowered_response = normalize_text(response_text[:240])
if any(term in lowered_user for term in CHAT_ONLY_TERMS):
return None
if lowered_response.startswith(SKIP_RESPONSE_PREFIXES):
return None
if looks_like_tool_or_status_response(response_text):
return None
kind, fmt, explicit_artifact = infer_kind_and_format(lowered_user)
if not explicit_artifact and not has_document_creation_intent(lowered_user):
return None
if not is_substantial(response_text, explicit_artifact):
return None
title = infer_title(user_text, response_text, kind)
return ArtifactDecision(
kind=kind,
fmt=fmt,
title=title,
content=response_text,
reason="explicit" if explicit_artifact else "document_intent",
)
def flatten_text(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, dict):
preferred_keys = ("user_message", "user_intervention", "message", "content", "text")
skipped_keys = {*preferred_keys, "attachments", "system_message", "raw_content"}
preferred = []
for key in preferred_keys:
if key in value:
preferred.append(flatten_text(value[key]))
remaining = [
flatten_text(child)
for key, child in value.items()
if key not in skipped_keys
]
return "\n".join(part for part in [*preferred, *remaining] if part)
if isinstance(value, (list, tuple, set)):
return "\n".join(part for item in value if (part := flatten_text(item)))
return str(value)
def normalize_text(value: str) -> str:
return re.sub(r"\s+", " ", value.lower()).strip()
def infer_kind_and_format(lowered_user: str) -> tuple[str, str, bool]:
explicit = False
if has_any(lowered_user, PRESENTATION_TERMS):
explicit = True
return "presentation", "pptx", explicit
if has_any(lowered_user, SPREADSHEET_TERMS):
explicit = True
return "spreadsheet", "xlsx", explicit
if has_any(lowered_user, DOCUMENT_TERMS):
explicit = True
return "document", "docx", explicit
def has_document_creation_intent(lowered_user: str) -> bool:
return has_any(lowered_user, CREATE_TERMS) and has_any(
lowered_user,
DOCUMENT_TERMS | SPREADSHEET_TERMS | PRESENTATION_TERMS,
)
def has_any(text: str, terms: set[str]) -> bool:
return any(re.search(rf"\b{re.escape(term)}\b", text) for term in terms)
def is_substantial(text: str, explicit_artifact: bool) -> bool:
word_count = len(re.findall(r"\w+", text))
char_count = len(text)
if explicit_artifact:
return char_count >= MIN_EXPLICIT_ARTIFACT_CHARS and word_count >= MIN_EXPLICIT_ARTIFACT_WORDS
return char_count >= MIN_ARTIFACT_CHARS and word_count >= MIN_ARTIFACT_WORDS
def looks_like_tool_or_status_response(text: str) -> bool:
stripped = text.strip()
if stripped.startswith("{") and '"tool_name"' in stripped[:300]:
return True
if "/a0/usr/workdir/documents/" in stripped:
return True
return False
def infer_title(user_text: str, response_text: str, kind: str) -> str:
response_title = title_from_response(response_text)
if response_title:
return response_title
request_title = title_from_request(user_text)
if request_title:
return request_title
return {
"spreadsheet": "Spreadsheet",
"presentation": "Presentation",
}.get(kind, "Document")
def title_from_response(response_text: str) -> str:
for raw_line in response_text.splitlines()[:8]:
line = raw_line.strip()
if not line:
continue
for pattern in (
r"^#{1,3}\s+(.+?)\s*$",
r"^\*\*(.+?)\*\*\s*$",
r"^__(.+?)__\s*$",
):
match = re.match(pattern, line)
if match:
return clean_title(match.group(1))
if len(line) <= 80 and not line.endswith((".", "?", "!", ":")):
return clean_title(line)
break
return ""
def title_from_request(user_text: str) -> str:
text = re.sub(r"\s+", " ", user_text).strip()
quoted = re.search(r"[\"'“”](.{4,90}?)[\"'“”]", text)
if quoted:
return clean_title(quoted.group(1))
cleaned = re.sub(
r"\b(write|draft|compose|create|generate|prepare|produce|make|build|author)\b",
"",
text,
flags=re.IGNORECASE,
)
cleaned = re.sub(r"\b(a|an|the|new|for me|please|docx|document|file)\b", "", cleaned, flags=re.IGNORECASE)
cleaned = clean_title(cleaned)
return cleaned if 4 <= len(cleaned) <= 80 else ""
def clean_title(value: str) -> str:
value = re.sub(r"[*_`#>\[\]{}]", "", value)
value = re.sub(r"\s+", " ", value).strip(" .:-")
return value[:90].strip(" .:-")
def format_created_response(basename: str, path: str) -> str:
return (
f"Created **{basename}** and opened it in the Office canvas.\n\n"
f"Path: `{path}`"
)

View file

@ -30,16 +30,14 @@
class="office-health-pill"
:class="`is-${$store.office.status?.state || 'unknown'}`"
:title="$store.office.status?.message || 'Office status'"
:aria-label="$store.office.status?.message || 'Office status'"
>
<span class="office-health-dot"></span>
<span x-text="$store.office.status?.state || 'status'"></span>
<span x-show="$store.office.status?.state !== 'healthy'" x-text="$store.office.status?.state || 'status'"></span>
</span>
<button type="button" class="office-icon-button" title="Save" @click="$store.office.save()" :disabled="!$store.office.session">
<span class="material-symbols-outlined">save</span>
</button>
<button type="button" class="office-icon-button" title="Versions" @click="$store.office.showVersions()" :disabled="!$store.office.session">
<span class="material-symbols-outlined">history</span>
</button>
<button type="button" class="office-icon-button" title="Refresh status" @click="$store.office.refresh()">
<span class="material-symbols-outlined">refresh</span>
</button>
@ -254,6 +252,13 @@
background: color-mix(in srgb, var(--color-panel) 64%, transparent);
}
.office-health-pill.is-healthy {
width: 28px;
min-width: 28px;
padding: 0;
gap: 0;
}
.office-health-dot {
width: 7px;
height: 7px;

View file

@ -2,6 +2,9 @@ import { createStore } from "/js/AlpineStore.js";
import { callJsonApi } from "/js/api.js";
const FRAME_NAME_PREFIX = "a0-office-frame";
const COLLABORA_STATE_VERSION = "2026-04-26.1";
const COLLABORA_STATE_MARKER = "a0.office.collaboraStateVersion";
const SERVICE_WORKER_CLEANUP_MARKER = "a0.office.serviceWorkerCleanupReloaded";
function makeFrameName() {
const id = globalThis.crypto?.randomUUID?.()
@ -131,6 +134,7 @@ const model = {
this.error = "";
this.message = "";
try {
await this.prepareBrowserHostForEditor();
const response = await callJsonApi("/plugins/_office/office_session", payload);
if (!response?.ok) {
this.error = response?.error || "Office session could not be opened.";
@ -210,6 +214,7 @@ const model = {
if (!this.session || this.frameReady || this._frameRecoveryTried) return;
this._frameRecoveryTried = true;
this._frameAttempt += 1;
this.resetCollaboraBrowserState({ force: true });
this.message = "Still opening the editor... trying a fresh editor load.";
await this.submitFrame();
this._frameTimer = setTimeout(() => {
@ -269,11 +274,6 @@ const model = {
this.clearFrameTimers();
},
async showVersions() {
if (!this.session?.file_id) return;
this.message = "Version history is available through the document_artifact tool.";
},
onPostMessage(event) {
if (!this.session) return;
if (!this.isAllowedFrameOrigin(event.origin)) return;
@ -444,6 +444,65 @@ const model = {
this._floatingCleanup = null;
if (element) this._root = element;
},
async prepareBrowserHostForEditor() {
await this.cleanupLegacyOfficeServiceWorkers();
this.resetCollaboraBrowserState();
},
async cleanupLegacyOfficeServiceWorkers() {
const serviceWorker = globalThis.navigator?.serviceWorker;
if (!serviceWorker?.getRegistrations) return;
let removedController = false;
try {
const registrations = await serviceWorker.getRegistrations();
const currentOrigin = globalThis.location.origin;
const officePath = "/office/";
for (const registration of registrations) {
const scope = new URL(registration.scope);
if (scope.origin !== currentOrigin) continue;
const scopePath = scope.pathname.endsWith("/") ? scope.pathname : `${scope.pathname}/`;
const affectsOffice = scopePath === "/" || scopePath.startsWith(officePath) || officePath.startsWith(scopePath);
if (!affectsOffice) continue;
const scriptUrl = registration.active?.scriptURL || "";
if (scriptUrl.endsWith("/js/sw.js") && scopePath === "/js/") continue;
removedController = await registration.unregister() || removedController;
}
const controllerUrl = serviceWorker.controller?.scriptURL || "";
if (removedController && controllerUrl.startsWith(currentOrigin)) {
const alreadyReloaded = sessionStorage.getItem(SERVICE_WORKER_CLEANUP_MARKER) === "1";
if (!alreadyReloaded) {
sessionStorage.setItem(SERVICE_WORKER_CLEANUP_MARKER, "1");
globalThis.location.reload();
}
}
} catch (error) {
console.warn("Office service worker cleanup skipped", error);
}
},
resetCollaboraBrowserState(options = {}) {
const force = Boolean(options.force);
try {
if (!force && localStorage.getItem(COLLABORA_STATE_MARKER) === COLLABORA_STATE_VERSION) {
return;
}
const exactKeys = new Set([
"UIDefaults",
"WSDFeedbackCount",
"WSDFeedbackTimestamp",
]);
const collaboraKeyPattern = /^(text|spreadsheet|presentation|drawing)\.[A-Za-z0-9_.-]+$/;
for (const key of Object.keys(localStorage)) {
if (exactKeys.has(key) || collaboraKeyPattern.test(key)) {
localStorage.removeItem(key);
}
}
localStorage.setItem(COLLABORA_STATE_MARKER, COLLABORA_STATE_VERSION);
} catch (error) {
console.warn("Office browser state cleanup skipped", error);
}
},
};
export const store = createStore("office", model);