Improve canvas and modal surface controls
Remove the fixed right-canvas width limits so the panel can shrink to zero and grow across the available workspace. Add Browser/Desktop surface-switch buttons to modal headers using the same registered surface metadata as the canvas controls, while preserving modal-mode preference and dock-to-canvas behavior. Add regression coverage for the unlimited canvas sizing and modal surface switcher controls.
Alessandro committed
May 5, 2026 at 11:39 UTC
3e07099d565eabdc63ed4c61050a3fb6a4c6f960
6 files changed
+162
-16
tests/test_browser_agent_regressions.py
+11
@@ -670,6 +670,7 @@ def test_browser_and_desktop_surface_buttons_remember_latest_window_mode():
670
encoding="utf-8"
671
)
672
modals_js = (PROJECT_ROOT / "webui" / "js" / "modals.js").read_text(encoding="utf-8")
673
+ modals_css = (PROJECT_ROOT / "webui" / "css" / "modals.css").read_text(encoding="utf-8")
674
675
assert "surfaceModes: {}" in canvas_store
676
assert "recordSurfaceMode(surfaceId" in canvas_store
@@ -685,11 +686,21 @@ def test_browser_and_desktop_surface_buttons_remember_latest_window_mode():
686
assert '@click="$store.rightCanvas.open(surface.id)"' in canvas_html
687
688
assert 'rightCanvasStore.recordSurfaceMode?.(metadata.surfaceId, "modal")' in modals_js
689
+ assert "configureModalSurfaceSwitcher" in modals_js
690
+ assert "modal-surface-switcher" in modals_js
691
+ assert "modal-surface-button" in modals_js
692
+ assert "rightCanvasStore.panelSurfaces" in modals_js
693
+ assert 'rightCanvasStore.recordSurfaceMode?.(surface.id, "modal")' in modals_js
694
+ assert "await closeModal(modal.path)" in modals_js
695
assert "modalRequiresExplicitClose" in modals_js
696
assert '"plugins/_browser/webui/main.html"' in modals_js
697
assert '"plugins/_office/webui/main.html"' in modals_js
698
assert "&& !modalRequiresExplicitClose(newModal)" in modals_js
699
assert "if (modalRequiresExplicitClose(modalStack[modalStack.length - 1])) return;" in modals_js
700
+ assert ".modal-surface-switcher" in modals_css
701
+ assert ".modal-surface-button.is-active" in modals_css
702
+ assert ".modal-surface-image" in modals_css
703
+ assert "grid-auto-flow: column" in modals_css
704
705
706
def test_browser_tool_does_not_auto_open_canvas_policy_is_documented():
tests/test_office_canvas_setup.py
+6
-1
@@ -416,7 +416,12 @@ def test_right_canvas_requires_explicit_open_and_is_absent_on_mobile():
416
assert "right-canvas-resize-end" in canvas_store
417
assert "dispatchResizeEvent" in canvas_store
418
assert "this.isOpen = false;" in canvas_store
419
- assert "wasMobileMode && this.width <= MIN_WIDTH" in canvas_store
419
+ assert "wasMobileMode && this.width < MIN_WIDTH" in canvas_store
420
+ assert "const MIN_WIDTH = 0" in canvas_store
421
+ assert "const MAX_WIDTH" not in canvas_store
422
+ assert "0.58" not in canvas_store
423
+ assert "min(900px, 58vw)" not in canvas_css
424
+ assert "max-width: none" in canvas_css
425
assert "if (this.isMobileMode && !surface.actionOnly)" in canvas_store
426
assert "if (this.isMobileMode)" in canvas_store
427
assert "shouldRender()" in canvas_store
webui/components/canvas/right-canvas-store.js
+19
-7
@@ -3,8 +3,7 @@ import { callJsExtensions } from "/js/extensions.js";
3
4
const STORAGE_KEY = "a0.rightCanvas";
5
const DEFAULT_WIDTH = 720;
6
-const MIN_WIDTH = 420;
7
-const MAX_WIDTH = 900;
6
+const MIN_WIDTH = 0;
7
const DESKTOP_BREAKPOINT = 1200;
8
const MOBILE_BREAKPOINT = 768;
9
const SURFACE_MODE_CANVAS = "canvas";
@@ -18,6 +17,12 @@ function viewportWidth() {
17
return Math.max(document.documentElement.clientWidth || 0, globalThis.innerWidth || 0);
18
}
19
20
+function normalizeWidth(value, fallback = DEFAULT_WIDTH) {
21
+ if (value === null || value === undefined || value === "") return fallback;
22
+ const width = Number(value);
23
+ return Number.isFinite(width) ? Math.max(MIN_WIDTH, Math.round(width)) : fallback;
24
+}
25
+
26
function normalizeSurfaceMode(mode = "") {
27
return mode === SURFACE_MODE_MODAL ? SURFACE_MODE_MODAL : SURFACE_MODE_CANVAS;
28
}
@@ -306,15 +311,22 @@ const model = {
311
312
setWidth(px, options = {}) {
313
const { persist = true } = options;
309
- const max = this.maxWidth();
310
- const next = clamp(Number(px) || DEFAULT_WIDTH, MIN_WIDTH, max);
314
+ const next = clamp(normalizeWidth(px), MIN_WIDTH, this.maxWidth());
315
this.width = next;
316
this.applyLayoutState();
317
if (persist) this.persist();
318
},
319
320
maxWidth() {
317
- return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.floor(viewportWidth() * 0.58)));
321
+ if (this.isOverlayMode) {
322
+ return Math.max(MIN_WIDTH, viewportWidth() - 44);
323
+ }
324
+
325
+ const container = this._rootElement?.closest(".container");
326
+ const rightPanel = document.getElementById("right-panel");
327
+ const containerRight = container?.getBoundingClientRect().right ?? viewportWidth();
328
+ const panelLeft = rightPanel?.getBoundingClientRect().left ?? 0;
329
+ return Math.max(MIN_WIDTH, Math.floor(containerRight - panelLeft));
330
},
331
332
defaultWidth() {
@@ -387,7 +399,7 @@ const model = {
399
normalizeSurfaceMode(mode),
400
]),
401
);
390
- if (saved.width) this.width = Number(saved.width);
402
+ if (Number.isFinite(Number(saved.width))) this.width = Number(saved.width);
403
} catch (error) {
404
console.warn("Could not restore right canvas state", error);
405
}
@@ -409,7 +421,7 @@ const model = {
421
surface.close?.({ ...payload, reason: "mobile" });
422
}, 0);
423
}
412
- } else if (wasMobileMode && this.width <= MIN_WIDTH) {
424
+ } else if (wasMobileMode && this.width < MIN_WIDTH) {
425
this.width = this.defaultWidth();
426
}
427
},
webui/components/canvas/right-canvas.css
+2
-2
@@ -30,8 +30,8 @@ body.right-canvas-resizing {
30
display: flex;
31
flex: 0 0 auto;
32
height: 100%;
33
- min-width: 52px;
34
- max-width: min(900px, 58vw);
33
+ min-width: 0;
34
+ max-width: none;
35
overflow: hidden;
36
border-left: 1px solid var(--right-canvas-border);
37
background: var(--right-canvas-chrome);
webui/css/modals.css
+24
-4
@@ -91,7 +91,9 @@ the old and the new system. */
91
/* Modal Header */
92
.modal-header {
93
display: grid;
94
- grid-template-columns: minmax(0, 1fr) auto auto;
94
+ grid-template-columns: minmax(0, 1fr);
95
+ grid-auto-flow: column;
96
+ grid-auto-columns: auto;
97
align-items: center;
98
justify-content: space-between;
99
gap: 0.5rem;
@@ -150,7 +152,14 @@ the old and the new system. */
152
background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
153
}
154
153
-.modal-dock-button {
155
+.modal-surface-switcher {
156
+ display: inline-flex;
157
+ align-items: center;
158
+ gap: 4px;
159
+}
160
+
161
+.modal-dock-button,
162
+.modal-surface-button {
163
display: inline-flex;
164
align-items: center;
165
justify-content: center;
@@ -168,16 +177,27 @@ the old and the new system. */
177
transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
178
}
179
171
-.modal-dock-button:hover {
180
+.modal-dock-button:hover,
181
+.modal-surface-button:hover,
182
+.modal-surface-button.is-active {
183
opacity: 1;
184
border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
185
background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
186
}
187
177
-.modal-dock-button .material-symbols-outlined {
188
+.modal-dock-button .material-symbols-outlined,
189
+.modal-surface-button .material-symbols-outlined {
190
font-size: 19px;
191
}
192
193
+.modal-surface-image {
194
+ display: block;
195
+ width: 22px;
196
+ height: 22px;
197
+ border-radius: 6px;
198
+ object-fit: cover;
199
+}
200
+
201
/* Modal Description */
202
.modal-description {
203
padding: 0.8rem 1rem 0 1rem;
webui/js/modals.js
+100
-2
@@ -14,6 +14,10 @@ function normalizeModalPath(modalPath = "") {
14
return String(modalPath || "").replace(/^\/+/, "");
15
}
16
17
+function sameModalPath(left = "", right = "") {
18
+ return normalizeModalPath(left) === normalizeModalPath(right);
19
+}
20
+
21
function modalRequiresExplicitClose(modalOrElement) {
22
const element = modalOrElement?.element || modalOrElement;
23
const path = normalizeModalPath(modalOrElement?.path || element?.path || "");
@@ -23,7 +27,7 @@ function modalRequiresExplicitClose(modalOrElement) {
27
}
28
29
function findModalIndexByPath(modalPath) {
26
- return modalStack.findIndex((modal) => modal.path === modalPath);
30
+ return modalStack.findIndex((modal) => sameModalPath(modal.path, modalPath));
31
}
32
33
function focusModal(modalPath) {
@@ -201,8 +205,102 @@ function getDockMetadata(doc, modalPath) {
205
};
206
}
207
204
-function configureModalDockButton(modal, doc) {
208
+function getModalSwitchSurfaces(metadata) {
209
+ if (!metadata) return [];
210
+ const surfaces = Array.isArray(rightCanvasStore.panelSurfaces)
211
+ ? rightCanvasStore.panelSurfaces
212
+ : [];
213
+ const modalSurfaces = surfaces.filter((surface) => (
214
+ surface?.id
215
+ && surface?.modalPath
216
+ && !surface.actionOnly
217
+ ));
218
+
219
+ if (modalSurfaces.some((surface) => surface.id === metadata.surfaceId)) {
220
+ return modalSurfaces;
221
+ }
222
+
223
+ return [
224
+ {
225
+ id: metadata.surfaceId,
226
+ title: metadata.title,
227
+ icon: metadata.icon,
228
+ modalPath: metadata.modalPath,
229
+ },
230
+ ...modalSurfaces,
231
+ ];
232
+}
233
+
234
+function createModalSurfaceButton(surface, metadata, modal) {
235
+ const title = surface.title || surface.id;
236
+ const targetModalPath = surface.modalPath || "";
237
+ const isActive = surface.id === metadata.surfaceId || sameModalPath(targetModalPath, modal.path);
238
+ const button = document.createElement("button");
239
+ button.type = "button";
240
+ button.className = "modal-surface-button";
241
+ button.dataset.canvasSurface = surface.id;
242
+ button.title = title;
243
+ button.setAttribute("aria-label", title);
244
+ button.setAttribute("aria-pressed", isActive.toString());
245
+ if (isActive) button.classList.add("is-active");
246
+
247
+ if (surface.image) {
248
+ const image = document.createElement("img");
249
+ image.className = "modal-surface-image";
250
+ image.src = surface.image;
251
+ image.alt = "";
252
+ image.setAttribute("aria-hidden", "true");
253
+ button.appendChild(image);
254
+ } else {
255
+ const icon = document.createElement("span");
256
+ icon.className = "material-symbols-outlined";
257
+ icon.setAttribute("aria-hidden", "true");
258
+ icon.textContent = surface.icon || "web_asset";
259
+ button.appendChild(icon);
260
+ }
261
+
262
+ button.addEventListener("click", async () => {
263
+ if (button.disabled || isActive || !targetModalPath) return;
264
+ button.disabled = true;
265
+ try {
266
+ rightCanvasStore.recordSurfaceMode?.(surface.id, "modal");
267
+ const openPromise = ensureModalOpen(targetModalPath);
268
+ if (openPromise?.catch) {
269
+ openPromise.catch((error) => console.error(`Modal surface ${surface.id} failed to open`, error));
270
+ }
271
+ await closeModal(modal.path);
272
+ } finally {
273
+ if (document.contains(button)) button.disabled = false;
274
+ }
275
+ });
276
+
277
+ return button;
278
+}
279
+
280
+function configureModalSurfaceSwitcher(modal, doc) {
281
const metadata = getDockMetadata(doc, modal.path);
282
+ if (!metadata || !modal.header || modal.header.querySelector(".modal-surface-switcher")) {
283
+ return metadata;
284
+ }
285
+
286
+ const surfaces = getModalSwitchSurfaces(metadata);
287
+ if (surfaces.length <= 1) return metadata;
288
+
289
+ const switcher = document.createElement("div");
290
+ switcher.className = "modal-surface-switcher";
291
+ switcher.setAttribute("role", "group");
292
+ switcher.setAttribute("aria-label", "Modal surfaces");
293
+
294
+ for (const surface of surfaces) {
295
+ switcher.appendChild(createModalSurfaceButton(surface, metadata, modal));
296
+ }
297
+
298
+ modal.close?.insertAdjacentElement("beforebegin", switcher);
299
+ return metadata;
300
+}
301
+
302
+function configureModalDockButton(modal, doc) {
303
+ const metadata = configureModalSurfaceSwitcher(modal, doc);
304
if (!metadata || !modal.header || modal.header.querySelector(".modal-dock-button")) {
305
return;
306
}