diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts index cfb83b6ad285..a2d761206f73 100644 --- a/ui/src/app/app-shell-view.ts +++ b/ui/src/app/app-shell-view.ts @@ -59,7 +59,6 @@ const SCOPE_UPGRADE_BANNER_ELEMENT = { function renderScopeUpgradeBanner( host: ShellViewHost, snapshot: ApplicationContext["gateway"]["snapshot"], - chromeOffset: boolean, ) { const state = readScopeUpgradeAvailability(snapshot); if (state.phase === "hidden") { @@ -80,7 +79,6 @@ function renderScopeUpgradeBanner( return html``; } @@ -506,7 +504,7 @@ export function renderApplicationShell(host: ShellViewHost) { : ""} ${activeRoute === "workboard" ? "content--workboard" : ""}" .tabIndex=${-1} > - ${renderScopeUpgradeBanner(host, gatewaySnapshot, !settingsTakeover && !mobileNavLayout)} + ${renderScopeUpgradeBanner(host, gatewaySnapshot)} ${gatewaySnapshot.hello?.deviceAuthMigration?.pending === true ? // The migration banner is registered by a rare-flow dynamic import after first render. customElements.get("openclaw-device-auth-migration-banner") diff --git a/ui/src/app/device-scope-upgrade.runtime.ts b/ui/src/app/device-scope-upgrade.runtime.ts index 310acb0f7756..798527da3a27 100644 --- a/ui/src/app/device-scope-upgrade.runtime.ts +++ b/ui/src/app/device-scope-upgrade.runtime.ts @@ -130,7 +130,6 @@ export class ScopeUpgradeController { type ScopeUpgradeBannerProps = { snapshot: ApplicationGatewaySnapshot; - chromeOffset: boolean; }; class ScopeUpgradeBanner extends OpenClawLightDomContentsElement { @@ -186,7 +185,6 @@ class ScopeUpgradeBanner extends OpenClawLightDomContentsElement { class="callout ${state.phase === "error" || state.phase === "rejected" ? "danger" : "warn"} callout--action" - style=${props.chromeOffset ? "margin-left: 72px" : nothing} role="status" > ${text} diff --git a/ui/src/e2e/device-scope-upgrade.e2e.test.ts b/ui/src/e2e/device-scope-upgrade.e2e.test.ts index ff0f3b000b8b..5a1b4a2fb89a 100644 --- a/ui/src/e2e/device-scope-upgrade.e2e.test.ts +++ b/ui/src/e2e/device-scope-upgrade.e2e.test.ts @@ -4,6 +4,7 @@ import { chromium, type Browser, type BrowserContext, type Page } from "playwrig import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { canRunPlaywrightChromium, + controlUiBundledSettingsStorageKey, installMockGateway, resolvePlaywrightChromiumExecutablePath, startControlUiE2eServer, @@ -29,9 +30,24 @@ const SCOPE_UPGRADE_METHODS = [ "device.scopes.requestUpgrade", "device.scopes.waitUpgrade", ] as const; +const HIDDEN_WEB_CHROME_HOSTS = [ + { collapsed: false, label: "native web chrome", rootClass: "openclaw-native-web-chrome" }, + { collapsed: true, label: "collapsed native navigation", rootClass: "openclaw-native-nav" }, +] as const; const MANUAL_UPGRADE_GUIDANCE = "This browser has limited access. Manage it with openclaw devices on the Gateway or from Devices on an admin browser."; +type BoundingBox = { x: number; y: number; width: number; height: number }; + +function boxesIntersect(left: BoundingBox, right: BoundingBox): boolean { + return !( + left.x + left.width <= right.x || + right.x + right.width <= left.x || + left.y + left.height <= right.y || + right.y + right.height <= left.y + ); +} + let browser: Browser; let server: ControlUiE2eServer; const openContexts = new Set(); @@ -122,6 +138,10 @@ describeControlUiE2e("Control UI live device scope upgrade", () => { await page.getByRole("button", { name: "Request admin" }).waitFor(); await page.locator("#new-session-project-trigger").click(); + const projectPopover = page.locator("wa-popover.new-session-page__project-popover"); + await expect + .poll(() => projectPopover.evaluate((element) => element === document.activeElement)) + .toBe(true); const browse = page.getByRole("button", { name: "Browse folders" }); await expect.poll(() => browse.isDisabled()).toBe(true); await browse.focus(); @@ -187,13 +207,81 @@ describeControlUiE2e("Control UI live device scope upgrade", () => { }); await page.goto(`${server.baseUrl}chat`); - await page.getByText(MANUAL_UPGRADE_GUIDANCE, { exact: true }).waitFor(); + const guidance = page.getByText(MANUAL_UPGRADE_GUIDANCE, { exact: true }); + await guidance.waitFor(); + + const guidanceBox = await guidance.boundingBox(); + const chromeControls = page.locator(".shell-chrome-controls__button"); + expect(guidanceBox).not.toBeNull(); + expect(await chromeControls.count()).toBe(2); + for (let index = 0; index < (await chromeControls.count()); index += 1) { + const control = chromeControls.nth(index); + const controlBox = await control.boundingBox(); + expect(controlBox).not.toBeNull(); + if (guidanceBox && controlBox) { + const label = await control.getAttribute("aria-label"); + expect( + boxesIntersect(guidanceBox, controlBox), + `guidance ${JSON.stringify(guidanceBox)} intersects ${label} ${JSON.stringify(controlBox)}`, + ).toBe(false); + } + } expect(await page.getByRole("button", { name: "Request admin" }).count()).toBe(0); expect(await gateway.getRequests("device.scopes.requestUpgrade")).toHaveLength(0); }, ); + it.each(HIDDEN_WEB_CHROME_HOSTS)( + "keeps manual guidance at the normal content inset with $label", + async ({ collapsed, rootClass }) => { + const context = await createContext(); + await context.addInitScript( + ({ hostClass, settingsKey, startCollapsed }) => { + localStorage.setItem(settingsKey, JSON.stringify({ navCollapsed: startCollapsed })); + const stamp = () => + document.documentElement.classList.add("openclaw-native-macos", hostClass); + if (document.documentElement) { + stamp(); + } else { + document.addEventListener("DOMContentLoaded", stamp); + } + }, + { + hostClass: rootClass, + settingsKey: controlUiBundledSettingsStorageKey(server.baseUrl), + startCollapsed: collapsed, + }, + ); + const page = await context.newPage(); + await installMockGateway(page, { + featureMethods: ["chat.metadata", "chat.startup", "device.scopes.requestUpgrade"], + operatorScopes: LIMITED_SCOPES, + }); + + await page.goto(`${server.baseUrl}chat`); + const guidance = page.getByText(MANUAL_UPGRADE_GUIDANCE, { exact: true }); + await guidance.waitFor(); + if (collapsed) { + await expect + .poll(() => page.locator(".shell").getAttribute("class")) + .toContain("shell--nav-collapsed"); + } + await expect.poll(() => page.locator(".shell-chrome-controls").isVisible()).toBe(false); + + const contentBox = await page.locator(".content").boundingBox(); + const calloutBox = await page.locator("openclaw-update-banner .callout").boundingBox(); + const contentInset = await page + .locator(".content") + .evaluate((element) => Number.parseFloat(getComputedStyle(element).paddingLeft)); + expect(contentBox).not.toBeNull(); + expect(calloutBox).not.toBeNull(); + if (contentBox && calloutBox) { + expect(calloutBox.x).toBe(contentBox.x + contentInset); + } + }, + ); + it("does not misreport limited Custodian access as an outdated Gateway", async () => { const context = await createContext(); const page = await context.newPage(); diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 5a1943a47e55..63a2dadb9981 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -15,8 +15,9 @@ var(--shell-chrome-control-size) + var(--shell-chrome-controls-gap) + var(--shell-chrome-controls-gap) ); - --shell-floating-update-card-left: calc( - var(--shell-chrome-controls-inset) + var(--shell-chrome-controls-collapsed-width) + 8px + --shell-chrome-safe-area-left: calc( + var(--shell-chrome-controls-inset) + var(--shell-chrome-controls-collapsed-width) - + var(--shell-chrome-control-size) - var(--shell-chrome-controls-gap) + 8px ); /* Desktop has no topbar row — the sidebar owns navigation. Narrow viewports (layout.mobile.css) and the chat split toolbar restore the row height. */ @@ -73,6 +74,9 @@ .shell--nav-collapsed { grid-template-columns: 0 minmax(0, 1fr); --shell-nav-width: 0px; + --shell-chrome-safe-area-left: calc( + var(--shell-chrome-controls-inset) + var(--shell-chrome-controls-collapsed-width) + 8px + ); } /* Drawer shells keep the nav mounted offscreen; desktop collapse removes it. */ @@ -136,6 +140,23 @@ display: none; } +/* Reserve notice space only while the corresponding in-page chrome remains visible. */ +:is( + html:not(.openclaw-native-nav, .openclaw-native-web-chrome) + .shell:not(.shell--mobile-nav, .shell--settings), + html.openclaw-native-nav:not(.openclaw-native-web-chrome) + .shell:not(.shell--mobile-nav, .shell--nav-collapsed, .shell--settings) + ) + .content + > :is( + openclaw-update-banner, + openclaw-device-scope-upgrade-banner, + openclaw-device-auth-migration-banner + ) + .callout { + margin-left: var(--shell-chrome-safe-area-left); +} + html.openclaw-native-macos .shell-chrome-controls { top: 52px; } @@ -1150,8 +1171,8 @@ openclaw-settings-save-indicator { /* Clear the collapsed three-button chrome cluster plus an 8px breathing gap. */ .shell:not(.shell--mobile-nav) .sidebar-update-card--floating .sidebar-update-card { - width: min(360px, calc(100% - var(--shell-floating-update-card-left) - 16px)); - margin-left: var(--shell-floating-update-card-left); + width: min(360px, calc(100% - var(--shell-chrome-safe-area-left) - 16px)); + margin-left: var(--shell-chrome-safe-area-left); } /* Native hosts hide the web cluster; onboarding never mounts it. */