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 }