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