fix(ui): restore floating sidebar attention cluster on collapsed nav (#128419)

* fix(ui): restore floating sidebar attention cluster on collapsed nav

* fix(ui): let stylesheets own sidebar-attention display and clear the collapsed header

The LightDomContents base stamps inline display:contents, which defeated the floating cluster's fixed flex layout; display is now stylesheet-owned. The collapsed chat pane header clears the cluster's resting width, and a stale-client refresh card drops below the strip. The layout browser test upgrades the real element so the inline-style regression is covered.
This commit is contained in:
Peter Steinberger
2026-08-23 17:56:58 -07:00
committed by GitHub
parent 3aab15c077
commit ab94c85dfd
7 changed files with 162 additions and 14 deletions
@@ -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 = `
<div class="shell-chrome-controls">
<button class="shell-chrome-controls__button"></button>
<button class="shell-chrome-controls__button"></button>
<button class="shell-chrome-controls__button"></button>
<button class="shell-chrome-controls__button shell-chrome-controls__custodian"></button>
</div>
<button class="shell-chrome-controls__button scope-upgrade-shell-status"></button>
<main class="content">
<openclaw-sidebar-attention class="sidebar-attention--floating">
<button class="sidebar-issues-button"></button>
</openclaw-sidebar-attention>
</main>
`;
document.body.append(shell);
const attention = shell.querySelector<HTMLElement>("openclaw-sidebar-attention")!;
const chrome = shell.querySelector<HTMLElement>(".shell-chrome-controls")!;
const access = shell.querySelector<HTMLElement>(".scope-upgrade-shell-status")!;
const inbox = attention.querySelector<HTMLElement>(".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";
@@ -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}
>
<div class="sidebar-issues-panel__grabber" aria-hidden="true"></div>
+18 -2
View File
@@ -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<HTMLButtonElement>(".sidebar-issues-button")).not.toBeNull(),
);
element.querySelector<HTMLButtonElement>(".sidebar-issues-button")?.click();
const trigger = element.querySelector<HTMLButtonElement>(".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<HTMLElement>(".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(() =>
+16 -7
View File
@@ -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<SidebarAttentionItem["kind"], number> = {
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);
+16
View File
@@ -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
+43
View File
@@ -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;
}
+2 -2
View File
@@ -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;