feat(ui): link session hovercard identities to their activity feed (#128313)

* feat(ui): link session hovercard identities to their activity feed

Names shown in the session hovercard are now links to that person's
Activity feed. `activityPersonLocation` in app-route-paths owns the
`person` query contract that the Activity filters parse and the sidebar
ONLINE list already hand-rolled, so every identity surface builds the
same link.

The creator avatar gets a decorative twin link (aria-hidden, out of the
tab order) and participant names keep the locale's own "with {name}"
phrasing and list separators while each name becomes its own link.

* test(ui): capture the hovercard identity at rest for PR proof
This commit is contained in:
Peter Steinberger
2026-08-23 11:58:59 -07:00
committed by GitHub
parent 7c7e10ab85
commit 04eae7cea1
8 changed files with 421 additions and 53 deletions
+13
View File
@@ -107,6 +107,19 @@ export function pathForRoute(routeId: RouteId, basePath = ""): string {
return normalizedBasePath ? `${normalizedBasePath}${path}` : path;
}
/** Query key the Activity feed reads to scope its session list to one person. */
export const ACTIVITY_PERSON_PARAM = "person";
/** Activity feed scoped to one person, for every surface that shows an identity. */
export function activityPersonLocation(
personId: string,
basePath = "",
): { pathname: string; search: string; href: string } {
const pathname = pathForRoute("activity", basePath);
const search = `?${new URLSearchParams({ [ACTIVITY_PERSON_PARAM]: personId }).toString()}`;
return { pathname, search, href: `${pathname}${search}` };
}
export function pathForWorkboardBoard(boardId: string, basePath = ""): string {
if (!isValidWorkboardBoardId(boardId)) {
throw new Error("Invalid Workboard board id.");
+3 -4
View File
@@ -5,7 +5,7 @@ import {
type NavigationRouteId,
type SidebarZoneEntry,
} from "../app-navigation.ts";
import { isRouteId, isSessionRouteId, pathForRoute } from "../app-route-paths.ts";
import { activityPersonLocation, isRouteId, isSessionRouteId } from "../app-route-paths.ts";
import { resolveControlUiAuthToken } from "../app/control-ui-auth.ts";
import { isNativeWebChromeHost } from "../app/native-web-chrome.ts";
import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts";
@@ -315,14 +315,13 @@ export function renderAppSidebarOnline(host: AppSidebarRenderHost) {
? nothing
: html`<div class="sidebar-online__list">
${users.map((user) => {
const pathname = pathForRoute("activity", host.basePath);
const search = `?${new URLSearchParams({ person: user.id }).toString()}`;
const { pathname, search, href } = activityPersonLocation(user.id, host.basePath);
return html`<a
class="sidebar-online__person ${isPresenceViewerIdle(user)
? "sidebar-online__person--away"
: ""}"
data-online-user-id=${user.id}
href=${`${pathname}${search}`}
href=${href}
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
@@ -338,6 +338,78 @@ describe("renderSessionHovercard", () => {
).toBe("Alice Baker, Mira, Riley 3 more participants");
});
it("opens the creator's activity feed from the identity row", () => {
const container = document.createElement("div");
const navigate = vi.fn();
render(
renderSessionHovercard({
row: row(),
personActivity: { basePath: "/ui", navigate },
}),
container,
);
const name = container.querySelector<HTMLAnchorElement>(".session-hovercard__identity-name");
expect(name?.getAttribute("href")).toBe("/ui/activity?person=alice");
expect(
container
.querySelector(".session-hovercard__identity-avatar-link")
?.getAttribute("aria-hidden"),
).toBe("true");
const click = new MouseEvent("click", { bubbles: true, cancelable: true });
name?.dispatchEvent(click);
expect(navigate).toHaveBeenCalledWith("alice");
expect(click.defaultPrevented).toBe(true);
});
it("links every participant name while keeping the locale's list phrasing", () => {
const container = document.createElement("div");
const navigate = vi.fn();
render(
renderSessionHovercard({
selfUserId: "self",
row: row({
participants: [
{ type: "human", id: "self", label: "You" },
{ type: "human", id: "mira", label: "Mira" },
{ type: "human", id: "riley", label: "Riley" },
],
participantCount: 5,
}),
personActivity: { basePath: "", navigate },
}),
container,
);
expect(
container
.querySelector(".session-hovercard__identity-copy")
?.textContent?.replace(/\s+/gu, " ")
.trim(),
).toBe("Alice Baker · with Mira, Riley +2");
const participantLinks = [
...container.querySelectorAll<HTMLAnchorElement>("a.session-hovercard__participant-name"),
];
expect(participantLinks.map((link) => [link.textContent, link.getAttribute("href")])).toEqual([
["Mira", "/activity?person=mira"],
["Riley", "/activity?person=riley"],
]);
participantLinks[1]?.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true }),
);
expect(navigate).toHaveBeenCalledWith("riley");
});
it("keeps the identity plain text when no activity route is available", () => {
const container = document.createElement("div");
render(renderSessionHovercard({ row: row() }), container);
expect(container.querySelector(".session-hovercard__identity-name")?.tagName).toBe("SPAN");
expect(container.querySelector(".session-hovercard__identity-avatar-link")).toBeNull();
});
it("keeps authoritative overflow when the participant projection is truncated", () => {
const container = document.createElement("div");
render(
+142 -45
View File
@@ -1,20 +1,26 @@
import type { ProgressCard } from "@openclaw/gateway-protocol";
import { bucketRelativeTimeMs, type RelativeTimeUnit } from "@openclaw/normalization-core";
import { html, nothing } from "lit";
import { html, nothing, type TemplateResult } from "lit";
import type {
ControlUiSessionPullRequest,
ControlUiSessionPullRequestSnapshot,
} from "../../../src/gateway/control-ui-contract.js";
import { activityPersonLocation } from "../app-route-paths.ts";
import { i18n, t } from "../i18n/index.ts";
import { shouldHandleNavigationClick } from "../lib/navigation-click.ts";
import type { SidebarSessionHovercardRow } from "./app-sidebar-session-types.ts";
import { icons } from "./icons.ts";
import { sessionOwnerInitials } from "./session-owner-chip.ts";
import { sessionOwnerInitials, type SessionCreatedActor } from "./session-owner-chip.ts";
import { renderSessionProgressCard } from "./session-progress-card.ts";
import "./viewer-facepile.ts";
const MAX_VISIBLE_PULL_REQUESTS = 4;
const MAX_VISIBLE_PARTICIPANTS = 3;
function participantLabel(participant: SessionCreatedActor): string {
return participant.label?.trim() || participant.id?.trim() || "";
}
type SessionAgeUnit = RelativeTimeUnit | "week" | "month" | "year";
type SessionHovercardAvatarAuth = {
@@ -22,6 +28,21 @@ type SessionHovercardAvatarAuth = {
authReady: boolean;
};
/** Opens the Activity feed for one identity; supplied by the hovercard host that owns routing. */
export type SessionHovercardPersonActivity = {
basePath: string;
navigate: (personId: string) => void;
};
type SessionHovercardInput = {
row?: SidebarSessionHovercardRow;
selfUserId?: string;
avatarAuth?: SessionHovercardAvatarAuth;
personActivity?: SessionHovercardPersonActivity;
pullRequests?: ControlUiSessionPullRequestSnapshot;
progressCard?: ProgressCard | null;
};
let channelAvatarElementLoad: Promise<unknown> | undefined;
function ensureChannelAvatarElement(): void {
channelAvatarElementLoad ??= import("./channel-avatar.ts");
@@ -158,11 +179,80 @@ function renderHeader(row: SidebarSessionHovercardRow) {
</header>`;
}
function renderSessionContext(
row: SidebarSessionHovercardRow | undefined,
selfUserId?: string,
avatarAuth?: SessionHovercardAvatarAuth,
type PersonActivityLink = { href: string; open: (event: MouseEvent) => void };
function personActivityLink(
personId: string,
personActivity: SessionHovercardPersonActivity | undefined,
): PersonActivityLink | null {
if (!personActivity) {
return null;
}
return {
href: activityPersonLocation(personId, personActivity.basePath).href,
open: (event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
personActivity.navigate(personId);
},
};
}
function renderPersonName(label: string, link: PersonActivityLink | null, className: string) {
return link
? html`<a
class="${className} session-hovercard__identity-link"
href=${link.href}
@click=${link.open}
>${label}</a
>`
: html`<span class=${className}>${label}</span>`;
}
/**
* Keeps the locale's own "with {name}" phrasing and list separators while making each
* name its own link. A translation that lost its placeholder falls back to plain text
* rather than dropping the names.
*/
function renderParticipantNames(
participants: readonly SessionCreatedActor[],
formattedNames: string,
personActivity: SessionHovercardPersonActivity | undefined,
) {
const [prefix, suffix] = t("sessionsView.withParticipant").split("{name}");
if (suffix === undefined) {
return t("sessionsView.withParticipant", { name: formattedNames });
}
const links = participants.map((participant) =>
participant.id ? personActivityLink(participant.id, personActivity) : null,
);
const parts = new Intl.ListFormat(i18n.getLocale(), {
style: "long",
type: "unit",
}).formatToParts(participants.map(participantLabel));
const names: (TemplateResult | string)[] = [];
let index = 0;
for (const part of parts) {
if (part.type === "literal") {
names.push(part.value);
continue;
}
names.push(
renderPersonName(part.value, links[index] ?? null, "session-hovercard__participant-name"),
);
index += 1;
}
return html`${prefix}${names}${suffix}`;
}
function renderSessionContext({
row,
selfUserId,
avatarAuth,
personActivity,
}: SessionHovercardInput) {
const creator = row?.createdActor;
const creatorLabel = creator?.label?.trim() || creator?.id?.trim();
const creatorInitials = creator ? sessionOwnerInitials(creator) : "";
@@ -187,9 +277,7 @@ function renderSessionContext(
return true;
});
const visibleParticipants = participants.slice(0, MAX_VISIBLE_PARTICIPANTS);
const participantNames = visibleParticipants.map(
(participant) => participant.label || participant.id || "",
);
const participantNames = visibleParticipants.map(participantLabel);
const formattedParticipantNames = new Intl.ListFormat(i18n.getLocale(), {
style: "long",
type: "unit",
@@ -213,40 +301,53 @@ function renderSessionContext(
if (row?.channelAvatarUrl) {
ensureChannelAvatarElement();
}
const creatorId = creator?.id;
const creatorActivity = creatorId ? personActivityLink(creatorId, personActivity) : null;
const creatorAvatar = row?.channelAvatarUrl
? html`<openclaw-channel-avatar
class="session-hovercard__creator-avatar"
.routeUrl=${row.channelAvatarUrl}
.authTokens=${avatarAuth?.authTokens ?? []}
.authReady=${avatarAuth?.authReady ?? false}
.fallback=${avatarFallback}
aria-hidden="true"
></openclaw-channel-avatar>`
: creatorId
? html`<openclaw-viewer-avatar
class="session-hovercard__creator-avatar"
.user=${{
id: creatorId,
name: creator?.label,
avatarUrl: creator?.avatarUrl,
watchedSessions: [],
}}
.markAsViewer=${false}
variant="session"
aria-hidden="true"
></openclaw-viewer-avatar>`
: html`<span class="session-hovercard__context-icon" aria-hidden="true"
>${icons.users}</span
>`;
return html`<div class="session-hovercard__context">
${creatorLabel || visibleParticipants.length > 0
? html`<div
class="session-hovercard__context-row session-hovercard__identity-row"
aria-label=${[creatorLabel, participantSummary].filter(Boolean).join(", ")}
>
${row?.channelAvatarUrl
? html`<openclaw-channel-avatar
class="session-hovercard__creator-avatar"
.routeUrl=${row.channelAvatarUrl}
.authTokens=${avatarAuth?.authTokens ?? []}
.authReady=${avatarAuth?.authReady ?? false}
.fallback=${avatarFallback}
${creatorActivity
? // Decorative twin of the name link: the name below carries the accessible target.
html`<a
class="session-hovercard__identity-avatar-link"
href=${creatorActivity.href}
tabindex="-1"
aria-hidden="true"
></openclaw-channel-avatar>`
: creator?.id
? html`<openclaw-viewer-avatar
class="session-hovercard__creator-avatar"
.user=${{
id: creator.id,
name: creator.label,
avatarUrl: creator.avatarUrl,
watchedSessions: [],
}}
.markAsViewer=${false}
variant="session"
aria-hidden="true"
></openclaw-viewer-avatar>`
: html`<span class="session-hovercard__context-icon" aria-hidden="true"
>${icons.users}</span
>`}
@click=${creatorActivity.open}
>${creatorAvatar}</a
>`
: creatorAvatar}
<span class="session-hovercard__identity-copy">
${creatorLabel
? html`<span class="session-hovercard__identity-name">${creatorLabel}</span>`
? renderPersonName(creatorLabel, creatorActivity, "session-hovercard__identity-name")
: nothing}
${creatorLabel && visibleParticipants.length > 0
? html`<span class="session-hovercard__identity-separator" aria-hidden="true"
@@ -256,9 +357,11 @@ function renderSessionContext(
${visibleParticipants.length > 0
? html`<span class="session-hovercard__participants">
<span class="session-hovercard__participant"
>${t("sessionsView.withParticipant", {
name: formattedParticipantNames,
})}</span
>${renderParticipantNames(
visibleParticipants,
formattedParticipantNames,
personActivity,
)}</span
>
${hiddenParticipantCount > 0
? html`<span class="session-hovercard__participants-more"
@@ -386,13 +489,7 @@ function renderPullRequestDetails(snapshot: ControlUiSessionPullRequestSnapshot
`;
}
export function renderSessionHovercard(input: {
row?: SidebarSessionHovercardRow;
selfUserId?: string;
avatarAuth?: SessionHovercardAvatarAuth;
pullRequests?: ControlUiSessionPullRequestSnapshot;
progressCard?: ProgressCard | null;
}) {
export function renderSessionHovercard(input: SessionHovercardInput) {
const hasPullRequestDetails = Boolean(
input.pullRequests && (input.pullRequests.pullRequests.length > 0 || input.pullRequests.branch),
);
@@ -421,7 +518,7 @@ export function renderSessionHovercard(input: {
: nothing}
${hasContext
? html`<section class="session-hovercard__section session-hovercard__section--metadata">
${renderSessionContext(input.row, input.selfUserId, input.avatarAuth)}
${renderSessionContext(input)}
</section>`
: nothing}
${hasPullRequestDetails
@@ -1,6 +1,7 @@
import type { ProgressCard } from "@openclaw/gateway-protocol";
import { ReactiveElement, render } from "lit";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import { activityPersonLocation } from "../app-route-paths.ts";
import type { ApplicationContext } from "../app/context.ts";
import { resolveControlUiAuthCandidates } from "../app/control-ui-auth.ts";
import type { ApplicationGateway } from "../app/gateway.ts";
@@ -17,7 +18,10 @@ import {
import { parseAgentSessionKey } from "../lib/sessions/session-key.ts";
import type { AppSidebarSessionNavigationElement } from "./app-sidebar-session-navigation.ts";
import { createPortaledHovercard, PortaledHovercardController } from "./portaled-hovercard.ts";
import { renderSessionHovercard } from "./session-hovercard.ts";
import {
renderSessionHovercard,
type SessionHovercardPersonActivity,
} from "./session-hovercard.ts";
import { SessionLinkTitler } from "./session-link-titling.ts";
import {
SESSION_MENU_OPEN_EVENT,
@@ -453,6 +457,7 @@ export class SessionProgressHovercardProvider extends ReactiveElement {
row: sidebarRow,
selfUserId: this.applicationContext?.gateway.snapshot.selfUser?.id,
avatarAuth: channelAvatarAuth,
personActivity: this.personActivity(),
pullRequests,
progressCard: this.lastProgressCard,
}),
@@ -543,7 +548,25 @@ export class SessionProgressHovercardProvider extends ReactiveElement {
};
private cardFocusables(): HTMLElement[] {
return [...(this.hovercard.card?.querySelectorAll<HTMLElement>("a[href]") ?? [])];
// Decorative link twins (avatars beside their labelled link) opt out with tabindex="-1".
return [
...(this.hovercard.card?.querySelectorAll<HTMLElement>('a[href]:not([tabindex="-1"])') ?? []),
];
}
private personActivity(): SessionHovercardPersonActivity | undefined {
const context = this.applicationContext;
if (!context) {
return undefined;
}
return {
basePath: context.basePath,
navigate: (personId) => {
// The card outlives its trigger row after navigation, so close it with the same call.
this.close();
context.navigate("activity", activityPersonLocation(personId, context.basePath));
},
};
}
private close(animateExit = false): void {
@@ -0,0 +1,147 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Page } from "playwright";
import { expect, it } from "vitest";
import { waitForControlUiRoute } from "../test-helpers/control-ui-e2e.ts";
import {
captureUiProofEnabled,
chatSessionListResponse,
controlUiSessionUrl,
createChatFlowE2eSuite,
installMockGateway,
} from "./chat-flow.test-support.ts";
const proofDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"session-hovercard-identity",
);
async function captureProof(page: Page, fileName: string): Promise<void> {
if (!captureUiProofEnabled) {
return;
}
await mkdir(proofDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,
path: path.join(proofDir, fileName),
});
}
const suite = createChatFlowE2eSuite();
suite.define(() => {
it("opens each identity's activity feed from the hovercard", async () => {
const now = Date.now();
const selectedSessionKey = "agent:main:identity-selected";
const sessionKey = "agent:main:identity-hovered";
await suite.withPage(
{
hasTouch: false,
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
},
async ({ page }) => {
await installMockGateway(page, {
featureMethods: ["chat.metadata", "chat.startup", "progressCard.get"],
presenceUsers: [
{ self: true, id: "profile-self", name: "You" },
{ id: "profile-ada", name: "Ada King" },
{ id: "profile-mira", name: "Mira" },
],
methodResponses: {
"progressCard.get": { card: null },
"sessions.list": chatSessionListResponse([
{
key: selectedSessionKey,
kind: "direct",
label: "Selected session",
updatedAt: now - 5 * 60_000,
},
{
createdActor: { type: "human", id: "profile-ada", label: "Ada King" },
createdAt: now - 3 * 60 * 60_000,
key: sessionKey,
kind: "direct",
label: "Ada's session",
displayName: "Ada's session",
owner: { actor: { type: "human", id: "profile-ada", label: "Ada King" } },
participants: [
{ type: "human", id: "profile-ada", label: "Ada King" },
{ type: "human", id: "profile-mira", label: "Mira" },
],
participantCount: 2,
updatedAt: now - 10 * 60_000,
},
]),
},
sessionKey: selectedSessionKey,
});
await page.goto(controlUiSessionUrl(suite.server.baseUrl, selectedSessionKey));
const row = page.locator(`.sidebar-recent-session[data-session-key="${sessionKey}"]`);
const card = page.locator(".session-progress-hovercard");
await row.waitFor({ state: "visible" });
await row.hover();
await card.waitFor({ state: "visible" });
await captureProof(page, "hovercard-identity-rest.png");
const trigger = row.locator("a.sidebar-recent-session__link");
const identity = card.locator("a.session-hovercard__identity-name");
const participant = card.locator("a.session-hovercard__participant-name");
await expect.poll(() => identity.textContent()).toBe("Ada King");
expect(await identity.getAttribute("href")).toBe("/activity?person=profile-ada");
// The locale's own "with {name}" phrasing survives per-name linking.
await expect
.poll(() => card.locator(".session-hovercard__participants").textContent())
.toContain("with Mira");
expect(await participant.textContent()).toBe("Mira");
expect(await participant.getAttribute("href")).toBe("/activity?person=profile-mira");
await identity.hover();
expect(
await identity.evaluate((element) => getComputedStyle(element).textDecorationLine),
).toBe("underline");
await captureProof(page, "hovercard-identity-link.png");
await participant.hover();
expect(
await participant.evaluate((element) => getComputedStyle(element).textDecorationLine),
).toBe("underline");
await captureProof(page, "hovercard-participant-link.png");
// The decorative avatar link stays out of the tab order; the name link is the target.
await trigger.focus();
await page.keyboard.press("Tab");
expect(await identity.evaluate((element) => document.activeElement === element)).toBe(true);
await identity.click();
await waitForControlUiRoute(page, { pathname: "/activity", routeId: "activity" });
expect(new URL(page.url()).searchParams.get("person")).toBe("profile-ada");
await expect.poll(() => card.count()).toBe(0);
const activityPage = page.locator("openclaw-activity-page");
await expect
.poll(() => activityPage.locator('[data-activity-identity="profile-ada"]').count())
.toBe(1);
await expect
.poll(() => activityPage.locator(`[data-activity-session="${sessionKey}"]`).count())
.toBe(1);
await captureProof(page, "hovercard-identity-activity.png");
await page.goBack();
await row.waitFor({ state: "visible" });
await row.hover();
await card.waitFor({ state: "visible" });
await participant.click();
await waitForControlUiRoute(page, { pathname: "/activity", routeId: "activity" });
expect(new URL(page.url()).searchParams.get("person")).toBe("profile-mira");
await expect
.poll(() => activityPage.locator('[data-activity-identity="profile-mira"]').count())
.toBe(1);
await captureProof(page, "hovercard-participant-activity.png");
},
);
});
});
+3 -2
View File
@@ -1,4 +1,5 @@
import type { GatewaySessionRow } from "../../api/types.ts";
import { ACTIVITY_PERSON_PARAM } from "../../app-route-paths.ts";
import {
presenceViewerLabel,
projectPresencePayload,
@@ -47,7 +48,7 @@ export function parseSessionActivityFilters(search: string): SessionActivityFilt
const params = new URLSearchParams(search);
const rawTime = params.get("time");
return {
personId: normalized(params.get("person")) ?? null,
personId: normalized(params.get(ACTIVITY_PERSON_PARAM)) ?? null,
query: params.get("q")?.trim() ?? "",
time: isActivityTimeFilter(rawTime) ? rawTime : DEFAULT_ACTIVITY_TIME_FILTER,
};
@@ -59,7 +60,7 @@ export function sessionActivitySearch(filters: SessionActivityFilters): string {
params.set("time", filters.time);
}
if (filters.personId) {
params.set("person", filters.personId);
params.set(ACTIVITY_PERSON_PARAM, filters.personId);
}
if (filters.query) {
params.set("q", filters.query);
+16
View File
@@ -174,6 +174,22 @@
font-weight: 500;
}
.session-hovercard__identity-avatar-link {
display: block;
min-width: 0;
line-height: 0;
}
.session-hovercard__identity-link {
color: inherit;
text-decoration: none;
}
.session-hovercard__identity-link:hover,
.session-hovercard__identity-link:focus-visible {
text-decoration: underline;
}
.session-hovercard__identity-separator {
flex: none;
color: var(--muted);