fix(ui): unify online person rows (#131440)

Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com>
This commit is contained in:
RoboClaw
2026-08-27 20:12:14 -07:00
committed by GitHub
parent 16b0c1f251
commit 3fe43df369
8 changed files with 71 additions and 109 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ When the loaded session list contains fewer than two distinct owner identities a
## People cards
Hover or focus a person in the sidebar's **Online** section to open their information card. The details button also opens the card on touch devices. Selecting the person's name or **View activity** opens their Activity page.
Hover, focus, click, or tap a person in the sidebar's **Online** section to open their information card. Select **View activity** in the card to open that person's Activity page.
The card shows how long the person has been continuously connected, their reported app/device context and time zone, and their last observed activity during that online period. Opening a different session, typing, and sending a new message count as activity; connection heartbeats and agent responses do not. **Not observed yet** means no qualifying activity has been recorded, not that the person is inactive. These timing facts are ephemeral and reset after the person's final connection closes or the Gateway restarts.
+9 -22
View File
@@ -40,7 +40,6 @@ import type { SidebarWorkboardBoard } from "./app-sidebar-workboard.ts";
import { icons } from "./icons.ts";
import { redactLoginFailureError } from "./login-gate.ts";
import { renderNewSessionLink } from "./new-session-link.ts";
import { personActivityLink, personActivityRouting } from "./person-activity-link.ts";
import {
renderSessionAttentionIcon,
renderSessionRunSpinner,
@@ -313,21 +312,16 @@ export function renderAppSidebarOnline(host: AppSidebarRenderHost) {
users,
(user) => user.id,
(user) => {
const activity = personActivityLink(
user.id,
personActivityRouting({
basePath: host.basePath,
navigate: (route, options) => host.onNavigate?.(route, options),
}),
)!;
return html`<div class="sidebar-online__row">
<a
<button
class="sidebar-online__person ${isPresenceViewerIdle(user)
? "sidebar-online__person--away"
: ""}"
type="button"
data-online-user-id=${user.id}
href=${activity.href}
@click=${activity.open}
aria-haspopup="dialog"
aria-expanded="false"
aria-label=${t("presence.card.details", { name: presenceViewerLabel(user) })}
>
<openclaw-viewer-avatar
.user=${user}
@@ -335,17 +329,10 @@ export function renderAppSidebarOnline(host: AppSidebarRenderHost) {
variant="footer"
aria-hidden="true"
></openclaw-viewer-avatar>
<span class="sidebar-online__person-name"
>${presenceViewerLabel(user)}</span
> </a
><button
class="sidebar-online__details"
type="button"
aria-haspopup="dialog"
aria-expanded="false"
aria-label=${t("presence.card.details", { name: presenceViewerLabel(user) })}
>
<span aria-hidden="true">${icons.chevronRight}</span>
<span class="sidebar-online__person-name">${presenceViewerLabel(user)}</span>
<span class="sidebar-online__person-action" aria-hidden="true"
>${icons.chevronRight}</span
>
</button>
</div>`;
},
@@ -73,12 +73,6 @@ export class SidebarPeopleController implements ReactiveController {
return;
}
// Input modality belongs to the runtime; row intent only warms its lazy code.
if (
event.type === "click" &&
!(event.target instanceof Element && event.target.closest(".sidebar-online__details"))
) {
return;
}
const generation = ++this.generation;
this.pendingTarget = target;
document.addEventListener("pointerdown", this.cancelPending, true);
+1 -7
View File
@@ -58,8 +58,6 @@ export class SidebarPeopleRuntime {
event.target instanceof Element
? event.target.closest<HTMLElement>(".sidebar-online__row")
: null;
const details =
event.target instanceof Element && event.target.closest(".sidebar-online__details");
if (event.type === "keydown" && event instanceof KeyboardEvent) {
if (event.key === "Tab" && !event.shiftKey && event.target === this.active?.trigger) {
const first = this.portal.focusables()[0];
@@ -82,10 +80,6 @@ export class SidebarPeopleRuntime {
return;
}
if (event.type === "click") {
if (!details) {
this.close();
return;
}
if (this.active?.row === row && this.portal.explicitHold) {
this.close();
return;
@@ -136,7 +130,7 @@ export class SidebarPeopleRuntime {
private activate(row: HTMLElement, delay: number): void {
const id = row.querySelector<HTMLElement>("[data-online-user-id]")?.dataset.onlineUserId;
const trigger = row.querySelector<HTMLElement>(".sidebar-online__details");
const trigger = row.querySelector<HTMLElement>(".sidebar-online__person");
if (!id || !trigger || !this.host.connected) {
return;
}
@@ -318,6 +318,9 @@ suite.define(() => {
});
await page.locator('[data-online-user-id="profile-alice"]').click();
const personCard = page.getByRole("dialog", { name: "Activity for Alice Chen" });
await personCard.waitFor({ state: "visible" });
await personCard.getByRole("link", { name: "View activity", exact: true }).click();
await expect
.poll(() => new URL(page.url()).searchParams.get("person"))
.toBe("profile-alice");
+20 -18
View File
@@ -13,6 +13,7 @@ import {
const suite = createChatFlowE2eSuite();
const selected = "agent:main:card-selected";
const watched = "agent:main:card-viewing";
const proofDirectory = path.resolve(".artifacts/control-ui-e2e/people-activity-cards");
function scenario() {
const now = Date.now();
@@ -78,22 +79,24 @@ async function capturePeopleCard(page: Page, filename: string) {
if (!captureUiProofEnabled) {
return;
}
const directory = path.resolve(".artifacts/control-ui-e2e/people-activity-cards");
await mkdir(directory, { recursive: true });
await mkdir(proofDirectory, { recursive: true });
await page.screenshot({
path: path.join(directory, filename),
path: path.join(proofDirectory, filename),
fullPage: true,
animations: "disabled",
});
}
suite.define(() => {
it("bridges hover, preserves focus on updates, and keeps navigation separate from details", async () => {
it("opens one person row, preserves focus on updates, and keeps activity navigation in the card", async () => {
await suite.withPage(
{
hasTouch: false,
colorScheme: "light",
locale: "en-US",
recordVideo: captureUiProofEnabled
? { dir: proofDirectory, size: { width: 1280, height: 900 } }
: undefined,
serviceWorkers: "block",
viewport: { width: 1280, height: 900 },
},
@@ -103,13 +106,12 @@ suite.define(() => {
const row = page
.locator(".sidebar-online__row")
.filter({ has: page.locator('[data-online-user-id="alice"]') });
const name = row.locator("a.sidebar-online__person");
const details = row.getByRole("button", { name: "Details for Alice" });
const person = row.getByRole("button", { name: "Details for Alice" });
const card = page.getByRole("dialog", { name: "Activity for Alice" });
await name.waitFor({ state: "visible" });
await person.waitFor({ state: "visible" });
expect(await card.count()).toBe(0);
expect(await name.getAttribute("href")).toContain("/activity?person=alice");
await name.hover();
expect(await row.locator("a, button").count()).toBe(1);
await person.hover();
await card.waitFor({ state: "visible" });
expect(await card.textContent()).toContain("Reported time zone: Europe/Paris");
await expect
@@ -133,7 +135,7 @@ suite.define(() => {
await page.mouse.move(bounds.x + bounds.width + 4, bounds.y + bounds.height / 2);
await page.mouse.move(cardBounds.x + 8, cardBounds.y + 20);
expect(await card.count()).toBe(1);
await details.focus();
await person.focus();
await page.keyboard.press("Tab");
const session = card.getByRole("link", { name: "Release checklist" });
await expect
@@ -161,14 +163,14 @@ suite.define(() => {
expect(await session.evaluate((element) => document.activeElement === element)).toBe(true);
await page.keyboard.press("Escape");
await expect.poll(() => card.count()).toBe(0);
expect(await details.evaluate((element) => document.activeElement === element)).toBe(true);
await details.click();
expect(await person.evaluate((element) => document.activeElement === element)).toBe(true);
await person.click();
await card.waitFor({ state: "visible" });
await page.mouse.move(1100, 850);
expect(await card.count()).toBe(1);
await page.mouse.click(1100, 850);
await expect.poll(() => card.count()).toBe(0);
await details.click();
await person.click();
await card.waitFor({ state: "visible" });
await card.getByRole("link", { name: "View activity", exact: true }).click();
await expect.poll(() => page.url()).toContain("/activity?person=alice");
@@ -195,16 +197,16 @@ suite.define(() => {
.locator(".topbar-nav-toggle:visible, .chat-pane__nav-toggle:visible")
.first()
.click();
const details = page.getByRole("button", { name: "Details for Alice" });
await details.tap();
const person = page.getByRole("button", { name: "Details for Alice" });
await person.tap();
const card = page.getByRole("dialog", { name: "Activity for Alice" });
await card.waitFor({ state: "visible" });
await page.keyboard.press("Tab");
await page.keyboard.press("Escape");
await expect.poll(() => card.count()).toBe(0);
expect(await details.isVisible()).toBe(true);
expect(await details.evaluate((element) => document.activeElement === element)).toBe(true);
await details.tap();
expect(await person.isVisible()).toBe(true);
expect(await person.evaluate((element) => document.activeElement === element)).toBe(true);
await person.tap();
await card.waitFor({ state: "visible" });
expect(await card.evaluate((element) => getComputedStyle(element).pointerEvents)).toBe(
"auto",
+20 -28
View File
@@ -2189,22 +2189,31 @@ openclaw-settings-save-indicator:empty {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
min-height: 30px;
padding: 4px 8px;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--text);
text-decoration: none;
font: inherit;
text-align: left;
transition:
background var(--duration-fast) ease,
box-shadow var(--duration-fast) ease,
filter var(--duration-fast) ease,
opacity var(--duration-fast) ease;
}
.sidebar-online__person:hover,
.sidebar-online__person:hover {
background: color-mix(in srgb, var(--bg-hover) 84%, transparent);
}
.sidebar-online__person:focus-visible {
background: color-mix(in srgb, var(--bg-hover) 84%, transparent);
text-decoration: none;
outline: none;
box-shadow: var(--focus-ring);
}
.sidebar-online__person--away {
@@ -2226,48 +2235,31 @@ openclaw-settings-save-indicator:empty {
white-space: nowrap;
}
.sidebar-online__details {
.sidebar-online__person-action {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
margin-left: auto;
color: var(--muted);
opacity: 0;
transition: opacity var(--duration-fast) ease;
}
.sidebar-online__row:hover .sidebar-online__details,
.sidebar-online__row:focus-within .sidebar-online__details,
.sidebar-online__details[aria-expanded="true"] {
.sidebar-online__person:hover .sidebar-online__person-action,
.sidebar-online__person:focus-visible .sidebar-online__person-action,
.sidebar-online__person[aria-expanded="true"] .sidebar-online__person-action {
opacity: 1;
}
.sidebar-online__details:hover {
background: var(--bg-hover);
color: var(--text);
}
.sidebar-online__details:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.sidebar-online__details svg {
.sidebar-online__person-action svg {
display: block;
width: 13px;
height: 13px;
}
@media (hover: none), (pointer: coarse) {
.sidebar-online__details {
width: 44px;
height: 44px;
.sidebar-online__person-action {
opacity: 1;
}
@@ -2278,7 +2270,7 @@ openclaw-settings-save-indicator:empty {
@media (prefers-reduced-motion: reduce) {
.sidebar-online__person,
.sidebar-online__details {
.sidebar-online__person-action {
transition: none;
}
}
@@ -19,6 +19,7 @@ describe("AppSidebar viewer presence", () => {
gatewayHarness.gateway,
createSessions("main", ["agent:main:main"]),
);
sidebar.connected = true;
const onNavigate = vi.fn();
sidebar.onNavigate = onNavigate;
@@ -93,28 +94,18 @@ describe("AppSidebar viewer presence", () => {
await sidebar.updateComplete;
expect(sidebar.querySelectorAll(".sidebar-online__person")).toHaveLength(3);
const aliceLink = sidebar.querySelector<HTMLAnchorElement>('[data-online-user-id="alice"]')!;
for (const options of [
{ ctrlKey: true },
{ metaKey: true },
{ shiftKey: true },
{ button: 1 },
]) {
const click = new MouseEvent("click", { ...options, bubbles: true, cancelable: true });
let intercepted = false;
aliceLink.addEventListener(
"click",
(event) => {
intercepted = event.defaultPrevented;
event.preventDefault(); // Keep jsdom from attempting a new browsing context.
},
{ once: true },
);
aliceLink.dispatchEvent(click);
expect(intercepted).toBe(false);
}
const aliceRow = sidebar.querySelector<HTMLButtonElement>('[data-online-user-id="alice"]')!;
expect(aliceRow.tagName).toBe("BUTTON");
expect(aliceRow.closest(".sidebar-online__row")?.querySelectorAll("a, button")).toHaveLength(1);
aliceRow.click();
await vi.dynamicImportSettled();
await vi.waitFor(() =>
expect(document.querySelector(".person-activity-hovercard")).not.toBeNull(),
);
expect(onNavigate).not.toHaveBeenCalled();
aliceLink.click();
document
.querySelector<HTMLAnchorElement>(".person-activity-card footer a")!
.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
expect(onNavigate).toHaveBeenCalledWith("activity", {
href: "/activity?person=alice",
pathname: "/activity",
@@ -169,7 +160,7 @@ describe("AppSidebar viewer presence", () => {
})),
});
await sidebar.updateComplete;
sidebar.querySelector<HTMLButtonElement>(".sidebar-online__details")!.click();
sidebar.querySelector<HTMLButtonElement>(".sidebar-online__person")!.click();
// The click loads its interaction owner before the card can render.
await vi.dynamicImportSettled();
await vi.waitFor(() =>
@@ -233,8 +224,7 @@ describe("AppSidebar viewer presence", () => {
const bob = { ts: now, user: { id: "bob", name: "Bob" }, lastInputSeconds: 0 };
gateway.publishEvent("presence", { presence: [alice, bob] });
await sidebar.updateComplete;
const aliceLink = sidebar.querySelector<HTMLAnchorElement>('[data-online-user-id="alice"]')!;
const button = aliceLink.parentElement!.querySelector<HTMLButtonElement>("button")!;
const button = sidebar.querySelector<HTMLButtonElement>('[data-online-user-id="alice"]')!;
button.click();
await vi.dynamicImportSettled();
await vi.waitFor(() =>
@@ -247,7 +237,7 @@ describe("AppSidebar viewer presence", () => {
presence: [{ ...alice, lastInputSeconds: 600, lastActivityAt: now }, bob],
});
await sidebar.updateComplete;
expect(sidebar.querySelector('[data-online-user-id="alice"]')).toBe(aliceLink);
expect(sidebar.querySelector('[data-online-user-id="alice"]')).toBe(button);
expect(
sidebar.querySelector(".sidebar-online__person")?.getAttribute("data-online-user-id"),
).toBe("bob");
@@ -290,7 +280,7 @@ describe("AppSidebar viewer presence", () => {
});
await sidebar.updateComplete;
vi.useFakeTimers();
sidebar.querySelector<HTMLButtonElement>(".sidebar-online__details")!.click();
sidebar.querySelector<HTMLButtonElement>(".sidebar-online__person")!.click();
await vi.dynamicImportSettled();
await vi.waitFor(() =>
expect(document.querySelector("openclaw-elapsed-time")?.textContent).toBeTruthy(),
@@ -335,7 +325,7 @@ describe("AppSidebar viewer presence", () => {
presence: [{ ...person, reason: "disconnect" }, returned],
});
await sidebar.updateComplete;
sidebar.querySelector<HTMLButtonElement>(".sidebar-online__details")!.click();
sidebar.querySelector<HTMLButtonElement>(".sidebar-online__person")!.click();
await vi.waitFor(() =>
expect(
document.querySelector(".person-activity-hovercard time")?.getAttribute("datetime"),