mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
fix(ui): unify online person rows (#131440)
Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user