mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-03 03:01:35 +00:00
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:
committed by
GitHub
parent
7767203be3
commit
a67b937fce
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2265,6 +2265,7 @@ export const en: TranslationMap = {
|
||||
presence: {
|
||||
rosterLabel: "Show who's online",
|
||||
rosterTitle: "Online",
|
||||
serverRegion: "Server",
|
||||
you: "you",
|
||||
},
|
||||
profilePage: {
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user