diff --git a/tests/test_browser_agent_regressions.py b/tests/test_browser_agent_regressions.py index 2140371f5..39f1048ea 100644 --- a/tests/test_browser_agent_regressions.py +++ b/tests/test_browser_agent_regressions.py @@ -670,6 +670,7 @@ def test_browser_and_desktop_surface_buttons_remember_latest_window_mode(): 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") assert "surfaceModes: {}" in canvas_store assert "recordSurfaceMode(surfaceId" in canvas_store @@ -685,11 +686,21 @@ def test_browser_and_desktop_surface_buttons_remember_latest_window_mode(): assert '@click="$store.rightCanvas.open(surface.id)"' in canvas_html assert 'rightCanvasStore.recordSurfaceMode?.(metadata.surfaceId, "modal")' in modals_js + assert "configureModalSurfaceSwitcher" in modals_js + assert "modal-surface-switcher" in modals_js + assert "modal-surface-button" in modals_js + assert "rightCanvasStore.panelSurfaces" in modals_js + assert 'rightCanvasStore.recordSurfaceMode?.(surface.id, "modal")' in modals_js + assert "await closeModal(modal.path)" in modals_js assert "modalRequiresExplicitClose" in modals_js assert '"plugins/_browser/webui/main.html"' in modals_js assert '"plugins/_office/webui/main.html"' in modals_js assert "&& !modalRequiresExplicitClose(newModal)" in modals_js assert "if (modalRequiresExplicitClose(modalStack[modalStack.length - 1])) return;" in modals_js + assert ".modal-surface-switcher" in modals_css + assert ".modal-surface-button.is-active" in modals_css + assert ".modal-surface-image" in modals_css + assert "grid-auto-flow: column" in modals_css def test_browser_tool_does_not_auto_open_canvas_policy_is_documented(): diff --git a/tests/test_office_canvas_setup.py b/tests/test_office_canvas_setup.py index 764fc8a91..c13e974a2 100644 --- a/tests/test_office_canvas_setup.py +++ b/tests/test_office_canvas_setup.py @@ -416,7 +416,12 @@ def test_right_canvas_requires_explicit_open_and_is_absent_on_mobile(): assert "right-canvas-resize-end" in canvas_store assert "dispatchResizeEvent" in canvas_store assert "this.isOpen = false;" in canvas_store - assert "wasMobileMode && this.width <= MIN_WIDTH" in canvas_store + assert "wasMobileMode && this.width < MIN_WIDTH" in canvas_store + assert "const MIN_WIDTH = 0" in canvas_store + assert "const MAX_WIDTH" not in canvas_store + assert "0.58" not in canvas_store + assert "min(900px, 58vw)" not in canvas_css + assert "max-width: none" in canvas_css assert "if (this.isMobileMode && !surface.actionOnly)" in canvas_store assert "if (this.isMobileMode)" in canvas_store assert "shouldRender()" in canvas_store diff --git a/webui/components/canvas/right-canvas-store.js b/webui/components/canvas/right-canvas-store.js index 4d155d1c6..5a19adbe1 100644 --- a/webui/components/canvas/right-canvas-store.js +++ b/webui/components/canvas/right-canvas-store.js @@ -3,8 +3,7 @@ import { callJsExtensions } from "/js/extensions.js"; const STORAGE_KEY = "a0.rightCanvas"; const DEFAULT_WIDTH = 720; -const MIN_WIDTH = 420; -const MAX_WIDTH = 900; +const MIN_WIDTH = 0; const DESKTOP_BREAKPOINT = 1200; const MOBILE_BREAKPOINT = 768; const SURFACE_MODE_CANVAS = "canvas"; @@ -18,6 +17,12 @@ function viewportWidth() { return Math.max(document.documentElement.clientWidth || 0, globalThis.innerWidth || 0); } +function normalizeWidth(value, fallback = DEFAULT_WIDTH) { + if (value === null || value === undefined || value === "") return fallback; + const width = Number(value); + return Number.isFinite(width) ? Math.max(MIN_WIDTH, Math.round(width)) : fallback; +} + function normalizeSurfaceMode(mode = "") { return mode === SURFACE_MODE_MODAL ? SURFACE_MODE_MODAL : SURFACE_MODE_CANVAS; } @@ -306,15 +311,22 @@ const model = { setWidth(px, options = {}) { const { persist = true } = options; - const max = this.maxWidth(); - const next = clamp(Number(px) || DEFAULT_WIDTH, MIN_WIDTH, max); + const next = clamp(normalizeWidth(px), MIN_WIDTH, this.maxWidth()); this.width = next; this.applyLayoutState(); if (persist) this.persist(); }, maxWidth() { - return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.floor(viewportWidth() * 0.58))); + if (this.isOverlayMode) { + return Math.max(MIN_WIDTH, viewportWidth() - 44); + } + + const container = this._rootElement?.closest(".container"); + const rightPanel = document.getElementById("right-panel"); + const containerRight = container?.getBoundingClientRect().right ?? viewportWidth(); + const panelLeft = rightPanel?.getBoundingClientRect().left ?? 0; + return Math.max(MIN_WIDTH, Math.floor(containerRight - panelLeft)); }, defaultWidth() { @@ -387,7 +399,7 @@ const model = { normalizeSurfaceMode(mode), ]), ); - if (saved.width) this.width = Number(saved.width); + if (Number.isFinite(Number(saved.width))) this.width = Number(saved.width); } catch (error) { console.warn("Could not restore right canvas state", error); } @@ -409,7 +421,7 @@ const model = { surface.close?.({ ...payload, reason: "mobile" }); }, 0); } - } else if (wasMobileMode && this.width <= MIN_WIDTH) { + } else if (wasMobileMode && this.width < MIN_WIDTH) { this.width = this.defaultWidth(); } }, diff --git a/webui/components/canvas/right-canvas.css b/webui/components/canvas/right-canvas.css index 3c80d09ba..f9c161e1e 100644 --- a/webui/components/canvas/right-canvas.css +++ b/webui/components/canvas/right-canvas.css @@ -30,8 +30,8 @@ body.right-canvas-resizing { display: flex; flex: 0 0 auto; height: 100%; - min-width: 52px; - max-width: min(900px, 58vw); + min-width: 0; + max-width: none; overflow: hidden; border-left: 1px solid var(--right-canvas-border); background: var(--right-canvas-chrome); diff --git a/webui/css/modals.css b/webui/css/modals.css index 54febb640..19c738b28 100644 --- a/webui/css/modals.css +++ b/webui/css/modals.css @@ -91,7 +91,9 @@ the old and the new system. */ /* Modal Header */ .modal-header { display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; + grid-template-columns: minmax(0, 1fr); + grid-auto-flow: column; + grid-auto-columns: auto; align-items: center; justify-content: space-between; gap: 0.5rem; @@ -150,7 +152,14 @@ the old and the new system. */ background: color-mix(in srgb, var(--color-background-hover) 72%, transparent); } -.modal-dock-button { +.modal-surface-switcher { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.modal-dock-button, +.modal-surface-button { display: inline-flex; align-items: center; justify-content: center; @@ -168,16 +177,27 @@ the old and the new system. */ transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease; } -.modal-dock-button:hover { +.modal-dock-button:hover, +.modal-surface-button:hover, +.modal-surface-button.is-active { opacity: 1; border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border)); background: color-mix(in srgb, var(--color-background-hover) 72%, transparent); } -.modal-dock-button .material-symbols-outlined { +.modal-dock-button .material-symbols-outlined, +.modal-surface-button .material-symbols-outlined { font-size: 19px; } +.modal-surface-image { + display: block; + width: 22px; + height: 22px; + border-radius: 6px; + object-fit: cover; +} + /* Modal Description */ .modal-description { padding: 0.8rem 1rem 0 1rem; diff --git a/webui/js/modals.js b/webui/js/modals.js index a449bd533..5c9c6653c 100644 --- a/webui/js/modals.js +++ b/webui/js/modals.js @@ -14,6 +14,10 @@ function normalizeModalPath(modalPath = "") { return String(modalPath || "").replace(/^\/+/, ""); } +function sameModalPath(left = "", right = "") { + return normalizeModalPath(left) === normalizeModalPath(right); +} + function modalRequiresExplicitClose(modalOrElement) { const element = modalOrElement?.element || modalOrElement; const path = normalizeModalPath(modalOrElement?.path || element?.path || ""); @@ -23,7 +27,7 @@ function modalRequiresExplicitClose(modalOrElement) { } function findModalIndexByPath(modalPath) { - return modalStack.findIndex((modal) => modal.path === modalPath); + return modalStack.findIndex((modal) => sameModalPath(modal.path, modalPath)); } function focusModal(modalPath) { @@ -201,8 +205,102 @@ function getDockMetadata(doc, modalPath) { }; } -function configureModalDockButton(modal, doc) { +function getModalSwitchSurfaces(metadata) { + if (!metadata) return []; + const surfaces = Array.isArray(rightCanvasStore.panelSurfaces) + ? rightCanvasStore.panelSurfaces + : []; + const modalSurfaces = surfaces.filter((surface) => ( + surface?.id + && surface?.modalPath + && !surface.actionOnly + )); + + if (modalSurfaces.some((surface) => surface.id === metadata.surfaceId)) { + return modalSurfaces; + } + + return [ + { + id: metadata.surfaceId, + title: metadata.title, + icon: metadata.icon, + modalPath: metadata.modalPath, + }, + ...modalSurfaces, + ]; +} + +function createModalSurfaceButton(surface, metadata, modal) { + const title = surface.title || surface.id; + const targetModalPath = surface.modalPath || ""; + const isActive = surface.id === metadata.surfaceId || sameModalPath(targetModalPath, modal.path); + const button = document.createElement("button"); + button.type = "button"; + button.className = "modal-surface-button"; + button.dataset.canvasSurface = surface.id; + button.title = title; + button.setAttribute("aria-label", title); + button.setAttribute("aria-pressed", isActive.toString()); + if (isActive) button.classList.add("is-active"); + + if (surface.image) { + const image = document.createElement("img"); + image.className = "modal-surface-image"; + image.src = surface.image; + image.alt = ""; + image.setAttribute("aria-hidden", "true"); + button.appendChild(image); + } else { + const icon = document.createElement("span"); + icon.className = "material-symbols-outlined"; + icon.setAttribute("aria-hidden", "true"); + icon.textContent = surface.icon || "web_asset"; + button.appendChild(icon); + } + + button.addEventListener("click", async () => { + if (button.disabled || isActive || !targetModalPath) return; + button.disabled = true; + try { + rightCanvasStore.recordSurfaceMode?.(surface.id, "modal"); + const openPromise = ensureModalOpen(targetModalPath); + if (openPromise?.catch) { + openPromise.catch((error) => console.error(`Modal surface ${surface.id} failed to open`, error)); + } + await closeModal(modal.path); + } finally { + if (document.contains(button)) button.disabled = false; + } + }); + + return button; +} + +function configureModalSurfaceSwitcher(modal, doc) { const metadata = getDockMetadata(doc, modal.path); + if (!metadata || !modal.header || modal.header.querySelector(".modal-surface-switcher")) { + return metadata; + } + + const surfaces = getModalSwitchSurfaces(metadata); + if (surfaces.length <= 1) return metadata; + + const switcher = document.createElement("div"); + switcher.className = "modal-surface-switcher"; + switcher.setAttribute("role", "group"); + switcher.setAttribute("aria-label", "Modal surfaces"); + + for (const surface of surfaces) { + switcher.appendChild(createModalSurfaceButton(surface, metadata, modal)); + } + + modal.close?.insertAdjacentElement("beforebegin", switcher); + return metadata; +} + +function configureModalDockButton(modal, doc) { + const metadata = configureModalSurfaceSwitcher(modal, doc); if (!metadata || !modal.header || modal.header.querySelector(".modal-dock-button")) { return; }