diff --git a/ui/src/components/sidebar-attention-layout.browser.test.ts b/ui/src/components/sidebar-attention-layout.browser.test.ts
index fd96688670b8..646f122649db 100644
--- a/ui/src/components/sidebar-attention-layout.browser.test.ts
+++ b/ui/src/components/sidebar-attention-layout.browser.test.ts
@@ -1,12 +1,67 @@
import { afterEach, describe, expect, it } from "vitest";
import "../test-helpers/load-styles.ts";
import "../styles/hub-tabs.css";
+import "../styles/sidebar-footer-update.css";
import "../styles/sidebar-issues.css";
import "./web-awesome-tabs.ts";
+// Upgrade the real element: the floating layout once regressed because a base
+// class stamped inline `display: contents`, which only a live upgrade reveals.
+import "./sidebar-attention.ts";
-afterEach(() => document.body.replaceChildren());
+afterEach(() => {
+ document.body.replaceChildren();
+ document.documentElement.classList.remove(
+ "openclaw-native-nav",
+ "openclaw-native-macos",
+ "openclaw-native-web-chrome",
+ );
+});
describe.runIf("__vitest_browser__" in globalThis)("Inbox panel layout", () => {
+ it("positions collapsed sidebar attention beyond chrome and access controls", () => {
+ const shell = document.createElement("div");
+ shell.className = "shell shell--nav-collapsed";
+ shell.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+ `;
+ document.body.append(shell);
+
+ const attention = shell.querySelector("openclaw-sidebar-attention")!;
+ const chrome = shell.querySelector(".shell-chrome-controls")!;
+ const access = shell.querySelector(".scope-upgrade-shell-status")!;
+ const inbox = attention.querySelector(".sidebar-issues-button")!;
+
+ expect(getComputedStyle(attention).position).toBe("fixed");
+ expect(getComputedStyle(attention).display).toBe("flex");
+ expect(attention.getBoundingClientRect().left).toBeGreaterThanOrEqual(
+ Math.max(chrome.getBoundingClientRect().right, access.getBoundingClientRect().right) + 8,
+ );
+ expect(Number.parseFloat(getComputedStyle(inbox).borderTopWidth)).toBeGreaterThan(0);
+
+ document.documentElement.classList.add("openclaw-native-nav");
+ expect(getComputedStyle(attention).left).toBe("52px");
+ expect(attention.getBoundingClientRect().left).toBeGreaterThanOrEqual(
+ access.getBoundingClientRect().right + 8,
+ );
+
+ document.documentElement.classList.add("openclaw-native-macos");
+ expect(getComputedStyle(attention).top).toBe("52px");
+
+ document.documentElement.classList.add("openclaw-native-web-chrome");
+ expect(getComputedStyle(attention).left).toBe("16px");
+ });
+
it("keeps hub tabs compact and item rails flush with the scrollport", async () => {
const fixture = document.createElement("section");
fixture.className = "sidebar-issues-panel";
diff --git a/ui/src/components/sidebar-attention-panel.runtime.ts b/ui/src/components/sidebar-attention-panel.runtime.ts
index 1d2cc50436c4..cbdfd7a31055 100644
--- a/ui/src/components/sidebar-attention-panel.runtime.ts
+++ b/ui/src/components/sidebar-attention-panel.runtime.ts
@@ -17,6 +17,11 @@ import {
import { ISSUE_TABS, issueTabLabel, type IssueTab } from "./sidebar-issues-tabs.ts";
import "./menu-surface.ts";
+export type SidebarAttentionPanelPosition = { left: number } & (
+ | { anchor: "top"; top: number }
+ | { anchor: "bottom"; bottom: number }
+);
+
type SidebarAttentionPanelParams = {
approvalQueue: readonly ExecApprovalRequest[];
context: ApplicationContext;
@@ -32,13 +37,17 @@ type SidebarAttentionPanelParams = {
onSelectTab: (tab: IssueTab) => void;
overflowAbove: boolean;
overflowBelow: boolean;
- panelPosition: { left: number; bottom: number };
+ panelPosition: SidebarAttentionPanelPosition;
selectedTab: IssueTab;
updateSurface: boolean;
watchUpdateProgress?: (listener: (progress: UpdateProgress) => void) => () => void;
};
export function renderSidebarAttentionPanel(params: SidebarAttentionPanelParams): TemplateResult {
+ const { anchor } = params.panelPosition;
+ const panelOffset =
+ params.panelPosition.anchor === "top" ? params.panelPosition.top : params.panelPosition.bottom;
+ const panelStyle = `left:${params.panelPosition.left}px;${anchor}:${panelOffset}px;--sidebar-issues-panel-${anchor}:${panelOffset}px`;
const automationItems = params.items.filter(
(item) => item.kind === "cronFailed" || item.kind === "cronOverdue",
);
@@ -106,7 +115,7 @@ export function renderSidebarAttentionPanel(params: SidebarAttentionPanelParams)
class="sidebar-issues-panel"
role="dialog"
aria-labelledby="sidebar-issues-panel-heading"
- style=${`left:${params.panelPosition.left}px;bottom:${params.panelPosition.bottom}px;--sidebar-issues-panel-bottom:${params.panelPosition.bottom}px`}
+ style=${panelStyle}
@keydown=${params.onKeydown}
>
diff --git a/ui/src/components/sidebar-attention.test.ts b/ui/src/components/sidebar-attention.test.ts
index 95fa54d207f2..c82c6d3417ed 100644
--- a/ui/src/components/sidebar-attention.test.ts
+++ b/ui/src/components/sidebar-attention.test.ts
@@ -420,7 +420,7 @@ describe("sidebar attention refresh ownership", () => {
switchedAuth.resolve({ ts: 3, providers: [] });
});
- it("clears a stale failure alert when the gateway reports an automation change", async () => {
+ it("opens top-mounted attention downward and clears stale live automation alerts", async () => {
const responses = {
"cron.list": [cronListResponse([cronJob("failed")]), cronListResponse([])],
"models.authStatus": [{ ts: 1, providers: [] }],
@@ -478,10 +478,26 @@ describe("sidebar attention refresh ownership", () => {
await waitForFast(() =>
expect(element.querySelector(".sidebar-issues-button")).not.toBeNull(),
);
- element.querySelector(".sidebar-issues-button")?.click();
+ const trigger = element.querySelector(".sidebar-issues-button")!;
+ vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
+ x: 20,
+ y: 10,
+ left: 20,
+ top: 10,
+ right: 52,
+ bottom: 42,
+ width: 32,
+ height: 32,
+ toJSON: () => ({}),
+ });
+ trigger.click();
await waitForFast(() =>
expect(element.querySelector('[data-attention-kind="cronFailed"]')).not.toBeNull(),
);
+ const panel = element.querySelector(".sidebar-issues-panel")!;
+ expect(panel.style.top).toBe("50px");
+ expect(panel.style.bottom).toBe("");
+ expect(panel.style.getPropertyValue("--sidebar-issues-panel-top")).toBe("50px");
eventListener?.({ type: "event", event: "cron", payload: {} });
await waitForFast(() =>
diff --git a/ui/src/components/sidebar-attention.ts b/ui/src/components/sidebar-attention.ts
index 4c917c87afd8..7773b7478ebd 100644
--- a/ui/src/components/sidebar-attention.ts
+++ b/ui/src/components/sidebar-attention.ts
@@ -19,7 +19,7 @@ import { t } from "../i18n/index.ts";
import { createInitialCronState, loadCronJobsPage } from "../lib/cron/index.ts";
import { canCallGatewayMethod } from "../lib/gateway-methods.ts";
import { loadModelAuthStatus } from "../lib/model-auth.ts";
-import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
+import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
import "../styles/sidebar-footer-update.css";
import { icons } from "./icons.ts";
@@ -41,6 +41,7 @@ import {
buildSidebarAttentionItems,
type SidebarAttentionItem,
} from "./sidebar-attention-items.ts";
+import type { SidebarAttentionPanelPosition } from "./sidebar-attention-panel.runtime.ts";
import "./tooltip.ts";
import type { IssueTab } from "./sidebar-issues-tabs.ts";
@@ -56,7 +57,10 @@ const ITEM_PRIORITY: Record = {
cronFailed: 1,
cronOverdue: 2,
};
-class SidebarAttention extends OpenClawLightDomContentsElement {
+// Display is stylesheet-owned (layout.css `display: contents` in the footer,
+// flex when floating): the LightDomContents base's inline display would defeat
+// the floating override, re-piling the collapsed-nav cluster at the origin.
+class SidebarAttention extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context?: ApplicationContext;
@@ -64,7 +68,11 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
@state() private modelAuthStatus: ModelAuthStatusResult | null = null;
@state() private dismissed: SidebarAttentionDismissals = {};
@state() private panelOpen = false;
- @state() private panelPosition = { left: 8, bottom: 8 };
+ @state() private panelPosition: SidebarAttentionPanelPosition = {
+ left: 8,
+ anchor: "bottom",
+ bottom: 8,
+ };
@state() private selectedTab: IssueTab = "all";
@state() private overflowAbove = false;
@state() private overflowBelow = false;
@@ -471,12 +479,13 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
const rect = trigger.getBoundingClientRect();
const width = Math.min(390, globalThis.innerWidth - 16);
const preferredLeft = rect.left + rect.width / 2 - width / 2;
+ const left = Math.max(8, Math.min(preferredLeft, globalThis.innerWidth - width - 8));
this.panelTrigger = trigger;
this.panelRenderer = panelRenderer;
- this.panelPosition = {
- left: Math.max(8, Math.min(preferredLeft, globalThis.innerWidth - width - 8)),
- bottom: Math.max(8, globalThis.innerHeight - rect.top + 8),
- };
+ this.panelPosition =
+ rect.top < globalThis.innerHeight / 2
+ ? { left, anchor: "top", top: Math.max(8, rect.bottom + 8) }
+ : { left, anchor: "bottom", bottom: Math.max(8, globalThis.innerHeight - rect.top + 8) };
this.selectedTab = "all";
this.panelOpen = true;
document.addEventListener("pointerdown", this.closeOnOutsidePointer, true);
diff --git a/ui/src/styles/chat/split-view.css b/ui/src/styles/chat/split-view.css
index e80ebac70cc1..3a9dbd04fb69 100644
--- a/ui/src/styles/chat/split-view.css
+++ b/ui/src/styles/chat/split-view.css
@@ -860,6 +860,22 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-
padding-left: 124px;
}
+/* The floating attention cluster widens the overlay strip; clear its resting
+ width (inbox 32 + gap 4 + update 32 + 12 breathing) so the session title is
+ not covered. The update pill's hover expansion may transiently overlap.
+ Native-nav/web-chrome hosts pin the cluster at the far left, inside the
+ larger insets below. */
+html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-web-chrome)
+ .shell--nav-collapsed:not(.shell--mobile-nav):has(.sidebar-attention--floating)
+ .chat-split-view__column:first-child
+ > .chat-split-view__cell:first-child
+ :is(.chat-main__conversation-column, .chat-pane-primary-column)
+ > .chat-pane__header {
+ padding-left: calc(
+ var(--shell-chrome-controls-inset) + var(--shell-chrome-controls-collapsed-width) + 88px
+ );
+}
+
/* Native macOS desktop shells: the pane header is the window's top surface,
so it adopts the app titlebar height (52px injected by
DashboardWindowController; the 50px fallback mirrors
diff --git a/ui/src/styles/sidebar-footer-update.css b/ui/src/styles/sidebar-footer-update.css
index c00845dfd445..502bcc359e7d 100644
--- a/ui/src/styles/sidebar-footer-update.css
+++ b/ui/src/styles/sidebar-footer-update.css
@@ -1,3 +1,46 @@
+/* Floating footer controls must clear the collapsed chrome and scope indicator. */
+openclaw-sidebar-attention.sidebar-attention--floating {
+ --sidebar-bg: var(--bg-content, var(--bg));
+
+ position: fixed;
+ top: 10px;
+ left: calc(
+ var(--shell-chrome-controls-inset) + var(--shell-chrome-controls-collapsed-width) + 8px
+ );
+ z-index: 45;
+ display: flex;
+ align-items: center;
+ gap: var(--shell-chrome-controls-gap);
+}
+
+/* Native navigation hides web controls but leaves its scope indicator in-page. */
+html.openclaw-native-nav openclaw-sidebar-attention.sidebar-attention--floating {
+ left: calc(var(--shell-chrome-controls-inset) + var(--shell-chrome-controls-access-width) + 6px);
+}
+
+html.openclaw-native-web-chrome openclaw-sidebar-attention.sidebar-attention--floating {
+ left: 16px;
+}
+
+html.openclaw-native-macos openclaw-sidebar-attention.sidebar-attention--floating {
+ top: 52px;
+}
+
+/* A stale-client refresh card shares the collapsed overlay strip's row; drop it
+ below the fixed attention cluster instead of rendering both at the origin. */
+.sidebar-attention--floating ~ .sidebar-update-card--floating .sidebar-update-card {
+ margin-top: 50px;
+}
+
+/* Footer buttons need opaque chrome when they float over arbitrary page content. */
+:where(openclaw-sidebar-attention.sidebar-attention--floating) .sidebar-issues-button {
+ border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
+ background: color-mix(in srgb, var(--panel) 92%, transparent);
+ box-shadow: var(--shadow-sm);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+}
+
.sidebar-footer-bar:has(.sidebar-footer-update-slot) {
padding-inline-end: 88px;
}
diff --git a/ui/src/styles/sidebar-issues.css b/ui/src/styles/sidebar-issues.css
index f3019fb251ab..f59dd81cd059 100644
--- a/ui/src/styles/sidebar-issues.css
+++ b/ui/src/styles/sidebar-issues.css
@@ -6,8 +6,8 @@
500px,
calc(
100dvh -
- max(var(--sidebar-issues-panel-bottom, 8px), calc(8px + env(safe-area-inset-bottom, 0px))) -
- 8px - env(safe-area-inset-top, 0px)
+ max(var(--sidebar-issues-panel-top, 8px), calc(8px + env(safe-area-inset-top, 0px))) -
+ max(var(--sidebar-issues-panel-bottom, 8px), calc(8px + env(safe-area-inset-bottom, 0px)))
)
);
display: flex;