Persist Browser and Desktop modal modes

Alessandro committed May 2, 2026 at 21:06 UTC 2e0069751358a6938d30719ac47312c3090df67e
4 files changed +128 -3
tests/test_browser_agent_regressions.py
+30
@@ -662,6 +662,36 @@ def test_browser_entry_points_prefer_canvas_and_modal_dock_handoff():
662 assert "Alpine.store" not in js
663
664
665 +def test_browser_and_desktop_surface_buttons_remember_latest_window_mode():
666 + canvas_store = (PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas-store.js").read_text(
667 + encoding="utf-8"
668 + )
669 + canvas_html = (PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas.html").read_text(
670 + encoding="utf-8"
671 + )
672 + modals_js = (PROJECT_ROOT / "webui" / "js" / "modals.js").read_text(encoding="utf-8")
673 +
674 + assert "surfaceModes: {}" in canvas_store
675 + assert "recordSurfaceMode(surfaceId" in canvas_store
676 + assert "latestSurfaceMode(surfaceId)" in canvas_store
677 + assert "async openLatest(surfaceId" in canvas_store
678 + assert "async openModalSurface(surfaceId" in canvas_store
679 + assert "this.recordSurfaceMode(targetId, SURFACE_MODE_CANVAS" in canvas_store
680 + assert "this.recordSurfaceMode(targetId, SURFACE_MODE_MODAL)" in canvas_store
681 + assert "surfaceModes: this.surfaceModes" in canvas_store
682 + assert "normalizeSurfaceMode(mode)" in canvas_store
683 +
684 + assert '@click="$store.rightCanvas.openLatest(surface.id)"' in canvas_html
685 + assert '@click="$store.rightCanvas.open(surface.id)"' in canvas_html
686 +
687 + assert 'rightCanvasStore.recordSurfaceMode?.(metadata.surfaceId, "modal")' in modals_js
688 + assert "modalRequiresExplicitClose" in modals_js
689 + assert '"plugins/_browser/webui/main.html"' in modals_js
690 + assert '"plugins/_office/webui/main.html"' in modals_js
691 + assert "&& !modalRequiresExplicitClose(newModal)" in modals_js
692 + assert "if (modalRequiresExplicitClose(modalStack[modalStack.length - 1])) return;" in modals_js
693 +
694 +
695 def test_browser_tool_does_not_auto_open_canvas_policy_is_documented():
696 prompt = (
697 PROJECT_ROOT / "plugins" / "_browser" / "prompts" / "agent.system.tool.browser.md"
webui/components/canvas/right-canvas-store.js
+73 -1
@@ -7,6 +7,8 @@ const MIN_WIDTH = 420;
7 const MAX_WIDTH = 900;
8 const DESKTOP_BREAKPOINT = 1200;
9 const MOBILE_BREAKPOINT = 768;
10 +const SURFACE_MODE_CANVAS = "canvas";
11 +const SURFACE_MODE_MODAL = "modal";
12
13 function clamp(value, min, max) {
14 return Math.min(Math.max(value, min), max);
@@ -16,9 +18,14 @@ function viewportWidth() {
18 return Math.max(document.documentElement.clientWidth || 0, globalThis.innerWidth || 0);
19 }
20
21 +function normalizeSurfaceMode(mode = "") {
22 + return mode === SURFACE_MODE_MODAL ? SURFACE_MODE_MODAL : SURFACE_MODE_CANVAS;
23 +}
24 +
25 const model = {
26 surfaces: [],
27 activeSurfaceId: "",
28 + surfaceModes: {},
29 isOpen: false,
30 width: DEFAULT_WIDTH,
31 isOverlayMode: false,
@@ -75,6 +82,9 @@ const model = {
82 } else {
83 this.surfaces.push(normalized);
84 }
85 + if (!this.surfaceModes[normalized.id]) {
86 + this.surfaceModes[normalized.id] = SURFACE_MODE_CANVAS;
87 + }
88 this.surfaces.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
89 if (!this._registering) {
90 this.ensureActiveSurface();
@@ -116,6 +126,7 @@ const model = {
126
127 this.activeSurfaceId = targetId;
128 this.isOpen = true;
129 + this.recordSurfaceMode(targetId, SURFACE_MODE_CANVAS, { persist: false });
130 this._lastPayloadBySurface[targetId] = payload || {};
131 this.persist();
132 this.applyLayoutState();
@@ -128,6 +139,30 @@ const model = {
139 return true;
140 },
141
142 + recordSurfaceMode(surfaceId, mode = SURFACE_MODE_CANVAS, options = {}) {
143 + const targetId = String(surfaceId || "").trim();
144 + if (!targetId) return;
145 + this.surfaceModes = {
146 + ...this.surfaceModes,
147 + [targetId]: normalizeSurfaceMode(mode),
148 + };
149 + if (options.persist !== false) this.persist();
150 + },
151 +
152 + latestSurfaceMode(surfaceId) {
153 + const targetId = String(surfaceId || "").trim();
154 + return normalizeSurfaceMode(this.surfaceModes[targetId]);
155 + },
156 +
157 + async openLatest(surfaceId = "", payload = {}) {
158 + const targetId = surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "";
159 + if (!targetId) return false;
160 + if (this.latestSurfaceMode(targetId) === SURFACE_MODE_MODAL) {
161 + return await this.openModalSurface(targetId, payload);
162 + }
163 + return await this.open(targetId, payload);
164 + },
165 +
166 async close() {
167 const surface = this.currentSurface();
168 this.isOpen = false;
@@ -194,6 +229,8 @@ const model = {
229 const surface = this.getSurface(targetId);
230 const modalPath = payload.modalPath || surface?.modalPath || "";
231 if (!surface || !modalPath) return false;
232 + const openModal = globalThis.ensureModalOpen || globalThis.openModal;
233 + if (!openModal) return false;
234 if (this.activeSurfaceId === targetId) {
235 this.isOpen = false;
236 this.persist();
@@ -204,13 +241,41 @@ const model = {
241 console.error(`Canvas surface ${targetId} failed to close while undocking`, error);
242 }
243 }
207 - const modalPromise = globalThis.ensureModalOpen?.(modalPath);
244 + this.recordSurfaceMode(targetId, SURFACE_MODE_MODAL);
245 + const modalPromise = openModal(modalPath);
246 if (modalPromise?.catch) {
247 modalPromise.catch((error) => console.error(`Canvas surface ${targetId} failed to undock`, error));
248 }
249 return true;
250 },
251
252 + async openModalSurface(surfaceId = "", payload = {}) {
253 + const targetId = surfaceId || this.activeSurfaceId;
254 + const surface = this.getSurface(targetId);
255 + const modalPath = payload.modalPath || surface?.modalPath || "";
256 + if (!surface || !modalPath) return false;
257 + const openModal = globalThis.ensureModalOpen || globalThis.openModal;
258 + if (!openModal) return false;
259 +
260 + if (this.isOpen && this.activeSurfaceId === targetId) {
261 + this.isOpen = false;
262 + this.persist();
263 + this.applyLayoutState();
264 + try {
265 + await surface.close?.(this._lastPayloadBySurface[targetId] || {});
266 + } catch (error) {
267 + console.error(`Canvas surface ${targetId} failed to close before modal open`, error);
268 + }
269 + }
270 +
271 + this.recordSurfaceMode(targetId, SURFACE_MODE_MODAL);
272 + const modalPromise = openModal(modalPath);
273 + if (modalPromise?.catch) {
274 + modalPromise.catch((error) => console.error(`Canvas surface ${targetId} failed to open as modal`, error));
275 + }
276 + return true;
277 + },
278 +
279 async undockActiveSurface() {
280 return await this.undockSurface(this.activeSurfaceId);
281 },
@@ -301,6 +366,7 @@ const model = {
366 JSON.stringify({
367 isOpen: this.isOpen,
368 activeSurfaceId: this.activeSurfaceId,
369 + surfaceModes: this.surfaceModes,
370 width: this.width,
371 }),
372 );
@@ -315,6 +381,12 @@ const model = {
381 const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
382 this.isOpen = false;
383 this.activeSurfaceId = String(saved.activeSurfaceId || "");
384 + this.surfaceModes = Object.fromEntries(
385 + Object.entries(saved.surfaceModes || {}).map(([surfaceId, mode]) => [
386 + surfaceId,
387 + normalizeSurfaceMode(mode),
388 + ]),
389 + );
390 if (saved.width) this.width = Number(saved.width);
391 } catch (error) {
392 console.warn("Could not restore right canvas state", error);
webui/components/canvas/right-canvas.html
+1 -1
@@ -46,7 +46,7 @@
46 :class="{ 'is-active': $store.rightCanvas.isSurfaceActive(surface.id) && $store.rightCanvas.isOpen }"
47 :title="surface.title"
48 :aria-label="surface.title"
49 - @click="$store.rightCanvas.open(surface.id)"
49 + @click="$store.rightCanvas.openLatest(surface.id)"
50 >
51 <template x-if="surface.image">
52 <img class="right-canvas-surface-image" :src="surface.image" alt="" aria-hidden="true">
webui/js/modals.js
+24 -1
@@ -5,6 +5,22 @@ import { store as rightCanvasStore } from "/components/canvas/right-canvas-store
5
6 // Modal functionality
7 const modalStack = [];
8 +const EXPLICIT_CLOSE_MODAL_PATHS = new Set([
9 + "plugins/_browser/webui/main.html",
10 + "plugins/_office/webui/main.html",
11 +]);
12 +
13 +function normalizeModalPath(modalPath = "") {
14 + return String(modalPath || "").replace(/^\/+/, "");
15 +}
16 +
17 +function modalRequiresExplicitClose(modalOrElement) {
18 + const element = modalOrElement?.element || modalOrElement;
19 + const path = normalizeModalPath(modalOrElement?.path || element?.path || "");
20 + return EXPLICIT_CLOSE_MODAL_PATHS.has(path)
21 + || element?.classList?.contains("modal-explicit-close")
22 + || element?.querySelector?.(".modal-inner")?.classList?.contains("modal-explicit-close");
23 +}
24
25 function findModalIndexByPath(modalPath) {
26 return modalStack.findIndex((modal) => modal.path === modalPath);
@@ -115,7 +131,11 @@ function createModalElement(path) {
131 mouseDownTarget = event.target;
132 });
133 newModal.addEventListener("mouseup", (event) => {
118 - if (event.target === newModal && mouseDownTarget === newModal) {
134 + if (
135 + event.target === newModal
136 + && mouseDownTarget === newModal
137 + && !modalRequiresExplicitClose(newModal)
138 + ) {
139 closeModal();
140 }
141 mouseDownTarget = null;
@@ -187,6 +207,8 @@ function configureModalDockButton(modal, doc) {
207 return;
208 }
209
210 + rightCanvasStore.recordSurfaceMode?.(metadata.surfaceId, "modal");
211 +
212 const button = document.createElement("button");
213 button.type = "button";
214 button.className = "modal-dock-button";
@@ -469,6 +491,7 @@ document.addEventListener("click", async (e) => {
491 // Close modal on escape key (closes only the top modal)
492 document.addEventListener("keydown", (e) => {
493 if (e.key === "Escape" && modalStack.length > 0) {
494 + if (modalRequiresExplicitClose(modalStack[modalStack.length - 1])) return;
495 closeModal();
496 }
497 });