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. */