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;