feat(ui): who's-online roster menu on the footer facepile (#111501)

* feat(ui): who's-online roster menu on the footer facepile

Clicking the sidebar footer facepile now opens a scrollable roster of
everyone online — avatar, name, and email subtitle per person, with your
own entry pinned first and marked (you). Uses the stock menu-surface +
wa-dropdown idiom; long rosters scroll inside the popup via the dropdown's
menu part. Session-row facepiles stay non-interactive so row clicks keep
navigating.

* fix(ui): roster menu interaction hardening from review

- close explicitly on wa-select (preventDefault also cancels the
  dropdown's own select-and-hide)
- clear the open state when a presence update unmounts the footer
  facepile, so the menu cannot remount stale when presence returns
- restore focus to the visible facepile button on keyboard dismissal
  (the dropdown's own trigger is a hidden throwaway anchor)

* fix(ui): return focus to the facepile trigger on roster row selection
This commit is contained in:
Peter Steinberger
2026-07-19 11:34:17 -07:00
committed by GitHub
parent 40d31f3481
commit c69d6e148e
5 changed files with 347 additions and 2 deletions
+1
View File
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- **Control UI who's-online roster:** click the sidebar footer facepile to open a scrollable roster of everyone online, showing each person's avatar, name, and email with your own entry pinned first.
- **Discord and Slack native login:** register `/login` in native command menus while keeping pairing-code issuance limited to private chats and the Web UI.
- **Control UI user profiles:** let trusted-proxy users manage their own display name and avatar, resolve attributed chat and presence identities through uploaded avatars or a private cached Gravatar proxy, and keep other users' profiles admin-only.
- **Trusted-proxy browser pairing:** optionally auto-approve new Control UI and WebChat devices from allowlisted proxy identities with non-admin scope caps, while keeping existing-device upgrades manual.
+152
View File
@@ -48,3 +48,155 @@ it("renders trusted presence avatar routes directly", async () => {
expect(avatar.querySelector("img")?.getAttribute("src")).toBe("/api/users/profile-ada/avatar");
});
});
type ViewerFacepileElement = HTMLElement & {
presencePayload: unknown;
selfInstanceId?: string;
variant: "session" | "footer";
updateComplete: Promise<boolean>;
};
function mountFooterFacepile() {
const facepile = document.createElement("openclaw-viewer-facepile") as ViewerFacepileElement;
facepile.variant = "footer";
facepile.selfInstanceId = "self-instance";
facepile.presencePayload = {
presence: [
{
instanceId: "self-instance",
user: { id: "00-self", name: "Self User", email: "self@example.test" },
watchedSessions: [],
},
{
instanceId: "alice-1",
user: { id: "alice", name: "Alice", email: "alice@example.test" },
watchedSessions: [],
},
{
instanceId: "bob-1",
user: { id: "bob", email: "bob@example.test" },
watchedSessions: [],
},
],
};
document.body.append(facepile);
return facepile;
}
it("opens a who's-online roster from the footer facepile", 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;
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");
// 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(
"alice@example.test",
);
expect(rows[2]?.querySelector(".presence-roster-menu__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[1]?.querySelector("openclaw-viewer-avatar")).not.toBeNull();
});
it("keeps session facepiles as plain non-interactive avatar clusters", async () => {
const facepile = document.createElement("openclaw-viewer-facepile") as ViewerFacepileElement;
facepile.variant = "session";
facepile.presencePayload = {
presence: [
{
instanceId: "alice-1",
user: { id: "alice", name: "Alice" },
watchedSessions: [],
},
],
};
document.body.append(facepile);
await vi.waitFor(async () => {
await facepile.updateComplete;
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();
});
+123 -2
View File
@@ -1,9 +1,12 @@
import { html, nothing } from "lit";
import { property } from "lit/decorators.js";
import { property, state } from "lit/decorators.js";
import type { PresenceEntry } from "../api/types.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 "./tooltip.ts";
import { consumeDropdownKeyboardDismissal, trackDropdownKeyboardDismissal } from "./web-awesome.ts";
export type PresenceViewer = {
id: string;
@@ -170,6 +173,24 @@ class ViewerAvatar extends OpenClawLightDomContentsElement {
}
}
function renderRosterRow(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"
>${label}${isSelf
? html` <span class="presence-roster-menu__you">(${t("presence.you")})</span>`
: nothing}</span
>
${subtitle ? html`<span class="presence-roster-menu__email">${subtitle}</span>` : nothing}
</span>
</wa-dropdown-item>`;
}
class ViewerFacepile extends OpenClawLightDomContentsElement {
@property({ attribute: false }) presencePayload: unknown;
@property({ attribute: false }) selfInstanceId?: string;
@@ -177,6 +198,87 @@ class ViewerFacepile extends OpenClawLightDomContentsElement {
@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>`;
}
override render() {
const projection = projectPresencePayload(this.presencePayload, this.selfInstanceId);
const sessionKey = this.sessionKey;
@@ -192,7 +294,7 @@ class ViewerFacepile extends OpenClawLightDomContentsElement {
}
const visible = users.slice(0, this.maxVisible);
const overflow = users.slice(this.maxVisible);
return html`<span
const facepile = html`<span
class="viewer-facepile viewer-facepile--${this.variant}"
data-viewer-count=${users.length}
aria-label=${users.map(presenceViewerLabel).join(", ")}
@@ -213,6 +315,25 @@ class ViewerFacepile extends OpenClawLightDomContentsElement {
</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.
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)}`;
}
}
+5
View File
@@ -2219,6 +2219,11 @@ export const en: TranslationMap = {
},
},
},
presence: {
rosterLabel: "Show who's online",
rosterTitle: "Online",
you: "you",
},
profilePage: {
offline: "Connect to the gateway to meet your agent.",
loading: "Diving for stats…",
+66
View File
@@ -1966,6 +1966,72 @@ html.openclaw-native-macos
font-size: var(--control-ui-text-sm);
}
.viewer-facepile-trigger {
display: inline-flex;
align-items: center;
padding: 2px;
margin: -2px;
border: none;
border-radius: var(--radius-full);
background: none;
cursor: pointer;
}
.viewer-facepile-trigger:hover,
.viewer-facepile-trigger:focus-visible {
background: color-mix(in srgb, var(--fg) 8%, transparent);
}
/* Long rosters scroll inside the popup instead of growing past the viewport. */
.presence-roster-menu::part(menu) {
max-height: min(320px, 60vh);
overflow-y: auto;
}
.presence-roster-menu__title {
padding: 4px 10px 2px;
color: var(--muted);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.presence-roster-menu__item::part(label) {
display: flex;
align-items: center;
}
.presence-roster-menu__item .viewer-avatar {
border-color: transparent;
}
.presence-roster-menu__text {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.3;
}
.presence-roster-menu__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.presence-roster-menu__you {
color: var(--muted);
}
.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;