mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
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:
committed by
GitHub
parent
7c7e10ab85
commit
04eae7cea1
@@ -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.");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user