diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index adb6234118f8..d5d814856662 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -11,6 +11,7 @@ import { sessionHasPendingApproval } from "../app/approval-presentation.ts"; import { beginNativeWindowDragFromTopInset } from "../app/native-window-drag.ts"; import { controlUiPublicAssetPath } from "../app/public-assets.ts"; import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts"; +import { CONTROL_UI_BUILD_INFO } from "../build-info.ts"; import { t } from "../i18n/index.ts"; import { normalizeAgentLabel, resolveAgentTextAvatar } from "../lib/agents/display.ts"; import { resolveAgentAvatarUrl } from "../lib/avatar.ts"; @@ -367,6 +368,8 @@ class AppSidebar extends AppSidebarSessionListElement { diff --git a/ui/src/components/sidebar-build-chip-format.ts b/ui/src/components/sidebar-build-chip-format.ts index 70ffced5d33f..739276b6af96 100644 --- a/ui/src/components/sidebar-build-chip-format.ts +++ b/ui/src/components/sidebar-build-chip-format.ts @@ -1,5 +1,7 @@ import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { html, type TemplateResult } from "lit"; import type { ControlUiBuildInfo } from "../build-info.ts"; +import { t } from "../i18n/index.ts"; const BRANCH_DISPLAY_LENGTH = 14; @@ -22,3 +24,52 @@ export function formatBuildChipText(info: ControlUiBuildInfo): string | null { const commit = `${info.commit.slice(0, 7)}${info.dirty === true ? "*" : ""}`; return `${branch}${commit}`; } + +function formatBuildCardDetails(info: ControlUiBuildInfo, gatewayVersion: string | null) { + return { + summary: [ + info.version ? `v${info.version}` : null, + info.branch, + info.dirty === true ? "dirty" : null, + ] + .filter((value): value is string => Boolean(value)) + .join(" · "), + commit: info.commit?.slice(0, 12) ?? null, + builtAt: info.builtAt, + gatewayVersion, + }; +} + +export function renderSidebarServerDetails( + info: ControlUiBuildInfo, + gatewayVersion: string | null, +): TemplateResult { + const details = formatBuildCardDetails(info, gatewayVersion); + const unavailable = t("aboutPage.unavailable"); + const rows = [ + { label: t("aboutPage.commit"), value: details.commit ?? unavailable, mono: true }, + { label: t("aboutPage.built"), value: details.builtAt ?? unavailable, mono: false }, + { + label: t("aboutPage.gatewayVersion"), + value: details.gatewayVersion ?? unavailable, + mono: false, + }, + ]; + return html` + + `; +} diff --git a/ui/src/components/sidebar-build-chip.ts b/ui/src/components/sidebar-build-chip.ts index ffe12b3d0c9b..edd50ebc70e5 100644 --- a/ui/src/components/sidebar-build-chip.ts +++ b/ui/src/components/sidebar-build-chip.ts @@ -4,7 +4,7 @@ import { pathForRoute } from "../app-route-paths.ts"; import { CONTROL_UI_BUILD_INFO } from "../build-info.ts"; import { t } from "../i18n/index.ts"; import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts"; -import { formatBuildChipText } from "./sidebar-build-chip-format.ts"; +import { formatBuildChipText, renderSidebarServerDetails } from "./sidebar-build-chip-format.ts"; import "./tooltip.ts"; function shouldHandleNavigationClick(event: MouseEvent): boolean { @@ -29,25 +29,8 @@ class SidebarBuildChip extends OpenClawLightDomContentsElement { if (!text) { return nothing; } - const summary = [ - CONTROL_UI_BUILD_INFO.version ? `v${CONTROL_UI_BUILD_INFO.version}` : null, - CONTROL_UI_BUILD_INFO.branch, - CONTROL_UI_BUILD_INFO.dirty === true ? "dirty" : null, - ] - .filter((line): line is string => line !== null) - .join(" · "); - const tooltip = [ - summary, - CONTROL_UI_BUILD_INFO.commit, - CONTROL_UI_BUILD_INFO.builtAt - ? `${t("aboutPage.built")}: ${CONTROL_UI_BUILD_INFO.builtAt}` - : null, - this.gatewayVersion ? `${t("aboutPage.gatewayVersion")}: ${this.gatewayVersion}` : null, - ] - .filter((line): line is string => Boolean(line)) - .join("\n"); return html` - + ${text} + `; } diff --git a/ui/src/components/tooltip.test.ts b/ui/src/components/tooltip.test.ts index a77b9562b246..755bf9d4e880 100644 --- a/ui/src/components/tooltip.test.ts +++ b/ui/src/components/tooltip.test.ts @@ -22,6 +22,17 @@ function createTooltip(content: string, triggerText = "trigger") { return { tooltip, trigger }; } +function createRichTooltip(content: string, triggerText = "trigger") { + const tooltip = document.createElement("openclaw-tooltip") as TooltipElement; + const trigger = document.createElement("button"); + trigger.textContent = triggerText; + const card = document.createElement("div"); + card.slot = "content"; + card.textContent = content; + tooltip.append(trigger, card); + return { tooltip, trigger, card }; +} + function createProvider() { return document.createElement("openclaw-tooltip-provider") as TooltipProviderElement; } @@ -30,10 +41,14 @@ function focusTrigger(trigger: HTMLElement) { trigger.dispatchEvent(new FocusEvent("focusin", { bubbles: true, composed: true })); } -function hoverTrigger(trigger: HTMLElement) { - const event = new MouseEvent("pointerenter", { bubbles: true, buttons: 0 }); +function dispatchMousePointer(target: EventTarget, type: "pointerenter" | "pointerleave") { + const event = new MouseEvent(type, { bubbles: true, buttons: 0 }); Object.defineProperty(event, "pointerType", { value: "mouse" }); - trigger.dispatchEvent(event); + target.dispatchEvent(event); +} + +function hoverTrigger(trigger: HTMLElement) { + dispatchMousePointer(trigger, "pointerenter"); } function webAwesomeTooltip(tooltip: TooltipElement) { @@ -94,7 +109,37 @@ describe("openclaw-tooltip", () => { focusTrigger(trigger); expectOpenCount(1); - expect(webAwesomeTooltip(tooltip)?.textContent).toBe("Single portal"); + expect(webAwesomeTooltip(tooltip)?.querySelector(".tooltip-content")?.textContent).toBe( + "Single portal", + ); + }); + + it("skins the body and arrow through shared Web Awesome tokens", async () => { + const { tooltip } = createTooltip("Styled tooltip"); + document.body.append(tooltip); + await tooltip.updateComplete; + + const styles = [...(tooltip.shadowRoot?.querySelectorAll("style") ?? [])] + .map((style) => style.textContent) + .join("\n"); + expect(styles).toContain("--wa-tooltip-background-color:"); + expect(styles).toContain("--wa-tooltip-border-color:"); + expect(styles).toContain("--wa-tooltip-border-width: 1px"); + expect(styles).toContain("--wa-tooltip-border-style: solid"); + expect(styles).toContain("--wa-tooltip-arrow-size: 6px"); + }); + + it("projects rich content into the Web Awesome tooltip", async () => { + const { tooltip, trigger, card } = createRichTooltip("Rich card", "Rich card"); + document.body.append(tooltip); + await tooltip.updateComplete; + + const contentSlot = + webAwesomeTooltip(tooltip)?.querySelector('slot[name="content"]'); + expect(contentSlot?.assignedElements()).toEqual([card]); + + focusTrigger(trigger); + expectOpenCount(1); }); it("anchors the Web Awesome popup after its initial update", async () => { @@ -202,6 +247,104 @@ describe("openclaw-tooltip", () => { expect(document.getElementById(descriptionId ?? "")?.textContent).toBe("Accessible tooltip"); }); + it("describes rich content with its text content", async () => { + const { tooltip, trigger } = createRichTooltip("Online 2 Alice Server v2026.7.2"); + document.body.append(tooltip); + await tooltip.updateComplete; + + const descriptionId = trigger.getAttribute("aria-describedby"); + expect(descriptionId).toBeTruthy(); + expect(document.getElementById(descriptionId ?? "")?.textContent).toBe( + "Online 2 Alice Server v2026.7.2", + ); + }); + + it("refreshes the rich description when assigned descendants change", async () => { + const { tooltip, trigger, card } = createRichTooltip(""); + const detail = document.createElement("span"); + detail.textContent = "Initial detail"; + card.append(detail); + document.body.append(tooltip); + await tooltip.updateComplete; + + const descriptionId = trigger.getAttribute("aria-describedby") ?? ""; + expect(document.getElementById(descriptionId)?.textContent).toBe("Initial detail"); + + detail.textContent = "Updated detail"; + await Promise.resolve(); + expect(document.getElementById(descriptionId)?.textContent).toBe("Updated detail"); + }); + + it("stays open while focus moves from the trigger into rich content", async () => { + const { tooltip, trigger, card } = createRichTooltip("Focusable card"); + card.tabIndex = 0; + const outside = document.createElement("button"); + document.body.append(tooltip, outside); + await tooltip.updateComplete; + + focusTrigger(trigger); + trigger.dispatchEvent( + new FocusEvent("focusout", { bubbles: true, composed: true, relatedTarget: card }), + ); + focusTrigger(card); + expectOpenCount(1); + + card.dispatchEvent( + new FocusEvent("focusout", { bubbles: true, composed: true, relatedTarget: outside }), + ); + expectOpenCount(0); + }); + + it("stays open when a focused trigger is swept through and out of rich content", async () => { + const { tooltip, trigger } = createRichTooltip("Scrollable card"); + document.body.append(tooltip); + await tooltip.updateComplete; + + trigger.focus(); + expect(document.activeElement).toBe(trigger); + hoverTrigger(trigger); + const richContent = tooltip.shadowRoot?.querySelector(".tooltip-rich-content"); + dispatchMousePointer(trigger, "pointerleave"); + if (richContent) { + dispatchMousePointer(richContent, "pointerenter"); + dispatchMousePointer(richContent, "pointerleave"); + } + vi.advanceTimersByTime(100); + + expect(document.activeElement).toBe(trigger); + expectOpenCount(1); + }); + + it("closes after pointer leave when nothing retains the rich tooltip", async () => { + const { tooltip, trigger } = createRichTooltip("Hover-only card"); + document.body.append(tooltip); + await tooltip.updateComplete; + + hoverTrigger(trigger); + vi.advanceTimersByTime(150); + expectOpenCount(1); + + dispatchMousePointer(trigger, "pointerleave"); + vi.advanceTimersByTime(99); + expectOpenCount(1); + vi.advanceTimersByTime(1); + expectOpenCount(0); + }); + + it("closes on focusout to an outside element when not hovered", async () => { + const { tooltip, trigger } = createRichTooltip("Focus-only card"); + const outside = document.createElement("button"); + document.body.append(tooltip, outside); + await tooltip.updateComplete; + + trigger.focus(); + expectOpenCount(1); + outside.focus(); + + expect(document.activeElement).toBe(outside); + expectOpenCount(0); + }); + it("releases the active provider reference when an open tooltip is removed", async () => { const provider = createProvider(); provider.delay = 40; diff --git a/ui/src/components/tooltip.ts b/ui/src/components/tooltip.ts index a52467f150cf..9bee51277cb3 100644 --- a/ui/src/components/tooltip.ts +++ b/ui/src/components/tooltip.ts @@ -11,6 +11,7 @@ const TOUCH_DELAY = 450; const TOUCH_VISIBLE = 900; const SKIP_DELAY = 300; const MOVE_LIMIT = 10; +const RICH_CONTENT_CLOSE_DELAY = 100; let nextTooltipId = 0; @@ -94,13 +95,17 @@ class Tooltip extends OpenClawLitElement { private triggerElement: HTMLElement | null = null; private openTimer: number | null = null; + private closeTimer: number | null = null; private touchTimer: number | null = null; private touchCloseTimer: number | null = null; private touchStart: { x: number; y: number } | null = null; + private triggerHovered = false; + private contentHovered = false; private suppressPointerFocus = false; private describedBy: string | null = null; private descriptionCaptured = false; private descriptionElement: HTMLSpanElement | null = null; + private richContentObserver: MutationObserver | null = null; private tooltipProvider: TooltipProvider | null = null; private readonly tooltipId = createTooltipId(); private readonly descriptionId = `${this.tooltipId}-description`; @@ -111,28 +116,37 @@ class Tooltip extends OpenClawLitElement { } wa-tooltip { - --max-width: min(260px, calc(100vw - 16px)); + --max-width: var(--openclaw-tooltip-max-width, min(260px, calc(100vw - 16px))); + --wa-tooltip-arrow-size: 6px; + --wa-tooltip-background-color: color-mix(in srgb, var(--card) 94%, black 6%); + --wa-tooltip-border-color: color-mix(in srgb, var(--border-strong) 84%, transparent); + --wa-tooltip-border-width: 1px; + --wa-tooltip-border-style: solid; + --wa-tooltip-content-color: var(--text); + --wa-tooltip-border-radius: var(--radius-md); font-family: var(--font-body); } wa-tooltip::part(body) { padding: 7px 9px; - border: 1px solid color-mix(in srgb, var(--border-strong) 84%, transparent); - border-radius: var(--radius-md); - background: color-mix(in srgb, var(--card) 94%, black 6%); box-shadow: var(--shadow-md); - color: var(--text); font-size: 12px; font-weight: 500; line-height: 1.35; - text-align: center; overflow-wrap: anywhere; - white-space: normal; } .tooltip-content { + display: block; + text-align: center; white-space: pre-line; } + + .tooltip-rich-content { + display: block; + pointer-events: auto; + text-align: left; + } `; override connectedCallback() { @@ -143,11 +157,16 @@ class Tooltip extends OpenClawLitElement { protected override updated() { this.attachTrigger(); + this.syncDescription(); this.syncWebAwesomeTooltip(); } override disconnectedCallback() { this.close(); + this.triggerHovered = false; + this.contentHovered = false; + this.richContentObserver?.disconnect(); + this.richContentObserver = null; this.tooltipProvider = null; this.detachTrigger(); super.disconnectedCallback(); @@ -166,7 +185,7 @@ class Tooltip extends OpenClawLitElement { } private attachTrigger() { - const slot = this.renderRoot.querySelector("slot"); + const slot = this.renderRoot.querySelector("slot:not([name])"); const trigger = slot ?.assignedElements({ flatten: true }) .find((element): element is HTMLElement => element instanceof HTMLElement); @@ -233,11 +252,33 @@ class Tooltip extends OpenClawLitElement { private readonly handlePointerEnter = (event: PointerEvent) => { if (event.pointerType !== "touch") { + this.triggerHovered = true; + this.clearCloseTimer(); this.scheduleOpen(); } }; - private readonly handlePointerLeave = () => this.close(); + private readonly handlePointerLeave = (event: PointerEvent) => { + if (event.pointerType !== "touch") { + this.triggerHovered = false; + this.maybeClose(); + } + }; + + private readonly handleContentPointerEnter = (event: PointerEvent) => { + if (event.pointerType !== "touch") { + this.contentHovered = true; + this.clearCloseTimer(); + this.show(); + } + }; + + private readonly handleContentPointerLeave = (event: PointerEvent) => { + if (event.pointerType !== "touch") { + this.contentHovered = false; + this.maybeClose(); + } + }; private readonly handlePointerDown = (event: PointerEvent) => { if (event.pointerType !== "touch") { @@ -286,7 +327,16 @@ class Tooltip extends OpenClawLitElement { this.show(); } }; - private readonly handleFocusOut = () => this.close(); + private readonly handleFocusOut = (event: FocusEvent) => { + if ( + (event.relatedTarget instanceof Node && this.contains(event.relatedTarget)) || + this.triggerHovered || + this.contentHovered + ) { + return; + } + this.close(); + }; private readonly handleClick = () => this.close(); private readonly handleDocumentPointerUp = () => { document.removeEventListener("pointerup", this.handleDocumentPointerUp); @@ -311,10 +361,10 @@ class Tooltip extends OpenClawLitElement { private show() { const tooltip = this.webAwesomeTooltip; - if (!tooltip || !this.triggerElement || !this.content || this.isRedundant()) { + if (!tooltip || !this.triggerElement || !this.tooltipText || this.isRedundant()) { return; } - this.clearTimers(); + this.clearTimers(false); this.provider?.openTooltip(this); this.syncDescription(); tooltip.open = true; @@ -322,6 +372,8 @@ class Tooltip extends OpenClawLitElement { private close() { this.clearTimers(); + this.triggerHovered = false; + this.contentHovered = false; this.touchStart = null; if (this.webAwesomeTooltip?.open) { this.webAwesomeTooltip.open = false; @@ -337,6 +389,9 @@ class Tooltip extends OpenClawLitElement { } private isRedundant() { + if (this.richContentText) { + return false; + } const trigger = this.triggerElement; if (!trigger) { return false; @@ -366,7 +421,7 @@ class Tooltip extends OpenClawLitElement { this.append(description); this.descriptionElement = description; } - this.descriptionElement.textContent = this.content; + this.descriptionElement.textContent = this.tooltipText; const ids = new Set((current ?? "").split(/\s+/u).filter(Boolean)); ids.add(this.descriptionId); trigger.setAttribute("aria-describedby", [...ids].join(" ")); @@ -394,11 +449,49 @@ class Tooltip extends OpenClawLitElement { } } - private clearTimers() { + private clearCloseTimer() { + if (this.closeTimer !== null) { + window.clearTimeout(this.closeTimer); + this.closeTimer = null; + } + } + + private shouldRemainOpen() { + const activeElement = document.activeElement; + return ( + this.triggerHovered || + this.contentHovered || + (activeElement instanceof Node && this.contains(activeElement)) + ); + } + + private maybeClose() { + this.clearCloseTimer(); + if (this.shouldRemainOpen()) { + return; + } + if (!this.richContentText) { + this.close(); + return; + } + this.closeTimer = window.setTimeout(() => { + this.closeTimer = null; + if (!this.shouldRemainOpen()) { + this.close(); + } + }, RICH_CONTENT_CLOSE_DELAY); + } + + private clearTimers(resetHover = true) { if (this.openTimer !== null) { window.clearTimeout(this.openTimer); this.openTimer = null; } + this.clearCloseTimer(); + if (resetHover) { + this.triggerHovered = false; + this.contentHovered = false; + } this.clearTouchTimer(); if (this.touchCloseTimer !== null) { window.clearTimeout(this.touchCloseTimer); @@ -406,11 +499,56 @@ class Tooltip extends OpenClawLitElement { } } + private get richContentText() { + const slot = this.renderRoot.querySelector('slot[name="content"]'); + return normalizeTooltipText( + slot + ?.assignedNodes({ flatten: true }) + .map((node) => node.textContent ?? "") + .join(" ") ?? "", + ); + } + + private get tooltipText() { + return this.richContentText || this.content; + } + + private observeRichContent() { + this.richContentObserver?.disconnect(); + this.richContentObserver ??= new MutationObserver(() => this.syncDescription()); + const slot = this.renderRoot.querySelector('slot[name="content"]'); + for (const node of slot?.assignedNodes({ flatten: true }) ?? []) { + this.richContentObserver.observe(node, { + characterData: true, + childList: true, + subtree: true, + }); + } + } + + private readonly handleContentSlotChange = () => { + this.observeRichContent(); + this.syncDescription(); + if (!this.tooltipText) { + this.close(); + } + }; + override render() { - const tooltipContent = html`${this.content}`; return html` this.attachTrigger()}> - ${tooltipContent} + + ${this.content} + + + + `; } } diff --git a/ui/src/components/viewer-facepile.test.ts b/ui/src/components/viewer-facepile.test.ts index 2a87ec0f291d..8fd1d071ef15 100644 --- a/ui/src/components/viewer-facepile.test.ts +++ b/ui/src/components/viewer-facepile.test.ts @@ -1,6 +1,7 @@ /* @vitest-environment jsdom */ import { afterEach, expect, it, vi } from "vitest"; +import type { ControlUiBuildInfo } from "../build-info.ts"; import { setAvatarGatewayOrigin } from "../lib/identity-avatar.ts"; import type { PresenceViewer } from "./viewer-facepile.ts"; import "./viewer-facepile.ts"; @@ -53,18 +54,32 @@ type ViewerFacepileElement = HTMLElement & { presencePayload: unknown; selfInstanceId?: string; variant: "session" | "footer"; + buildInfo: ControlUiBuildInfo; + gatewayVersion: string | null; updateComplete: Promise; }; +const BUILD_INFO: ControlUiBuildInfo = { + version: "2026.7.2", + commit: "1234567890abcdef1234567890abcdef12345678", + commitAt: null, + builtAt: "2026-07-20T10:30:00.000Z", + branch: "main", + dirty: true, + buildId: "test", +}; + function mountFooterFacepile() { const facepile = document.createElement("openclaw-viewer-facepile") as ViewerFacepileElement; facepile.variant = "footer"; facepile.selfInstanceId = "self-instance"; + facepile.buildInfo = BUILD_INFO; + facepile.gatewayVersion = "2026.7.1"; facepile.presencePayload = { presence: [ { instanceId: "self-instance", - user: { id: "00-self", name: "Self User", email: "self@example.test" }, + user: { id: "z-self", name: "Self User", email: "self@example.test" }, watchedSessions: [], }, { @@ -83,41 +98,51 @@ function mountFooterFacepile() { return facepile; } -it("opens a who's-online roster from the footer facepile", async () => { +it("shows one footer hover card with every online user and server details", async () => { const facepile = mountFooterFacepile(); await vi.waitFor(async () => { await facepile.updateComplete; - expect(facepile.querySelector("button.viewer-facepile-trigger")).not.toBeNull(); + expect(facepile.querySelector(".viewer-facepile-trigger")).not.toBeNull(); }); - facepile.querySelector("button.viewer-facepile-trigger")?.click(); + const tooltip = facepile.querySelector }>( + "openclaw-tooltip.sidebar-hover-tooltip", + ); + await tooltip?.updateComplete; + const trigger = facepile.querySelector(".viewer-facepile-trigger"); + trigger?.dispatchEvent(new FocusEvent("focusin", { bubbles: true, composed: true })); - await vi.waitFor(async () => { - await facepile.updateComplete; - const items = [...document.querySelectorAll(".presence-roster-menu__item")]; - // Everyone online is listed — including self, sorted first and marked. - expect(items.map((item) => item.getAttribute("data-viewer-id"))).toEqual([ - "00-self", - "alice", - "bob", - ]); - }); - - const menu = document.querySelector(".presence-roster-menu"); - expect(menu?.querySelector(".presence-roster-menu__title")?.textContent).toContain("3"); - const rows = [...(menu?.querySelectorAll(".presence-roster-menu__item") ?? [])]; - expect(rows[0]?.querySelector(".presence-roster-menu__you")?.textContent).toContain("you"); + expect( + tooltip?.shadowRoot?.querySelector("wa-tooltip")?.open, + ).toBe(true); + const card = facepile.querySelector('.sidebar-presence-hover-card[slot="content"]'); + expect(card?.querySelector(".sidebar-hover-card__heading")?.textContent).toContain("Online · 3"); + const rows = [...(card?.querySelectorAll(".sidebar-hover-card__person") ?? [])]; + expect(card?.querySelector(".sidebar-hover-card__people")?.getAttribute("tabindex")).toBe("0"); + expect(rows.map((row) => row.getAttribute("data-viewer-id"))).toEqual(["z-self", "alice", "bob"]); + expect(rows[0]?.querySelector(".sidebar-hover-card__you")?.textContent).toContain("you"); // Named users show the email as a subtitle; email-only users don't repeat it. - expect(rows[1]?.querySelector(".presence-roster-menu__email")?.textContent).toBe( + expect(rows[1]?.querySelector(".sidebar-hover-card__person-email")?.textContent).toBe( "alice@example.test", ); - expect(rows[2]?.querySelector(".presence-roster-menu__name")?.textContent?.trim()).toBe( + expect(rows[2]?.querySelector(".sidebar-hover-card__person-name")?.textContent?.trim()).toBe( "bob@example.test", ); - expect(rows[2]?.querySelector(".presence-roster-menu__email")).toBeNull(); - // Each row carries the shared avatar element. + expect(rows[2]?.querySelector(".sidebar-hover-card__person-email")).toBeNull(); expect(rows[1]?.querySelector("openclaw-viewer-avatar")).not.toBeNull(); + expect(card?.textContent).toContain("Server"); + expect(card?.querySelector(".sidebar-hover-card__summary")?.textContent).toContain( + "v2026.7.2 · main · dirty", + ); + expect( + card?.querySelector(".sidebar-hover-card__metadata-value--mono")?.textContent?.trim(), + ).toBe("1234567890ab"); + expect(card?.textContent).toContain("2026-07-20T10:30:00.000Z"); + expect(card?.textContent).toContain("2026.7.1"); + expect(facepile.querySelector("wa-dropdown")).toBeNull(); + expect(trigger?.hasAttribute("aria-haspopup")).toBe(false); + expect(trigger?.hasAttribute("aria-expanded")).toBe(false); }); it("keeps session facepiles as plain non-interactive avatar clusters", async () => { @@ -139,64 +164,5 @@ it("keeps session facepiles as plain non-interactive avatar clusters", async () expect(facepile.querySelector(".viewer-facepile")).not.toBeNull(); }); expect(facepile.querySelector("button.viewer-facepile-trigger")).toBeNull(); -}); - -it("closes the roster when a row is selected", async () => { - const facepile = mountFooterFacepile(); - await vi.waitFor(async () => { - await facepile.updateComplete; - expect(facepile.querySelector("button.viewer-facepile-trigger")).not.toBeNull(); - }); - facepile.querySelector("button.viewer-facepile-trigger")?.click(); - await vi.waitFor(async () => { - await facepile.updateComplete; - expect(document.querySelector(".presence-roster-menu")).not.toBeNull(); - }); - - document - .querySelector(".presence-roster-menu") - ?.dispatchEvent(new CustomEvent("wa-select", { bubbles: true, cancelable: true })); - - await vi.waitFor(async () => { - await facepile.updateComplete; - expect(document.querySelector(".presence-roster-menu")).toBeNull(); - }); -}); - -it("drops a stale open roster when presence empties and does not reopen on return", async () => { - const facepile = mountFooterFacepile(); - const fullPresence = facepile.presencePayload; - await vi.waitFor(async () => { - await facepile.updateComplete; - expect(facepile.querySelector("button.viewer-facepile-trigger")).not.toBeNull(); - }); - facepile.querySelector("button.viewer-facepile-trigger")?.click(); - await vi.waitFor(async () => { - await facepile.updateComplete; - expect(document.querySelector(".presence-roster-menu")).not.toBeNull(); - }); - - // Everyone else disconnects: the facepile (and menu) unmount without a - // wa-after-hide, so the open state must clear instead of going stale. - facepile.presencePayload = { - presence: [ - { - instanceId: "self-instance", - user: { id: "00-self", name: "Self User", email: "self@example.test" }, - watchedSessions: [], - }, - ], - }; - await vi.waitFor(async () => { - await facepile.updateComplete; - expect(document.querySelector(".presence-roster-menu")).toBeNull(); - }); - - facepile.presencePayload = fullPresence; - await vi.waitFor(async () => { - await facepile.updateComplete; - expect(facepile.querySelector("button.viewer-facepile-trigger")).not.toBeNull(); - }); - // Presence returning must not resurrect the previously open menu. - expect(document.querySelector(".presence-roster-menu")).toBeNull(); + expect(facepile.querySelectorAll("openclaw-tooltip")).toHaveLength(1); }); diff --git a/ui/src/components/viewer-facepile.ts b/ui/src/components/viewer-facepile.ts index 1961a5140bc3..a0b7c5cf19a1 100644 --- a/ui/src/components/viewer-facepile.ts +++ b/ui/src/components/viewer-facepile.ts @@ -1,12 +1,12 @@ import { html, nothing } from "lit"; -import { property, state } from "lit/decorators.js"; +import { property } from "lit/decorators.js"; import type { PresenceEntry } from "../api/types.ts"; +import { CONTROL_UI_BUILD_INFO, type ControlUiBuildInfo } from "../build-info.ts"; import { t } from "../i18n/index.ts"; import { resolveAvatar } from "../lib/identity-avatar.ts"; import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts"; -import "./menu-surface.ts"; +import { renderSidebarServerDetails } from "./sidebar-build-chip-format.ts"; import "./tooltip.ts"; -import { consumeDropdownKeyboardDismissal, trackDropdownKeyboardDismissal } from "./web-awesome.ts"; export type PresenceViewer = { id: string; @@ -173,22 +173,24 @@ class ViewerAvatar extends OpenClawLightDomContentsElement { } } -function renderRosterRow(user: PresenceViewer, isSelf: boolean) { +function renderPresenceCardRow(user: PresenceViewer, isSelf: boolean) { const label = presenceViewerLabel(user); // The email doubles as the label when no display name exists; repeating it // as a subtitle would just echo the same line. const subtitle = user.email && user.email !== label ? user.email : undefined; - return html` - - - + + + ${label}${isSelf - ? html` (${t("presence.you")})` + ? html` (${t("presence.you")})` : nothing} - ${subtitle ? html`` : nothing} + ${subtitle + ? html`${subtitle}` + : nothing} - `; + `; } class ViewerFacepile extends OpenClawLightDomContentsElement { @@ -197,87 +199,8 @@ class ViewerFacepile extends OpenClawLightDomContentsElement { @property({ attribute: false }) sessionKey?: string; @property({ type: Number, attribute: "max-visible" }) maxVisible = 3; @property() variant: "session" | "footer" = "session"; - - @state() private rosterPosition: { x: number; y: number } | null = null; - - private openRoster(event: MouseEvent) { - const trigger = event.currentTarget; - if (!(trigger instanceof HTMLElement)) { - return; - } - const rect = trigger.getBoundingClientRect(); - this.rosterPosition = { x: rect.left, y: rect.top }; - } - - private focusRosterTrigger() { - this.querySelector("button.viewer-facepile-trigger")?.focus(); - } - - protected override willUpdate() { - if (!this.rosterPosition) { - return; - } - // A presence update can unmount the footer facepile (everyone else left) - // while the roster is open. The dropdown is then removed without hiding, - // so wa-after-hide never fires — clear the open state here or the menu - // would remount at stale coordinates when presence returns. - const projection = projectPresencePayload(this.presencePayload, this.selfInstanceId); - const available = - this.variant === "footer" && - !this.sessionKey && - projection.users.some((user) => user.id !== projection.selfUserId); - if (!available) { - this.rosterPosition = null; - } - } - - private renderRosterMenu(roster: readonly PresenceViewer[], selfUserId: string | undefined) { - const position = this.rosterPosition; - if (!position) { - return nothing; - } - return html` - { - // Rows are informational; selecting one just dismisses the menu. - // Close explicitly — preventDefault also cancels the dropdown's own - // select-and-hide behavior — and hand focus back to the trigger so - // a keyboard activation does not strand focus on the body. - event.preventDefault(); - this.rosterPosition = null; - this.focusRosterTrigger(); - }} - @keydown=${(event: KeyboardEvent) => - trackDropdownKeyboardDismissal(event, () => this.focusRosterTrigger())} - @wa-after-hide=${(event: Event) => { - // The dropdown's own trigger is a hidden throwaway anchor, so restore - // focus to the visible facepile button on keyboard dismissal. - const keyboard = consumeDropdownKeyboardDismissal(event); - this.rosterPosition = null; - if (keyboard) { - this.focusRosterTrigger(); - } - }} - > - - - ${roster.map((user) => renderRosterRow(user, user.id === selfUserId))} - - `; - } + @property({ attribute: false }) buildInfo: ControlUiBuildInfo = CONTROL_UI_BUILD_INFO; + @property({ attribute: false }) gatewayVersion: string | null = null; override render() { const projection = projectPresencePayload(this.presencePayload, this.selfInstanceId); @@ -299,41 +222,69 @@ class ViewerFacepile extends OpenClawLightDomContentsElement { data-viewer-count=${users.length} aria-label=${users.map(presenceViewerLabel).join(", ")} > - ${visible.map((user) => { - const label = presenceViewerLabel(user); - return html` - - `; - })} + ${visible.map((user) => + this.variant === "footer" + ? html`` + : html` + + `, + )} ${overflow.length > 0 - ? html` - +${overflow.length} - ` + >` + : html` + +${overflow.length} + ` : nothing} `; if (this.variant !== "footer") { return facepile; } - // The footer cluster opens the who's-online roster. Self sorts first so - // your own row anchors the list; everyone else keeps the projection order. + // Self anchors the hover card; everyone else keeps the projection order. const roster = [...projection.users].toSorted((a, b) => a.id === projection.selfUserId ? -1 : b.id === projection.selfUserId ? 1 : 0, ); - return html` - ${this.renderRosterMenu(roster, projection.selfUserId)}`; + return html` + + + ${facepile} + + + + `; } } diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index cd980e77b30f..e412694175bf 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -2265,6 +2265,7 @@ export const en: TranslationMap = { presence: { rosterLabel: "Show who's online", rosterTitle: "Online", + serverRegion: "Server", you: "you", }, profilePage: { diff --git a/ui/src/pages/chat/components/chat-background-tasks-status.ts b/ui/src/pages/chat/components/chat-background-tasks-status.ts index 72df963ee0b3..f80e56c80ed0 100644 --- a/ui/src/pages/chat/components/chat-background-tasks-status.ts +++ b/ui/src/pages/chat/components/chat-background-tasks-status.ts @@ -1,9 +1,7 @@ -// Registers : the status row's hover preview uses it directly -// because openclaw-tooltip only carries plain-text content. -import "@awesome.me/webawesome/dist/components/tooltip/tooltip.js"; import { html, nothing, type TemplateResult } from "lit"; import "../../../components/elapsed-time.ts"; import { icons } from "../../../components/icons.ts"; +import "../../../components/tooltip.ts"; import { t } from "../../../i18n/index.ts"; import { formatRelativeTimestamp } from "../../../lib/format.ts"; import { @@ -79,21 +77,14 @@ function renderStatusPreview(props: BackgroundTasksProps): TemplateResult { const preview = tasks.slice(0, STATUS_PREVIEW_LIMIT); const overflow = tasks.length - preview.length; return html` - -
- ${preview.map((task) => renderStatusPreviewRow(task))} - ${overflow > 0 - ? html`
- ${t("chat.backgroundTasks.statusPreviewMore", { count: String(overflow) })} -
` - : nothing} -
-
+
+ ${preview.map((task) => renderStatusPreviewRow(task))} + ${overflow > 0 + ? html`
+ ${t("chat.backgroundTasks.statusPreviewMore", { count: String(overflow) })} +
` + : nothing} +
`; } @@ -119,25 +110,25 @@ export function renderBackgroundTasksStatusRow( backgroundTasks.onToggleCollapsed(); } }; - // The preview tooltip anchors the whole row (not the link button): wa-tooltip - // joins the anchor's aria-labelledby, which would replace the button's - // accessible name. It also stays a sibling so the ticking preview content - // never lives inside the polite live region. + // The preview tooltip anchors the whole row (not the link button), so the + // ticking preview content stays outside the polite live region. return html` -
- - ${status.startedMs !== null - ? html` - - - - ` - : nothing} - -
- ${renderStatusPreview(backgroundTasks)} + +
+ + ${status.startedMs !== null + ? html` + + + + ` + : nothing} + +
+ ${renderStatusPreview(backgroundTasks)} +
`; } diff --git a/ui/src/pages/chat/components/chat-background-tasks.test.ts b/ui/src/pages/chat/components/chat-background-tasks.test.ts index 20212fef9e2d..b88783cc0408 100644 --- a/ui/src/pages/chat/components/chat-background-tasks.test.ts +++ b/ui/src/pages/chat/components/chat-background-tasks.test.ts @@ -730,9 +730,9 @@ describe("running-tasks status row", () => { container, ); - const preview = container.querySelector("wa-tooltip.chat-tasks-status__preview"); - expect(preview?.getAttribute("for")).toBe("chat-tasks-status-test"); - expect(container.querySelector(".chat-tasks-status")?.id).toBe("chat-tasks-status-test"); + const preview = container.querySelector("openclaw-tooltip.chat-tasks-status__preview"); + expect(preview?.querySelector(".chat-tasks-status")?.id).toBe("chat-tasks-status-test"); + expect(preview?.querySelector('.chat-tasks-preview[slot="content"]')).not.toBeNull(); const titles = [...container.querySelectorAll(".chat-tasks-preview__title")].map((el) => el.textContent?.trim(), ); diff --git a/ui/src/pages/usage/view-overview.test.ts b/ui/src/pages/usage/view-overview.test.ts index a53b3514c631..6a268d11a638 100644 --- a/ui/src/pages/usage/view-overview.test.ts +++ b/ui/src/pages/usage/view-overview.test.ts @@ -126,7 +126,7 @@ describe("renderUsageInsights", () => { ); const buttons = [...container.querySelectorAll("button.usage-summary-hint")]; - const tooltips = [...container.querySelectorAll("wa-tooltip.usage-summary-tooltip")]; + const tooltips = [...container.querySelectorAll("openclaw-tooltip")]; expect(buttons).toHaveLength(9); expect(tooltips).toHaveLength(9); expect( @@ -138,11 +138,16 @@ describe("renderUsageInsights", () => { ), ).toBe(true); expect( - tooltips.every( - (tooltip) => - tooltip.getAttribute("trigger") === "hover focus" && - buttons.some((button) => button.id === tooltip.getAttribute("for")), - ), + tooltips.every((tooltip) => { + const button = tooltip.querySelector("button.usage-summary-hint"); + const content = tooltip.querySelector('[slot="content"]'); + return Boolean( + button && + buttons.includes(button) && + content && + button.getAttribute("aria-label") !== content.textContent, + ); + }), ).toBe(true); buttons[0]?.click(); diff --git a/ui/src/pages/usage/view-overview.ts b/ui/src/pages/usage/view-overview.ts index 4358b5642819..72fa03b0ade9 100644 --- a/ui/src/pages/usage/view-overview.ts +++ b/ui/src/pages/usage/view-overview.ts @@ -608,7 +608,6 @@ function renderSummaryStat(params: { compactValue?: boolean; }) { const hintId = `usage-summary-hint-${params.hintId}`; - const tooltipId = `${hintId}-tooltip`; const classes = [ "stat", "usage-summary-card", @@ -629,25 +628,20 @@ function renderSummaryStat(params: {
${params.title} - - - - ${params.hint} - + + + + ${params.hint} +
${params.value}
${params.sub}
diff --git a/ui/src/styles/chat/tool-cards.css b/ui/src/styles/chat/tool-cards.css index 5c9b5e098a03..2077e5200ed7 100644 --- a/ui/src/styles/chat/tool-cards.css +++ b/ui/src/styles/chat/tool-cards.css @@ -1689,20 +1689,8 @@ /* Hover preview on the status row: the latest tasks in a read-only card. The tooltip body is content-sized (width: max-content up to --max-width), so a single task stays one compact line instead of a fixed-size panel. */ -wa-tooltip.chat-tasks-status__preview { - --max-width: min(360px, calc(100vw - 24px)); - font-family: var(--font-body); -} - -wa-tooltip.chat-tasks-status__preview::part(body) { - padding: 8px 10px; - border: 1px solid color-mix(in srgb, var(--border-strong) 84%, transparent); - border-radius: var(--radius-md); - background: color-mix(in srgb, var(--card) 94%, black 6%); - box-shadow: var(--shadow-md); - color: var(--text); - font-size: var(--control-ui-text-xs); - line-height: 1.35; +openclaw-tooltip.chat-tasks-status__preview { + --openclaw-tooltip-max-width: min(360px, calc(100vw - 24px)); } .chat-tasks-preview { diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 0e2e4bbe2066..f02c00d3c4fe 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -1933,6 +1933,11 @@ html.openclaw-native-macos margin-left: -5px; } +.viewer-facepile > openclaw-viewer-avatar:not(:first-child) .viewer-avatar, +.viewer-facepile > .viewer-avatar:not(:first-child) { + margin-left: -5px; +} + .viewer-avatar > img, .viewer-avatar > span { width: 100%; @@ -1996,7 +2001,7 @@ html.openclaw-native-macos border: none; border-radius: var(--radius-full); background: none; - cursor: pointer; + cursor: default; } .viewer-facepile-trigger:hover, @@ -2004,114 +2009,6 @@ html.openclaw-native-macos background: color-mix(in srgb, var(--fg) 8%, transparent); } -/* Long rosters scroll inside the popup instead of growing past the viewport. - Chrome matches the sidebar's other fixed menus (customize/session menus). */ -.presence-roster-menu::part(menu) { - min-width: 232px; - max-width: 288px; - max-height: min(340px, 60vh); - overflow-y: auto; - padding: 6px; - border: 1px solid color-mix(in srgb, var(--border-strong) 78%, transparent); - border-radius: var(--radius-lg); - background: var(--bg-elevated); - box-shadow: var(--shadow-lg); -} - -.presence-roster-menu__title { - padding: 6px 8px 4px; - color: var(--muted); - font-size: 11px; - font-weight: 650; - letter-spacing: 0.05em; - text-transform: uppercase; -} - -/* Rows are informational people entries, not checkbox menu items: drop Web - Awesome's checkmark gutter and item metrics so title, avatars, and text - share one left edge with the rest of the app's menus. */ -.presence-roster-menu__item { - padding: 6px 8px; - border-radius: var(--radius-md); -} - -.presence-roster-menu__item:hover, -.presence-roster-menu__item:focus-visible { - outline: none; - background: color-mix(in srgb, var(--bg-hover) 84%, transparent); -} - -.presence-roster-menu__item::part(checkmark) { - display: none; -} - -.presence-roster-menu__item::part(label) { - display: flex; - align-items: center; -} - -/* Roster avatars grow to two-line row scale. The circle clips via the child - image/initials instead of the wrapper so the online badge can overflow. - The avatar-to-text gap lives here: Web Awesome's icon-slot margin targets - the slotted openclaw-viewer-avatar host, which is display:contents and - therefore generates no box for the margin to apply to. */ -.presence-roster-menu__item .viewer-avatar { - width: 26px; - height: 26px; - margin-inline-end: 10px; - overflow: visible; - border: none; - font-size: 10px; -} - -.presence-roster-menu__item .viewer-avatar > img, -.presence-roster-menu__item .viewer-avatar > span { - border-radius: var(--radius-full); -} - -/* Everyone listed is online; the dot restates the header at a glance. */ -.presence-roster-menu__item .viewer-avatar::after { - content: ""; - position: absolute; - right: -1px; - bottom: -1px; - width: 8px; - height: 8px; - border: 2px solid var(--bg-elevated); - border-radius: var(--radius-full); - background: var(--ok); -} - -.presence-roster-menu__text { - display: flex; - flex-direction: column; - gap: 1px; - min-width: 0; - line-height: 1.3; -} - -.presence-roster-menu__name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--text); - font-size: 13px; - font-weight: 500; -} - -.presence-roster-menu__you { - color: var(--muted); - font-weight: 400; -} - -.presence-roster-menu__email { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--muted); - font-size: 11px; -} - .sidebar-recent-session__name { flex: 1 1 auto; min-width: 0; @@ -3158,6 +3055,131 @@ wa-dropdown.sidebar-session-sort-menu::part(menu) { margin-left: 1px; } +openclaw-tooltip.sidebar-hover-tooltip { + --openclaw-tooltip-max-width: min(340px, calc(100vw - 24px)); +} + +.sidebar-hover-card { + width: min(320px, calc(100vw - 44px)); + max-width: 100%; + color: var(--text); + font-size: 12px; + line-height: 1.35; + text-align: left; +} + +.sidebar-hover-card__region { + display: flex; + flex-direction: column; + gap: 7px; +} + +.sidebar-hover-card__heading { + color: var(--muted); + font-size: 11px; + font-weight: 650; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.sidebar-hover-card__people { + display: flex; + flex-direction: column; + gap: 7px; + max-height: min(240px, 45vh); + overflow-y: auto; +} + +.sidebar-hover-card__person { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.sidebar-hover-card__person .viewer-avatar { + width: 24px; + height: 24px; + flex: none; + border-color: var(--card); + font-size: 9px; +} + +.sidebar-hover-card__person-text { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; +} + +.sidebar-hover-card__person-name, +.sidebar-hover-card__person-email { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-hover-card__person-name { + color: var(--text); + font-weight: 550; +} + +.sidebar-hover-card__you, +.sidebar-hover-card__person-email { + color: var(--muted); + font-weight: 400; +} + +.sidebar-hover-card__person-email { + font-size: 11px; +} + +.sidebar-hover-card__divider { + height: 1px; + margin: 10px 0; + background: color-mix(in srgb, var(--border-strong) 68%, transparent); +} + +.sidebar-hover-card__server-details { + display: flex; + flex-direction: column; + gap: 7px; +} + +.sidebar-hover-card__summary { + color: var(--text); + font-weight: 600; +} + +.sidebar-hover-card__metadata { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0; +} + +.sidebar-hover-card__metadata-row { + display: grid; + grid-template-columns: minmax(72px, auto) minmax(0, 1fr); + gap: 10px; +} + +.sidebar-hover-card__metadata-row dt { + color: var(--muted); +} + +.sidebar-hover-card__metadata-row dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + color: var(--text); +} + +.sidebar-hover-card__metadata-value--mono { + font-family: var(--font-mono); + font-size: 11px; +} + .sidebar-footer-bar__identity { display: inline-flex; align-items: center; diff --git a/ui/src/styles/usage.css b/ui/src/styles/usage.css index 6c0ca69bf5d8..08200bd66a2d 100644 --- a/ui/src/styles/usage.css +++ b/ui/src/styles/usage.css @@ -916,27 +916,6 @@ details.usage-filter-select summary::-webkit-details-marker, outline-offset: 2px; } -wa-tooltip.usage-summary-tooltip { - --max-width: min(280px, calc(100vw - 16px)); - font-family: var(--font-body); - letter-spacing: normal; - text-align: center; - text-transform: none; -} - -wa-tooltip.usage-summary-tooltip::part(body) { - padding: 7px 9px; - border: 1px solid color-mix(in srgb, var(--border-strong) 84%, transparent); - border-radius: var(--radius-md); - background: color-mix(in srgb, var(--card) 94%, black 6%); - box-shadow: var(--shadow-md); - color: var(--text); - font-size: 12px; - font-weight: 500; - line-height: 1.35; - overflow-wrap: anywhere; -} - .usage-insights-grid { grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr)); } diff --git a/ui/src/test-helpers/app-sidebar-cases/basics.ts b/ui/src/test-helpers/app-sidebar-cases/basics.ts index d0b93c46da26..faeb061ba36d 100644 --- a/ui/src/test-helpers/app-sidebar-cases/basics.ts +++ b/ui/src/test-helpers/app-sidebar-cases/basics.ts @@ -174,8 +174,19 @@ describe("AppSidebar viewer presence", () => { expect( footerFacepile?.querySelector(".viewer-facepile")?.getAttribute("data-viewer-count"), ).toBe("6"); - expect(footerFacepile?.querySelector('[data-viewer-id="00-self"]')).toBeNull(); + expect(footerFacepile?.querySelector('.viewer-facepile [data-viewer-id="00-self"]')).toBeNull(); expect(footerFacepile?.querySelector(".viewer-avatar--overflow")?.textContent).toContain("+1"); + expect(footerFacepile?.querySelector(".viewer-facepile openclaw-tooltip")).toBeNull(); + expect( + [ + ...(footerFacepile?.querySelectorAll( + ".sidebar-presence-hover-card .sidebar-hover-card__person", + ) ?? []), + ].map((row) => row.getAttribute("data-viewer-id")), + ).toEqual(["00-self", "alice", "bob", "carol", "dave", "erin", "frank"]); + expect(footerFacepile?.querySelector(".sidebar-presence-hover-card")?.textContent).toContain( + "Server", + ); const identityChip = sidebar.querySelector(".sidebar-footer-bar__identity"); expect(identityChip?.querySelector(".sidebar-footer-bar__identity-name")?.textContent).toBe(