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.
Alessandro committed
Jun 23, 2026 at 13:28 UTC
a27ac511367bf2c08ea4a4b37460fb3effec882c
6 files changed
+97
-15
tests/test_browser_agent_regressions.py
+24
@@ -810,6 +810,9 @@ def test_surface_buttons_keep_modal_and_canvas_entry_points_separate():
810
canvas_html = (PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas.html").read_text(
811
encoding="utf-8"
812
)
813
+ canvas_css = (PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas.css").read_text(
814
+ encoding="utf-8"
815
+ )
816
modals_js = (PROJECT_ROOT / "webui" / "js" / "modals.js").read_text(encoding="utf-8")
817
modals_css = (PROJECT_ROOT / "webui" / "css" / "modals.css").read_text(encoding="utf-8")
818
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():
829
canvas_store.index("async openModalSurface"):
830
canvas_store.index("async undockActiveSurface")
831
]
832
+ should_render_block = canvas_store[
833
+ canvas_store.index("shouldRender()"):
834
+ canvas_store.index("};\n\nexport const store")
835
+ ]
836
surface_button_block = surfaces_js[
837
surfaces_js.index("function createModalSurfaceButton"):
838
surfaces_js.index("function configureModalSurfaceSwitcher")
@@ -845,6 +852,12 @@ def test_surface_buttons_keep_modal_and_canvas_entry_points_separate():
852
assert "surfaceModes: this.surfaceModes" in canvas_store
853
assert "normalizeSurfaceMode(mode)" in canvas_store
854
assert "migratePersistedSurfaceState" in canvas_store
855
+ assert "if (this.isMobileMode && !surface.actionOnly)" not in canvas_store
856
+ assert "return await this.openModalSurface(targetId, payload);" in canvas_store
857
+ assert "return await this.open(targetId, payload);" in canvas_store
858
+ assert 'return await this.open(this.activeSurfaceId || this.panelSurfaces[0]?.id || "", { source: "mobile-toggle" });' in canvas_store
859
+ assert "return true;" in should_render_block
860
+ assert "isMobileMode" not in should_render_block
861
assert "this.mountedSurfaces = {}" not in close_block
862
assert "surface?.close" not in close_block
863
assert "this.mountedSurfaces = {}" not in undock_block
@@ -854,6 +867,10 @@ def test_surface_buttons_keep_modal_and_canvas_entry_points_separate():
867
868
assert '@click="$store.rightCanvas.openLatest(surface.id)"' not in canvas_html
869
assert canvas_html.count('@click="$store.rightCanvas.open(surface.id)"') == 2
870
+ assert "body.right-canvas-mobile-mode .right-canvas-rail" in canvas_css
871
+ assert "display: flex !important;" in canvas_css
872
+ assert "body.right-canvas-mobile-mode .right-canvas-shell" in canvas_css
873
+ assert "body.right-canvas-mobile-mode .right-canvas,\nbody.right-canvas-mobile-mode .right-canvas-rail" not in canvas_css
874
875
assert "recordMode(metadata.surfaceId, SURFACE_MODE_FLOATING)" in surfaces_js
876
assert "configureModalSurfaceSwitcher" in surfaces_js
@@ -886,6 +903,13 @@ def test_surface_buttons_keep_modal_and_canvas_entry_points_separate():
903
assert ".modal-surface-button.is-active" in surfaces_css
904
assert ".modal-surface-image" in surfaces_css
905
assert ".modal.modal-surface-parked" in surfaces_css
906
+ assert "@media (max-width: 768px)" in surfaces_css
907
+ assert ".modal-inner.surface-modal .surface-modal-action-group-surfaces" in surfaces_css
908
+ assert ".modal-inner.surface-modal .surface-modal-action-group-window" in surfaces_css
909
+ assert ".modal-inner.surface-modal .surface-modal-action-group-new" in surfaces_css
910
+ assert ".modal-inner.editor-modal .surface-modal-action-group-new" in surfaces_css
911
+ assert ".surface-modal-new-action:not(.editor-header-actions)" in surfaces_css
912
+ assert "const safeMinWidth = Math.min(minWidth, maxWidth)" in surfaces_js
913
assert "grid-auto-flow: column" in surfaces_css
914
assert 'id: "browser"' in surfaces_js
915
assert 'id: "desktop"' in surfaces_js
webui/components/canvas/AGENTS.md
+1
-1
@@ -14,7 +14,7 @@
14
15
- Keep registered surfaces compatible with WebUI extension hooks.
16
- Preserve responsive layout and avoid overlapping the chat/sidebar shells.
17
-- 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.
17
+- 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.
18
19
## Work Guidance
20
webui/components/canvas/right-canvas-store.js
+11
-5
@@ -122,9 +122,6 @@ const model = {
122
if (!surface) {
123
return false;
124
}
125
- if (this.isMobileMode && !surface.actionOnly) {
126
- return false;
127
- }
125
if (typeof surface.canOpen === "function" && surface.canOpen(payload) === false) {
126
return false;
127
}
@@ -138,6 +135,12 @@ const model = {
135
return true;
136
}
137
138
+ if (this.isMobileMode) {
139
+ this.activeSurfaceId = targetId;
140
+ this.isOpen = false;
141
+ return await this.openModalSurface(targetId, payload);
142
+ }
143
+
144
this.activeSurfaceId = targetId;
145
this.markSurfaceMounted(targetId);
146
this.isOpen = true;
@@ -208,6 +211,9 @@ const model = {
211
async openLatest(surfaceId = "", payload = {}) {
212
const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "");
213
if (!targetId) return false;
214
+ if (this.isMobileMode) {
215
+ return await this.open(targetId, payload);
216
+ }
217
if (this.latestSurfaceMode(targetId) === SURFACE_MODE_FLOATING) {
218
return await this.openModalSurface(targetId, payload);
219
}
@@ -337,7 +343,7 @@ const model = {
343
344
async toggleCanvas() {
345
if (this.isMobileMode) {
340
- return false;
346
+ return await this.open(this.activeSurfaceId || this.panelSurfaces[0]?.id || "", { source: "mobile-toggle" });
347
}
348
if (this.isOpen) {
349
await this.close();
@@ -512,7 +518,7 @@ const model = {
518
},
519
520
shouldRender() {
515
- return !this.isMobileMode;
521
+ return true;
522
},
523
};
524
webui/components/canvas/right-canvas.css
+32
-1
@@ -318,8 +318,39 @@ body.right-canvas-overlay-mode .right-canvas-resize-handle {
318
display: none;
319
}
320
321
-body.right-canvas-mobile-mode .right-canvas,
321
+body.right-canvas-mobile-mode .right-canvas {
322
+ position: fixed;
323
+ top: 0;
324
+ right: 0;
325
+ bottom: 0;
326
+ z-index: 3400;
327
+ display: flex !important;
328
+ width: 0 !important;
329
+ min-width: 0;
330
+ height: 100%;
331
+ overflow: visible;
332
+ transform: none;
333
+ border-left: 0;
334
+ background: transparent;
335
+ box-shadow: none;
336
+ pointer-events: none;
337
+}
338
+
339
+body.right-canvas-mobile-mode .right-canvas.is-open {
340
+ transform: none;
341
+}
342
+
343
body.right-canvas-mobile-mode .right-canvas-rail {
344
+ position: fixed;
345
+ top: 50%;
346
+ right: max(0px, env(safe-area-inset-right));
347
+ display: flex !important;
348
+ transform: translateY(-50%);
349
+ pointer-events: auto;
350
+}
351
+
352
+body.right-canvas-mobile-mode .right-canvas-shell,
353
+body.right-canvas-mobile-mode .right-canvas-resize-handle {
354
display: none !important;
355
}
356
webui/css/surfaces.css
+17
@@ -176,6 +176,23 @@
176
}
177
}
178
179
+@media (max-width: 768px) {
180
+ .modal-inner.surface-modal .surface-modal-action-group-surfaces,
181
+ .modal-inner.surface-modal .surface-modal-action-group-window,
182
+ .modal-inner.surface-modal .surface-modal-action-separator,
183
+ .modal-inner.surface-modal .surface-modal-action-group-new {
184
+ display: none !important;
185
+ }
186
+
187
+ .modal-inner.editor-modal .surface-modal-action-group-new {
188
+ display: inline-flex !important;
189
+ }
190
+
191
+ .modal-inner.editor-modal .surface-modal-action-group-new .surface-modal-new-action:not(.editor-header-actions) {
192
+ display: none !important;
193
+ }
194
+}
195
+
196
.surface-image,
197
.modal-surface-image {
198
display: block;
webui/js/surfaces.js
+12
-8
@@ -416,6 +416,8 @@ export function setupFloatingSurfaceModalChrome(options = {}) {
416
417
const viewportWidth = () => Math.max(document.documentElement.clientWidth || 0, globalThis.innerWidth || 0);
418
const viewportHeight = () => Math.max(document.documentElement.clientHeight || 0, globalThis.innerHeight || 0);
419
+ const availableWidth = () => Math.max(1, viewportWidth() - viewportGap * 2);
420
+ const availableHeight = () => Math.max(1, viewportHeight() - viewportGap * 2);
421
const currentBounds = () => {
422
const bounds = inner.getBoundingClientRect();
423
return {
@@ -426,10 +428,12 @@ export function setupFloatingSurfaceModalChrome(options = {}) {
428
};
429
};
430
const normalizedBounds = (bounds = {}) => {
429
- const maxWidth = Math.max(minWidth, viewportWidth() - viewportGap * 2);
430
- const maxHeight = Math.max(minHeight, viewportHeight() - viewportGap * 2);
431
- const width = Math.min(Math.max(minWidth, Number(bounds.width || minWidth)), maxWidth);
432
- const height = Math.min(Math.max(minHeight, Number(bounds.height || minHeight)), maxHeight);
431
+ const maxWidth = availableWidth();
432
+ const maxHeight = availableHeight();
433
+ const safeMinWidth = Math.min(minWidth, maxWidth);
434
+ const safeMinHeight = Math.min(minHeight, maxHeight);
435
+ const width = Math.min(Math.max(safeMinWidth, Number(bounds.width || safeMinWidth)), maxWidth);
436
+ const height = Math.min(Math.max(safeMinHeight, Number(bounds.height || safeMinHeight)), maxHeight);
437
return {
438
left: Math.min(
439
Math.max(viewportGap, Number(bounds.left || viewportGap)),
@@ -461,16 +465,16 @@ export function setupFloatingSurfaceModalChrome(options = {}) {
465
inner.style.top = `${Math.round(next.top)}px`;
466
inner.style.width = `${Math.round(next.width)}px`;
467
inner.style.height = `${Math.round(next.height)}px`;
464
- inner.style.maxWidth = `${Math.max(minWidth, viewportWidth() - viewportGap * 2)}px`;
465
- inner.style.maxHeight = `${Math.max(minHeight, viewportHeight() - viewportGap * 2)}px`;
468
+ inner.style.maxWidth = `${availableWidth()}px`;
469
+ inner.style.maxHeight = `${availableHeight()}px`;
470
notifyBoundsChange();
471
return next;
472
};
473
const focusBounds = () => ({
474
left: viewportGap,
475
top: viewportGap,
472
- width: viewportWidth() - viewportGap * 2,
473
- height: viewportHeight() - viewportGap * 2,
476
+ width: availableWidth(),
477
+ height: availableHeight(),
478
});
479
const clampGeometry = () => {
480
if (inner.classList.contains("is-focus-mode")) {