mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user