fix(canvas): keep browser and office surfaces opt-in

Make Markdown the first-class document workflow in the office skills and state the Desktop/LibreOffice path as opt-in for GUI or binary Office work. Remove passive Browser canvas auto-opening from tool results; Browser result handling now only syncs an already-open Browser canvas, while explicit user buttons can still open the canvas or modal. Add regression coverage for the no-auto-open policy and Markdown-first skill guidance.

Alessandro committed May 2, 2026 at 13:39 UTC ce7ec3cb4c0847f91c7e8740adb5638ffba290e4
9 files changed +118 -58
plugins/_browser/default_config.yaml
+1 -1
@@ -5,7 +5,7 @@ extension_paths: []
5 # Page opened by new Browser sessions when no URL is provided.
6 default_homepage: "about:blank"
7
8 -# Focus Browser canvas pages automatically when agent Browser tool results arrive.
8 +# When the Browser canvas is already open, keep it synced to agent Browser tool results.
9 autofocus_active_page: true
10
11 # Optional _model_config preset used by Browser-owned model helpers.
plugins/_browser/extensions/webui/get_tool_message_handler/browser-tool-handler.js
+30 -22
@@ -14,7 +14,7 @@ import {
14
15 const BROWSER_MODAL = "/plugins/_browser/webui/main.html";
16 const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
17 -const autoOpenedBrowsers = new Set();
17 +const syncedBrowserCanvases = new Set();
18
19 export default async function registerBrowserToolHandler(extData) {
20 if (extData?.tool_name === "browser") {
@@ -37,17 +37,6 @@ async function openBrowserCanvas(payload = {}) {
37 }
38 }
39
40 -async function browserAllowsToolAutofocus() {
41 - try {
42 - if (browserStore.allowsToolAutofocus) {
43 - return await browserStore.allowsToolAutofocus();
44 - }
45 - } catch (error) {
46 - console.warn("Browser autofocus setting could not be checked", error);
47 - }
48 - return true;
49 -}
50 -
40 function parseBrowserResult(content) {
41 if (!content || typeof content !== "string") return {};
42 try {
@@ -86,28 +75,47 @@ function isFreshToolMessage(timestamp) {
75 return Math.abs(Date.now() - messageMs) <= AUTO_OPEN_WINDOW_MS;
76 }
77
89 -function shouldAutoOpenBrowser(args, result) {
78 +async function browserAllowsToolAutofocus() {
79 + try {
80 + if (browserStore.allowsToolAutofocus) {
81 + return await browserStore.allowsToolAutofocus();
82 + }
83 + } catch (error) {
84 + console.warn("Browser autofocus setting could not be checked", error);
85 + }
86 + return true;
87 +}
88 +
89 +function isBrowserCanvasAlreadyOpen() {
90 + return Boolean(
91 + rightCanvasStore?.isOpen
92 + && rightCanvasStore?.activeSurfaceId === "browser"
93 + && !rightCanvasStore?.isMobileMode,
94 + );
95 +}
96 +
97 +function shouldSyncOpenBrowserCanvas(args, result) {
98 + if (!isBrowserCanvasAlreadyOpen()) return false;
99 if (!isFreshToolMessage(args?.timestamp)) return false;
100 const action = String(args?.kvps?.action || "").trim().toLowerCase().replace("-", "_");
101 if (["list", "content", "detail", "close", "close_all"].includes(action)) return false;
102 return Boolean(browserIdFromResult(result, args?.kvps || {}) || action === "open" || action === "navigate");
103 }
104
96 -function autoOpenBrowserCanvas(args, result) {
97 - if (!shouldAutoOpenBrowser(args, result)) return;
105 +function syncOpenBrowserCanvas(args, result) {
106 + if (!shouldSyncOpenBrowserCanvas(args, result)) return;
107 const kvps = args?.kvps || {};
108 const browserId = browserIdFromResult(result, kvps);
109 const key = `${args.id || ""}:${kvps.action || ""}:${browserId || ""}:${result.currentUrl || result.state?.currentUrl || kvps.url || ""}`;
101 - const persistedKey = `a0.browser.autoOpened.${key}`;
102 - if (autoOpenedBrowsers.has(key) || sessionStorage.getItem(persistedKey)) return;
103 - autoOpenedBrowsers.add(key);
104 - sessionStorage.setItem(persistedKey, "1");
110 + if (syncedBrowserCanvases.has(key)) return;
111 + syncedBrowserCanvases.add(key);
112 requestAnimationFrame(async () => {
113 + if (!isBrowserCanvasAlreadyOpen()) return;
114 if (!(await browserAllowsToolAutofocus())) return;
107 - void openBrowserCanvas({
115 + void rightCanvasStore.open("browser", {
116 browserId,
117 contextId: browserContextIdFromResult(result, kvps),
110 - source: "tool",
118 + source: "tool-sync",
119 });
120 });
121 }
@@ -167,6 +175,6 @@ function drawBrowserTool({
175 actionButtons: actionButtons.filter(Boolean),
176 log: args,
177 });
170 - autoOpenBrowserCanvas(args, browserResult);
178 + syncOpenBrowserCanvas(args, browserResult);
179 return result;
180 }
plugins/_browser/extensions/webui/set_messages_after_loop/auto-open-browser-results.js
+22 -22
@@ -2,18 +2,18 @@ import { store as rightCanvasStore } from "/components/canvas/right-canvas-store
2 import { store as browserStore } from "/plugins/_browser/webui/browser-store.js";
3
4 const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
5 -const BROWSER_MODAL = "/plugins/_browser/webui/main.html";
6 -const autoOpenedBrowsers = new Set();
5 +const syncedBrowserCanvases = new Set();
6
8 -export default async function autoOpenBrowserResults(context) {
7 +export default async function syncBrowserResultsIntoOpenCanvas(context) {
8 if (!context?.results?.length || context.historyEmpty) return;
9 + if (!isBrowserCanvasAlreadyOpen()) return;
10
11 for (const { args } of context.results) {
12 const payload = getToolResultPayload(args);
13 if (getToolName(payload) !== "browser") continue;
14
15 const result = parseMaybeJson(payload.tool_result) || {};
16 - if (!shouldAutoOpen(args, payload, result)) continue;
16 + if (!shouldSyncOpenBrowserCanvas(args, payload, result)) continue;
17
18 const browserId = getBrowserId(payload, result);
19 const contextId = getBrowserContextId(payload, result);
@@ -22,12 +22,13 @@ export default async function autoOpenBrowserResults(context) {
22 browserId || "",
23 result.currentUrl || result.state?.currentUrl || payload.url || "",
24 ].join(":");
25 - const persistedKey = `a0.browser.autoOpened.${key}`;
25 + const persistedKey = `a0.browser.synced.${key}`;
26 if (hasOpened(key, persistedKey)) continue;
27
28 requestAnimationFrame(async () => {
29 + if (!isBrowserCanvasAlreadyOpen()) return;
30 if (!(await browserAllowsToolAutofocus())) return;
30 - void openBrowserCanvas({ browserId, contextId, source: "tool-result" });
31 + void syncOpenBrowserCanvas({ browserId, contextId, source: "tool-result-sync" });
32 });
33 }
34 }
@@ -79,7 +80,8 @@ function parseMaybeJson(value) {
80 }
81 }
82
82 -function shouldAutoOpen(args = {}, payload = {}, result = {}) {
83 +function shouldSyncOpenBrowserCanvas(args = {}, payload = {}, result = {}) {
84 + if (!isBrowserCanvasAlreadyOpen()) return false;
85 if (!isFresh(args.timestamp, payload.last_modified || result.last_modified)) return false;
86
87 const action = String(payload.action || "").trim().toLowerCase().replace("-", "_");
@@ -135,32 +137,30 @@ function toMs(value) {
137 }
138
139 function hasOpened(key, persistedKey) {
138 - if (autoOpenedBrowsers.has(key)) return true;
139 - autoOpenedBrowsers.add(key);
140 + if (syncedBrowserCanvases.has(key)) return true;
141 + syncedBrowserCanvases.add(key);
142
143 try {
144 if (sessionStorage.getItem(persistedKey)) return true;
145 sessionStorage.setItem(persistedKey, "1");
146 } catch {
145 - // Best-effort persistence; the in-memory guard still prevents repeat opens.
147 + // Best-effort persistence; the in-memory guard still prevents repeat syncs.
148 }
149
150 return false;
151 }
152
151 -async function openBrowserCanvas(payload = {}) {
152 - if (rightCanvasStore?.open) {
153 - await rightCanvasStore.open("browser", payload);
154 - return;
155 - }
153 +async function syncOpenBrowserCanvas(payload = {}) {
154 + if (!isBrowserCanvasAlreadyOpen()) return;
155 + await rightCanvasStore.open("browser", payload);
156 +}
157
157 - if (window.ensureModalOpen) {
158 - await window.ensureModalOpen(BROWSER_MODAL);
159 - return;
160 - }
161 - if (window.openModal) {
162 - await window.openModal(BROWSER_MODAL);
163 - }
158 +function isBrowserCanvasAlreadyOpen() {
159 + return Boolean(
160 + rightCanvasStore?.isOpen
161 + && rightCanvasStore?.activeSurfaceId === "browser"
162 + && !rightCanvasStore?.isMobileMode,
163 + );
164 }
165
166 async function browserAllowsToolAutofocus() {
plugins/_browser/prompts/agent.system.tool.browser.md
+3 -1
@@ -1,9 +1,11 @@
1 ### browser
2 -direct Playwright browser control with visible WebUI viewer
2 +direct Playwright browser control with optional visible WebUI viewer
3 use for web browsing, page inspection, forms, downloads, and browser-only tasks
4 state stays open per chat context
5 refs come from content as typed markers: [link 3], [button 6], [image 1], [input text 8]
6
7 +Browser tool actions must not open the right canvas automatically. Use the tool headlessly unless the user opens the Browser canvas or explicitly asks for a visible browser view; if the Browser canvas is already open, it may reflect the active page.
8 +
9 actions: open list state navigate back forward reload content detail click type submit type_submit scroll evaluate close close_all
10 common args: action browser_id url ref text selector selectors script
11
plugins/_browser/webui/config.html
+2 -2
@@ -18,7 +18,7 @@
18 <div class="browser-config-card">
19 <div class="section-title">Browsing</div>
20 <div class="section-description">
21 - Set how new Browser sessions start and whether agent activity should pull focus.
21 + Set how new Browser sessions start and how an already-open Browser canvas follows agent activity.
22 </div>
23
24 <label class="browser-config-field">
@@ -34,7 +34,7 @@
34 <label class="browser-config-switch-row">
35 <span class="browser-config-switch-copy">
36 <span class="browser-config-field-label">Autofocus active page</span>
37 - <span class="browser-config-field-help">Focus pages opened or changed by Browser tool results.</span>
37 + <span class="browser-config-field-help">Update the visible Browser canvas for pages opened or changed by Browser tool results.</span>
38 </span>
39 <span class="browser-config-toggle-with-label">
40 <span class="browser-config-toggle-label" x-text="$store.browserConfig.autofocusLabel()"></span>
plugins/_office/skills/linux-desktop/SKILL.md
+7 -5
@@ -19,15 +19,17 @@ allowed_tools:
19
20 # Linux Desktop Interface
21
22 -Use the Desktop as a full Linux GUI when the user needs a visual workflow, an installed desktop app, or manual layout polish that is awkward through structured file edits alone. Use structured tools first for deterministic content changes, then use the Desktop for inspection, GUI-only actions, and final visual confirmation.
22 +Use the Desktop as a full Linux GUI when the user explicitly needs a visual workflow, an installed desktop app, or manual layout polish that is awkward through structured file edits alone. The Desktop is opt-in: do not launch it just because the user asks for a document. Use structured tools first for deterministic content changes, then use the Desktop for inspection, GUI-only actions, and final visual confirmation.
23
24 ## Operating Model
25
26 1. Prefer `document_artifact` for creating, reading, and editing Markdown, DOCX, XLSX, and PPTX files.
27 -2. Open the Desktop only when the user asks for the Desktop, a GUI app, or visual confirmation.
28 -3. Launch common apps from the Desktop icons, the header buttons, or `scripts/desktopctl.sh`.
29 -4. Use the external Agent Zero Browser for web browsing. Do not launch an operating-system browser in this version.
30 -5. Verify GUI work by observing the desktop state, checking window titles, and saving the file before reporting success.
27 +2. Treat Markdown as first-class. For writing, notes, reports, and drafts with no explicit binary Office requirement, create Markdown and use the custom Markdown editor when the user opens the canvas.
28 +3. Open the Desktop only when the user asks for the Desktop, a GUI app, binary Office visual work, or visual confirmation.
29 +4. Never open the Desktop/canvas automatically from a tool result if the user has not opened it.
30 +5. Launch common apps from the Desktop icons, the header buttons, or `scripts/desktopctl.sh`.
31 +6. Use the external Agent Zero Browser for web browsing. Do not launch an operating-system browser in this version.
32 +7. Verify GUI work by observing the desktop state, checking window titles, and saving the file before reporting success.
33
34 ## Control Flow
35
plugins/_office/skills/office-artifacts/SKILL.md
+6 -1
@@ -20,7 +20,9 @@ allowed_tools:
20
21 # Document Artifacts
22
23 -Use `document_artifact` for substantial deliverables that should remain editable in the canvas. Markdown is the default document format. Use DOCX only when the user explicitly asks for it or needs a Word-compatible binary file.
23 +Use `document_artifact` for substantial deliverables that should remain editable in the custom document canvas. Markdown is the first-class document format and the default for writing, notes, reports, briefs, and drafts. Use DOCX, XLSX, or PPTX only when the user explicitly asks for that binary format, provides an existing file in that format, or needs a Word/Excel/PowerPoint-compatible artifact.
24 +
25 +The canvas is user-owned UI. Creating, reading, or editing an artifact must save the file and update its state, but it must not open the canvas automatically if the user has not opened it. Provide an explicit document action/button path for the user instead.
26
27 ## Workflow
28
@@ -126,6 +128,9 @@ Arguments:
128
129 - Prefer `file_id` from canvas context or prior tool output; use `path` when that is all you have.
130 - Use `read` before editing unless the current saved content is already known.
131 +- For document-style requests with no requested binary format, create Markdown and let the custom Markdown editor be the primary interactive surface.
132 +- Treat Desktop and LibreOffice as opt-in visual tools for explicit GUI requests, binary Office formats, or final layout inspection.
133 +- Never open the canvas automatically from a tool result. If the user has not opened the canvas, leave the saved artifact available through the normal UI affordance.
134 - Do not create ODT, ODS, or ODP in this pass; return a clear unsupported response if asked.
135 - Use native `create_chart` for embedded spreadsheet charts. Reach for Python/code execution only when the requested chart behavior is not supported by the tool.
136 - Use `edit` for precise saved changes; use the visual document canvas for human/manual layout polish.
tests/test_browser_agent_regressions.py
+31 -4
@@ -539,12 +539,22 @@ def test_browser_entry_points_prefer_canvas_and_modal_dock_handoff():
539 assert "releaseSurfaceBindings()" in browser_store
540 assert "this.releaseSurfaceBindings();" in browser_store
541
542 + assert "async function openBrowserCanvas" in tool_handler
543 + assert 'await rightCanvasStore.open("browser", payload);' in tool_handler
544 + assert "window.ensureModalOpen" in tool_handler
545 + assert "window.openModal" in tool_handler
546 + assert "function syncOpenBrowserCanvas" in tool_handler
547 + assert "async function syncOpenBrowserCanvas" in after_loop_handler
548 + assert "syncBrowserResultsIntoOpenCanvas" in after_loop_handler
549 + assert "window.ensureModalOpen" not in after_loop_handler
550 + assert "window.openModal" not in after_loop_handler
551 +
552 for js in (tool_handler, after_loop_handler):
543 - assert "async function openBrowserCanvas" in js
553 assert "openBrowserModal" not in js
545 - assert js.index('await rightCanvasStore.open("browser", payload);') < js.index("if (window.ensureModalOpen)")
546 -
547 - assert "function autoOpenBrowserCanvas" in tool_handler
554 + assert "isBrowserCanvasAlreadyOpen" in js
555 + assert "rightCanvasStore?.isOpen" in js
556 + assert 'rightCanvasStore?.activeSurfaceId === "browser"' in js
557 + assert "autoOpenBrowserCanvas" not in js
558
559 for js in (tool_handler, after_loop_handler, register_js, browser_store, modals_js):
560 assert "globalThis.Alpine" not in js
@@ -552,6 +562,23 @@ def test_browser_entry_points_prefer_canvas_and_modal_dock_handoff():
562 assert "Alpine.store" not in js
563
564
565 +def test_browser_tool_does_not_auto_open_canvas_policy_is_documented():
566 + prompt = (
567 + PROJECT_ROOT / "plugins" / "_browser" / "prompts" / "agent.system.tool.browser.md"
568 + ).read_text(encoding="utf-8")
569 + config = (PROJECT_ROOT / "plugins" / "_browser" / "default_config.yaml").read_text(
570 + encoding="utf-8"
571 + )
572 + config_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "config.html").read_text(
573 + encoding="utf-8"
574 + )
575 +
576 + assert "must not open the right canvas automatically" in prompt
577 + assert "Use the tool headlessly unless the user opens the Browser canvas" in prompt
578 + assert "already open" in config
579 + assert "already-open Browser canvas" in config_html
580 +
581 +
582 def test_browser_canvas_uses_plain_panel_without_debug_probe():
583 panel_html = (
584 PROJECT_ROOT
tests/test_office_canvas_setup.py
+16
@@ -304,3 +304,19 @@ def test_right_canvas_requires_explicit_open_and_is_absent_on_mobile():
304 assert "display: none !important" in canvas_css
305 assert "autoOpenOfficeCanvas" not in handler
306 assert "requestAnimationFrame" not in after_loop
307 +
308 +
309 +def test_office_skills_preserve_markdown_first_and_opt_in_desktop_policy():
310 + office_skill = (
311 + PROJECT_ROOT / "plugins" / "_office" / "skills" / "office-artifacts" / "SKILL.md"
312 + ).read_text(encoding="utf-8")
313 + desktop_skill = (
314 + PROJECT_ROOT / "plugins" / "_office" / "skills" / "linux-desktop" / "SKILL.md"
315 + ).read_text(encoding="utf-8")
316 +
317 + assert "Markdown is the first-class document format" in office_skill
318 + assert "custom document canvas" in office_skill
319 + assert "must not open the canvas automatically" in office_skill
320 + assert "The Desktop is opt-in" in desktop_skill
321 + assert "custom Markdown editor" in desktop_skill
322 + assert "Never open the Desktop/canvas automatically" in desktop_skill