From a27ac511367bf2c08ea4a4b37460fb3effec882c Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:28:38 +0200 Subject: [PATCH] Route mobile canvas rail to modals Keep the right-canvas rail visible at mobile widths while routing non-action surface affordances into floating modals instead of the side canvas shell. Hide mobile surface-modal header controls except the close button and Editor New action, and clamp floating surface modal geometry to narrow viewports. --- tests/test_browser_agent_regressions.py | 24 ++++++++++++++ webui/components/canvas/AGENTS.md | 2 +- webui/components/canvas/right-canvas-store.js | 16 ++++++--- webui/components/canvas/right-canvas.css | 33 ++++++++++++++++++- webui/css/surfaces.css | 17 ++++++++++ webui/js/surfaces.js | 20 ++++++----- 6 files changed, 97 insertions(+), 15 deletions(-) diff --git a/tests/test_browser_agent_regressions.py b/tests/test_browser_agent_regressions.py index d32e99ec3..19765aa8d 100644 --- a/tests/test_browser_agent_regressions.py +++ b/tests/test_browser_agent_regressions.py @@ -810,6 +810,9 @@ def test_surface_buttons_keep_modal_and_canvas_entry_points_separate(): canvas_html = (PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas.html").read_text( encoding="utf-8" ) + canvas_css = (PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas.css").read_text( + encoding="utf-8" + ) modals_js = (PROJECT_ROOT / "webui" / "js" / "modals.js").read_text(encoding="utf-8") modals_css = (PROJECT_ROOT / "webui" / "css" / "modals.css").read_text(encoding="utf-8") surfaces_js = (PROJECT_ROOT / "webui" / "js" / "surfaces.js").read_text(encoding="utf-8") @@ -826,6 +829,10 @@ def test_surface_buttons_keep_modal_and_canvas_entry_points_separate(): canvas_store.index("async openModalSurface"): canvas_store.index("async undockActiveSurface") ] + should_render_block = canvas_store[ + canvas_store.index("shouldRender()"): + canvas_store.index("};\n\nexport const store") + ] surface_button_block = surfaces_js[ surfaces_js.index("function createModalSurfaceButton"): surfaces_js.index("function configureModalSurfaceSwitcher") @@ -845,6 +852,12 @@ def test_surface_buttons_keep_modal_and_canvas_entry_points_separate(): assert "surfaceModes: this.surfaceModes" in canvas_store assert "normalizeSurfaceMode(mode)" in canvas_store assert "migratePersistedSurfaceState" in canvas_store + assert "if (this.isMobileMode && !surface.actionOnly)" not in canvas_store + assert "return await this.openModalSurface(targetId, payload);" in canvas_store + assert "return await this.open(targetId, payload);" in canvas_store + assert 'return await this.open(this.activeSurfaceId || this.panelSurfaces[0]?.id || "", { source: "mobile-toggle" });' in canvas_store + assert "return true;" in should_render_block + assert "isMobileMode" not in should_render_block assert "this.mountedSurfaces = {}" not in close_block assert "surface?.close" not in close_block assert "this.mountedSurfaces = {}" not in undock_block @@ -854,6 +867,10 @@ def test_surface_buttons_keep_modal_and_canvas_entry_points_separate(): assert '@click="$store.rightCanvas.openLatest(surface.id)"' not in canvas_html assert canvas_html.count('@click="$store.rightCanvas.open(surface.id)"') == 2 + assert "body.right-canvas-mobile-mode .right-canvas-rail" in canvas_css + assert "display: flex !important;" in canvas_css + assert "body.right-canvas-mobile-mode .right-canvas-shell" in canvas_css + assert "body.right-canvas-mobile-mode .right-canvas,\nbody.right-canvas-mobile-mode .right-canvas-rail" not in canvas_css assert "recordMode(metadata.surfaceId, SURFACE_MODE_FLOATING)" in surfaces_js assert "configureModalSurfaceSwitcher" in surfaces_js @@ -886,6 +903,13 @@ def test_surface_buttons_keep_modal_and_canvas_entry_points_separate(): assert ".modal-surface-button.is-active" in surfaces_css assert ".modal-surface-image" in surfaces_css assert ".modal.modal-surface-parked" in surfaces_css + assert "@media (max-width: 768px)" in surfaces_css + assert ".modal-inner.surface-modal .surface-modal-action-group-surfaces" in surfaces_css + assert ".modal-inner.surface-modal .surface-modal-action-group-window" in surfaces_css + assert ".modal-inner.surface-modal .surface-modal-action-group-new" in surfaces_css + assert ".modal-inner.editor-modal .surface-modal-action-group-new" in surfaces_css + assert ".surface-modal-new-action:not(.editor-header-actions)" in surfaces_css + assert "const safeMinWidth = Math.min(minWidth, maxWidth)" in surfaces_js assert "grid-auto-flow: column" in surfaces_css assert 'id: "browser"' in surfaces_js assert 'id: "desktop"' in surfaces_js diff --git a/webui/components/canvas/AGENTS.md b/webui/components/canvas/AGENTS.md index fed0637e5..7e9d61f6b 100644 --- a/webui/components/canvas/AGENTS.md +++ b/webui/components/canvas/AGENTS.md @@ -14,7 +14,7 @@ - Keep registered surfaces compatible with WebUI extension hooks. - Preserve responsive layout and avoid overlapping the chat/sidebar shells. -- Right-canvas rail and tab buttons are explicit canvas entry points; they must open surfaces in the canvas even when the same surface was last used as a floating modal. +- Right-canvas rail and tab buttons are explicit canvas entry points above the mobile breakpoint; at mobile widths, keep the rail visible but route non-action surfaces into floating modals instead of the side canvas shell. ## Work Guidance diff --git a/webui/components/canvas/right-canvas-store.js b/webui/components/canvas/right-canvas-store.js index a1fee6acf..1718d4613 100644 --- a/webui/components/canvas/right-canvas-store.js +++ b/webui/components/canvas/right-canvas-store.js @@ -122,9 +122,6 @@ const model = { if (!surface) { return false; } - if (this.isMobileMode && !surface.actionOnly) { - return false; - } if (typeof surface.canOpen === "function" && surface.canOpen(payload) === false) { return false; } @@ -138,6 +135,12 @@ const model = { return true; } + if (this.isMobileMode) { + this.activeSurfaceId = targetId; + this.isOpen = false; + return await this.openModalSurface(targetId, payload); + } + this.activeSurfaceId = targetId; this.markSurfaceMounted(targetId); this.isOpen = true; @@ -208,6 +211,9 @@ const model = { async openLatest(surfaceId = "", payload = {}) { const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || ""); if (!targetId) return false; + if (this.isMobileMode) { + return await this.open(targetId, payload); + } if (this.latestSurfaceMode(targetId) === SURFACE_MODE_FLOATING) { return await this.openModalSurface(targetId, payload); } @@ -337,7 +343,7 @@ const model = { async toggleCanvas() { if (this.isMobileMode) { - return false; + return await this.open(this.activeSurfaceId || this.panelSurfaces[0]?.id || "", { source: "mobile-toggle" }); } if (this.isOpen) { await this.close(); @@ -512,7 +518,7 @@ const model = { }, shouldRender() { - return !this.isMobileMode; + return true; }, }; diff --git a/webui/components/canvas/right-canvas.css b/webui/components/canvas/right-canvas.css index 93f4314a2..db2ac6099 100644 --- a/webui/components/canvas/right-canvas.css +++ b/webui/components/canvas/right-canvas.css @@ -318,8 +318,39 @@ body.right-canvas-overlay-mode .right-canvas-resize-handle { display: none; } -body.right-canvas-mobile-mode .right-canvas, +body.right-canvas-mobile-mode .right-canvas { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 3400; + display: flex !important; + width: 0 !important; + min-width: 0; + height: 100%; + overflow: visible; + transform: none; + border-left: 0; + background: transparent; + box-shadow: none; + pointer-events: none; +} + +body.right-canvas-mobile-mode .right-canvas.is-open { + transform: none; +} + body.right-canvas-mobile-mode .right-canvas-rail { + position: fixed; + top: 50%; + right: max(0px, env(safe-area-inset-right)); + display: flex !important; + transform: translateY(-50%); + pointer-events: auto; +} + +body.right-canvas-mobile-mode .right-canvas-shell, +body.right-canvas-mobile-mode .right-canvas-resize-handle { display: none !important; } diff --git a/webui/css/surfaces.css b/webui/css/surfaces.css index 57649a3ea..03e6cf8cd 100644 --- a/webui/css/surfaces.css +++ b/webui/css/surfaces.css @@ -176,6 +176,23 @@ } } +@media (max-width: 768px) { + .modal-inner.surface-modal .surface-modal-action-group-surfaces, + .modal-inner.surface-modal .surface-modal-action-group-window, + .modal-inner.surface-modal .surface-modal-action-separator, + .modal-inner.surface-modal .surface-modal-action-group-new { + display: none !important; + } + + .modal-inner.editor-modal .surface-modal-action-group-new { + display: inline-flex !important; + } + + .modal-inner.editor-modal .surface-modal-action-group-new .surface-modal-new-action:not(.editor-header-actions) { + display: none !important; + } +} + .surface-image, .modal-surface-image { display: block; diff --git a/webui/js/surfaces.js b/webui/js/surfaces.js index 963301230..185aa4701 100644 --- a/webui/js/surfaces.js +++ b/webui/js/surfaces.js @@ -416,6 +416,8 @@ export function setupFloatingSurfaceModalChrome(options = {}) { const viewportWidth = () => Math.max(document.documentElement.clientWidth || 0, globalThis.innerWidth || 0); const viewportHeight = () => Math.max(document.documentElement.clientHeight || 0, globalThis.innerHeight || 0); + const availableWidth = () => Math.max(1, viewportWidth() - viewportGap * 2); + const availableHeight = () => Math.max(1, viewportHeight() - viewportGap * 2); const currentBounds = () => { const bounds = inner.getBoundingClientRect(); return { @@ -426,10 +428,12 @@ export function setupFloatingSurfaceModalChrome(options = {}) { }; }; const normalizedBounds = (bounds = {}) => { - const maxWidth = Math.max(minWidth, viewportWidth() - viewportGap * 2); - const maxHeight = Math.max(minHeight, viewportHeight() - viewportGap * 2); - const width = Math.min(Math.max(minWidth, Number(bounds.width || minWidth)), maxWidth); - const height = Math.min(Math.max(minHeight, Number(bounds.height || minHeight)), maxHeight); + const maxWidth = availableWidth(); + const maxHeight = availableHeight(); + const safeMinWidth = Math.min(minWidth, maxWidth); + const safeMinHeight = Math.min(minHeight, maxHeight); + const width = Math.min(Math.max(safeMinWidth, Number(bounds.width || safeMinWidth)), maxWidth); + const height = Math.min(Math.max(safeMinHeight, Number(bounds.height || safeMinHeight)), maxHeight); return { left: Math.min( Math.max(viewportGap, Number(bounds.left || viewportGap)), @@ -461,16 +465,16 @@ export function setupFloatingSurfaceModalChrome(options = {}) { inner.style.top = `${Math.round(next.top)}px`; inner.style.width = `${Math.round(next.width)}px`; inner.style.height = `${Math.round(next.height)}px`; - inner.style.maxWidth = `${Math.max(minWidth, viewportWidth() - viewportGap * 2)}px`; - inner.style.maxHeight = `${Math.max(minHeight, viewportHeight() - viewportGap * 2)}px`; + inner.style.maxWidth = `${availableWidth()}px`; + inner.style.maxHeight = `${availableHeight()}px`; notifyBoundsChange(); return next; }; const focusBounds = () => ({ left: viewportGap, top: viewportGap, - width: viewportWidth() - viewportGap * 2, - height: viewportHeight() - viewportGap * 2, + width: availableWidth(), + height: availableHeight(), }); const clampGeometry = () => { if (inner.classList.contains("is-focus-mode")) {