fix(ui): unify tooltip arrows and rework sidebar footer hover cards (#112602)

* fix(ui): unify tooltip arrows and rework sidebar footer hover cards

* fix(ui): keep rich tooltips open while trigger or card retains hover or focus

* fix(ui): type trigger slot query and drop unused build-card export

* test(ui): type usage hint button query for test-types lane
This commit is contained in:
Peter Steinberger
2026-07-22 02:17:35 -07:00
committed by GitHub
parent 7767203be3
commit a67b937fce
16 changed files with 677 additions and 448 deletions

View File

@@ -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 {
<openclaw-viewer-facepile
.presencePayload=${this.presencePayload}
.selfInstanceId=${this.presenceInstanceId}
.buildInfo=${CONTROL_UI_BUILD_INFO}
.gatewayVersion=${this.gatewayVersion}
.maxVisible=${5}
variant="footer"
></openclaw-viewer-facepile>

View File

@@ -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`
<div class="sidebar-hover-card__server-details">
<div class="sidebar-hover-card__summary">${details.summary || unavailable}</div>
<dl class="sidebar-hover-card__metadata">
${rows.map(
(row) => html`
<div class="sidebar-hover-card__metadata-row">
<dt>${row.label}</dt>
<dd class=${row.mono ? "sidebar-hover-card__metadata-value--mono" : ""}>
${row.value}
</dd>
</div>
`,
)}
</dl>
</div>
`;
}

View File

@@ -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`
<openclaw-tooltip .content=${tooltip}>
<openclaw-tooltip class="sidebar-hover-tooltip">
<a
class="sidebar-footer-build"
href=${pathForRoute("about", this.basePath)}
@@ -61,6 +44,9 @@ class SidebarBuildChip extends OpenClawLightDomContentsElement {
}}
>${text}</a
>
<div slot="content" class="sidebar-hover-card sidebar-build-hover-card">
${renderSidebarServerDetails(CONTROL_UI_BUILD_INFO, this.gatewayVersion)}
</div>
</openclaw-tooltip>
`;
}

View File

@@ -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<HTMLSlotElement>('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;

View File

@@ -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<HTMLSlotElement>("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<HTMLSlotElement>('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<HTMLSlotElement>('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`<span class="tooltip-content">${this.content}</span>`;
return html`
<slot @slotchange=${() => this.attachTrigger()}></slot>
<wa-tooltip id=${this.tooltipId} trigger="manual">${tooltipContent}</wa-tooltip>
<wa-tooltip id=${this.tooltipId} trigger="manual">
<span class="tooltip-content">${this.content}</span>
<span
class="tooltip-rich-content"
@pointerenter=${this.handleContentPointerEnter}
@pointerleave=${this.handleContentPointerLeave}
@focusin=${this.handleFocusIn}
@focusout=${this.handleFocusOut}
>
<slot name="content" @slotchange=${this.handleContentSlotChange}></slot>
</span>
</wa-tooltip>
`;
}
}

View File

@@ -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<boolean>;
};
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<HTMLButtonElement>("button.viewer-facepile-trigger")?.click();
const tooltip = facepile.querySelector<HTMLElement & { updateComplete: Promise<boolean> }>(
"openclaw-tooltip.sidebar-hover-tooltip",
);
await tooltip?.updateComplete;
const trigger = facepile.querySelector<HTMLElement>(".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<HTMLElement & { open: boolean }>("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<HTMLButtonElement>("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<HTMLButtonElement>("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);
});

View File

@@ -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`<wa-dropdown-item class="presence-roster-menu__item" data-viewer-id=${user.id}>
<openclaw-viewer-avatar slot="icon" .user=${user} variant="footer"></openclaw-viewer-avatar>
<span class="presence-roster-menu__text">
<span class="presence-roster-menu__name"
return html`<div class="sidebar-hover-card__person" data-viewer-id=${user.id}>
<openclaw-viewer-avatar .user=${user} variant="footer"></openclaw-viewer-avatar>
<span class="sidebar-hover-card__person-text">
<span class="sidebar-hover-card__person-name"
>${label}${isSelf
? html` <span class="presence-roster-menu__you">(${t("presence.you")})</span>`
? html` <span class="sidebar-hover-card__you">(${t("presence.you")})</span>`
: nothing}</span
>
${subtitle ? html`<span class="presence-roster-menu__email">${subtitle}</span>` : nothing}
${subtitle
? html`<span class="sidebar-hover-card__person-email">${subtitle}</span>`
: nothing}
</span>
</wa-dropdown-item>`;
</div>`;
}
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<HTMLButtonElement>("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`<openclaw-menu-surface>
<wa-dropdown
class="presence-roster-menu"
.open=${true}
placement="top-start"
.distance=${4}
aria-label=${t("presence.rosterTitle")}
@wa-select=${(event: CustomEvent) => {
// 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();
}
}}
>
<button
slot="trigger"
type="button"
tabindex="-1"
aria-hidden="true"
style="position: fixed; left: ${position.x}px; top: ${position.y}px; width: 1px; height: 1px; opacity: 0; pointer-events: none;"
></button>
<div class="presence-roster-menu__title" role="presentation">
${t("presence.rosterTitle")} · ${roster.length}
</div>
${roster.map((user) => renderRosterRow(user, user.id === selfUserId))}
</wa-dropdown>
</openclaw-menu-surface>`;
}
@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`<openclaw-tooltip .content=${label}>
<openclaw-viewer-avatar .user=${user} .variant=${this.variant}></openclaw-viewer-avatar>
</openclaw-tooltip>`;
})}
${visible.map((user) =>
this.variant === "footer"
? html`<openclaw-viewer-avatar .user=${user} variant="footer"></openclaw-viewer-avatar>`
: html`<openclaw-tooltip .content=${presenceViewerLabel(user)}>
<openclaw-viewer-avatar .user=${user} variant="session"></openclaw-viewer-avatar>
</openclaw-tooltip>`,
)}
${overflow.length > 0
? html`<openclaw-tooltip .content=${overflow.map(presenceViewerLabel).join("\n")}>
<span
? this.variant === "footer"
? html`<span
class="viewer-avatar viewer-avatar--overflow"
aria-label=${overflow.map(presenceViewerLabel).join(", ")}
>+${overflow.length}</span
>
</openclaw-tooltip>`
>`
: html`<openclaw-tooltip .content=${overflow.map(presenceViewerLabel).join("\n")}>
<span
class="viewer-avatar viewer-avatar--overflow"
aria-label=${overflow.map(presenceViewerLabel).join(", ")}
>+${overflow.length}</span
>
</openclaw-tooltip>`
: nothing}
</span>`;
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`<button
type="button"
class="viewer-facepile-trigger"
aria-label=${t("presence.rosterLabel")}
aria-haspopup="menu"
aria-expanded=${this.rosterPosition !== null}
@click=${(event: MouseEvent) => this.openRoster(event)}
>
${facepile}
</button>
${this.renderRosterMenu(roster, projection.selfUserId)}`;
return html`
<openclaw-tooltip class="sidebar-hover-tooltip">
<span
class="viewer-facepile-trigger"
role="group"
tabindex="0"
aria-label=${t("presence.rosterLabel")}
>
${facepile}
</span>
<div slot="content" class="sidebar-hover-card sidebar-presence-hover-card">
<section class="sidebar-hover-card__region">
<div class="sidebar-hover-card__heading">
${t("presence.rosterTitle")} · ${roster.length}
</div>
<div
class="sidebar-hover-card__people"
tabindex="0"
aria-label=${`${t("presence.rosterTitle")} · ${roster.length}`}
>
${roster.map((user) =>
renderPresenceCardRow(user, user.id === projection.selfUserId),
)}
</div>
</section>
<div class="sidebar-hover-card__divider" role="separator"></div>
<section class="sidebar-hover-card__region">
<div class="sidebar-hover-card__heading">${t("presence.serverRegion")}</div>
${renderSidebarServerDetails(this.buildInfo, this.gatewayVersion)}
</section>
</div>
</openclaw-tooltip>
`;
}
}

View File

@@ -2265,6 +2265,7 @@ export const en: TranslationMap = {
presence: {
rosterLabel: "Show who's online",
rosterTitle: "Online",
serverRegion: "Server",
you: "you",
},
profilePage: {

View File

@@ -1,9 +1,7 @@
// Registers <wa-tooltip>: 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`
<wa-tooltip
class="chat-tasks-status__preview"
for=${props.statusRowId}
placement="top-start"
without-arrow
>
<div class="chat-tasks-preview">
${preview.map((task) => renderStatusPreviewRow(task))}
${overflow > 0
? html`<div class="chat-tasks-preview__more">
${t("chat.backgroundTasks.statusPreviewMore", { count: String(overflow) })}
</div>`
: nothing}
</div>
</wa-tooltip>
<div slot="content" class="chat-tasks-preview">
${preview.map((task) => renderStatusPreviewRow(task))}
${overflow > 0
? html`<div class="chat-tasks-preview__more">
${t("chat.backgroundTasks.statusPreviewMore", { count: String(overflow) })}
</div>`
: nothing}
</div>
`;
}
@@ -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`
<div class="chat-tasks-status" id=${backgroundTasks.statusRowId} role="status">
<span class="chat-tasks-status__claw" aria-hidden="true">${icons.claw}</span>
${status.startedMs !== null
? html`
<!-- Ticking time stays out of the polite live region: without
aria-hidden, screen readers would re-announce every second. -->
<span class="chat-tasks-status__time" aria-hidden="true">
<openclaw-elapsed-time .startMs=${status.startedMs}></openclaw-elapsed-time>
</span>
<span class="chat-tasks-status__sep" aria-hidden="true">·</span>
`
: nothing}
<button class="chat-tasks-status__link" type="button" @click=${openRail}>${label}</button>
</div>
${renderStatusPreview(backgroundTasks)}
<openclaw-tooltip class="chat-tasks-status__preview">
<div class="chat-tasks-status" id=${backgroundTasks.statusRowId} role="status">
<span class="chat-tasks-status__claw" aria-hidden="true">${icons.claw}</span>
${status.startedMs !== null
? html`
<!-- Ticking time stays out of the polite live region: without
aria-hidden, screen readers would re-announce every second. -->
<span class="chat-tasks-status__time" aria-hidden="true">
<openclaw-elapsed-time .startMs=${status.startedMs}></openclaw-elapsed-time>
</span>
<span class="chat-tasks-status__sep" aria-hidden="true">·</span>
`
: nothing}
<button class="chat-tasks-status__link" type="button" @click=${openRail}>${label}</button>
</div>
${renderStatusPreview(backgroundTasks)}
</openclaw-tooltip>
`;
}

View File

@@ -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(),
);

View File

@@ -126,7 +126,7 @@ describe("renderUsageInsights", () => {
);
const buttons = [...container.querySelectorAll<HTMLButtonElement>("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<HTMLButtonElement>("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();

View File

@@ -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: {
<div class=${classes}>
<div class="usage-summary-title">
${params.title}
<button
id=${hintId}
type="button"
class="usage-summary-hint"
aria-label=${params.hint}
@click=${focusSummaryHint}
>
?
</button>
<!-- Some browsers do not focus buttons on pointer activation; the
click handler normalizes that path without adding a second toggle. -->
<wa-tooltip
id=${tooltipId}
class="usage-summary-tooltip"
for=${hintId}
trigger="hover focus"
>
${params.hint}
</wa-tooltip>
<openclaw-tooltip>
<button
id=${hintId}
type="button"
class="usage-summary-hint"
aria-label=${params.title}
@click=${focusSummaryHint}
>
?
</button>
<!-- Some browsers do not focus buttons on pointer activation; the
click handler normalizes that path without adding a second toggle. -->
<span slot="content">${params.hint}</span>
</openclaw-tooltip>
</div>
<div class=${valueClasses}>${params.value}</div>
<div class="usage-summary-sub">${params.sub}</div>

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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));
}

View File

@@ -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<HTMLButtonElement>(".sidebar-footer-bar__identity");
expect(identityChip?.querySelector(".sidebar-footer-bar__identity-name")?.textContent).toBe(