mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix(ui): show real avatars in session hovercards (#126372)
* fix(ui): show channel avatars in session hovercards * fix(ui): preserve hovercard avatar fallback
This commit is contained in:
committed by
GitHub
parent
1d52299bca
commit
1410ffcd23
@@ -42,17 +42,19 @@ describe("renderSessionHovercard", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders synchronous row metadata without inventing a progress section", () => {
|
||||
it("renders initials and synchronous row metadata without inventing a progress section", () => {
|
||||
const container = document.createElement("div");
|
||||
render(renderSessionHovercard({ row: row() }), container);
|
||||
|
||||
expect(container.querySelector(".session-hovercard__title")?.textContent).toBe(
|
||||
"Ship the release",
|
||||
);
|
||||
expect(container.querySelector(".session-hovercard__avatar")?.textContent).toBe("AB");
|
||||
expect(container.querySelector("span.session-hovercard__avatar")?.textContent).toBe("AB");
|
||||
expect(container.querySelector("openclaw-channel-avatar")).toBeNull();
|
||||
const metadata = container.querySelector(".session-hovercard__meta")?.textContent ?? "";
|
||||
expect(metadata).toContain("Alice Baker");
|
||||
expect(metadata).toContain("created");
|
||||
@@ -62,6 +64,57 @@ describe("renderSessionHovercard", () => {
|
||||
expect(container.querySelector(".session-hovercard__divider")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the channel avatar with gateway auth instead of an initials span", () => {
|
||||
const container = document.createElement("div");
|
||||
const channelAvatarUrl = "/__openclaw__/channel-avatar/agent%3Amain%3Awork";
|
||||
render(
|
||||
renderSessionHovercard({
|
||||
row: row({ channelAvatarUrl }),
|
||||
channelAvatarAuth: {
|
||||
authTokens: ["device-token", "saved-token"],
|
||||
authReady: true,
|
||||
},
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const avatar = container.querySelector<
|
||||
HTMLElement & {
|
||||
routeUrl: string;
|
||||
authTokens: readonly string[];
|
||||
authReady: boolean;
|
||||
}
|
||||
>("openclaw-channel-avatar");
|
||||
expect(avatar).not.toBeNull();
|
||||
expect(avatar?.routeUrl).toBe(channelAvatarUrl);
|
||||
expect(avatar?.authTokens).toEqual(["device-token", "saved-token"]);
|
||||
expect(avatar?.authReady).toBe(true);
|
||||
expect(container.querySelector("span.session-hovercard__avatar")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps initials visible inside the channel avatar while auth is unavailable", async () => {
|
||||
const container = document.body.appendChild(document.createElement("div"));
|
||||
render(
|
||||
renderSessionHovercard({
|
||||
row: row({ channelAvatarUrl: "/__openclaw__/channel-avatar/pending" }),
|
||||
channelAvatarAuth: { authTokens: [], authReady: false },
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
await customElements.whenDefined("openclaw-channel-avatar");
|
||||
const avatar = container.querySelector<HTMLElement & { updateComplete: Promise<boolean> }>(
|
||||
"openclaw-channel-avatar",
|
||||
);
|
||||
await avatar?.updateComplete;
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(avatar?.querySelector(".session-hovercard__avatar-fallback")?.textContent).toBe("AB");
|
||||
});
|
||||
expect(avatar?.querySelector("img.channel-avatar")).toBeNull();
|
||||
expect(container.querySelector("span.session-hovercard__avatar")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders bounded linked PR chips with state, CI, and diff facts", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
|
||||
@@ -12,6 +12,16 @@ import { renderSessionProgressCard } from "./session-progress-card.ts";
|
||||
|
||||
const MAX_VISIBLE_PULL_REQUESTS = 3;
|
||||
|
||||
type SessionHovercardAvatarAuth = {
|
||||
authTokens: readonly string[];
|
||||
authReady: boolean;
|
||||
};
|
||||
|
||||
let channelAvatarElementLoad: Promise<unknown> | undefined;
|
||||
function ensureChannelAvatarElement(): void {
|
||||
channelAvatarElementLoad ??= import("./channel-avatar.ts");
|
||||
}
|
||||
|
||||
function pullRequestStateLabel(state: ControlUiSessionPullRequest["state"]): string {
|
||||
return t(`sessionHovercard.states.${state}`);
|
||||
}
|
||||
@@ -59,13 +69,19 @@ function renderDiffStats(item: { additions?: number; deletions?: number; changed
|
||||
</span>`;
|
||||
}
|
||||
|
||||
function renderHeader(row: SidebarRecentSession | undefined) {
|
||||
function renderHeader(
|
||||
row: SidebarRecentSession | undefined,
|
||||
channelAvatarAuth: SessionHovercardAvatarAuth | undefined,
|
||||
) {
|
||||
if (!row) {
|
||||
return nothing;
|
||||
}
|
||||
const owner = row.owner?.actor ?? row.createdActor;
|
||||
const ownerLabel = owner?.label?.trim() || owner?.id?.trim();
|
||||
const initials = owner ? sessionOwnerInitials(owner) : "";
|
||||
const avatarFallback = initials
|
||||
? html`<span class="session-hovercard__avatar-fallback" aria-hidden="true">${initials}</span>`
|
||||
: nothing;
|
||||
const created = formatRelativeTimestamp(row.startedAt, { fallback: "" });
|
||||
const updated = formatRelativeTimestamp(row.updatedAt, { fallback: "" });
|
||||
const metadata = [
|
||||
@@ -73,10 +89,21 @@ function renderHeader(row: SidebarRecentSession | undefined) {
|
||||
created ? t("sessionHovercard.created", { time: created }) : undefined,
|
||||
updated ? t("sessionHovercard.updated", { time: updated }) : undefined,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
if (row.channelAvatarUrl) {
|
||||
ensureChannelAvatarElement();
|
||||
}
|
||||
return html`<header class="session-hovercard__header">
|
||||
${initials
|
||||
? html`<span class="session-hovercard__avatar" aria-hidden="true">${initials}</span>`
|
||||
: nothing}
|
||||
${row.channelAvatarUrl
|
||||
? html`<openclaw-channel-avatar
|
||||
class="session-hovercard__avatar"
|
||||
.routeUrl=${row.channelAvatarUrl}
|
||||
.authTokens=${channelAvatarAuth?.authTokens ?? []}
|
||||
.authReady=${channelAvatarAuth?.authReady ?? false}
|
||||
.fallback=${avatarFallback}
|
||||
></openclaw-channel-avatar>`
|
||||
: initials
|
||||
? html`<span class="session-hovercard__avatar" aria-hidden="true">${initials}</span>`
|
||||
: nothing}
|
||||
<span class="session-hovercard__heading">
|
||||
<span class="session-hovercard__title">${row.label}</span>
|
||||
${metadata.length > 0
|
||||
@@ -155,6 +182,7 @@ function renderPullRequestDetails(snapshot: ControlUiSessionPullRequestSnapshot
|
||||
|
||||
export function renderSessionHovercard(input: {
|
||||
row?: SidebarRecentSession;
|
||||
channelAvatarAuth?: SessionHovercardAvatarAuth;
|
||||
pullRequests?: ControlUiSessionPullRequestSnapshot;
|
||||
progressCard?: ProgressCard | null;
|
||||
}) {
|
||||
@@ -168,7 +196,8 @@ export function renderSessionHovercard(input: {
|
||||
return nothing;
|
||||
}
|
||||
return html`<div class="session-hovercard">
|
||||
${renderHeader(input.row)} ${renderPullRequestDetails(input.pullRequests)}
|
||||
${renderHeader(input.row, input.channelAvatarAuth)}
|
||||
${renderPullRequestDetails(input.pullRequests)}
|
||||
${input.progressCard
|
||||
? html`<div class="session-hovercard__divider" role="presentation"></div>
|
||||
${renderSessionProgressCard(input.progressCard, "hovercard")}`
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ProgressCard } from "@openclaw/gateway-protocol";
|
||||
import { ReactiveElement, render } from "lit";
|
||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||
import type { ApplicationContext } from "../app/context.ts";
|
||||
import { resolveControlUiAuthCandidates } from "../app/control-ui-auth.ts";
|
||||
import type { ApplicationGateway } from "../app/gateway.ts";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import {
|
||||
@@ -293,6 +294,22 @@ export class SessionProgressHovercardProvider extends ReactiveElement {
|
||||
if (currentProgressCard !== undefined) {
|
||||
this.lastProgressCard = currentProgressCard;
|
||||
}
|
||||
const gateway = this.applicationGateway;
|
||||
const channelAvatarAuth = {
|
||||
authTokens: gateway
|
||||
? resolveControlUiAuthCandidates({
|
||||
hello: gateway.snapshot.hello,
|
||||
settings: { token: gateway.connection.token },
|
||||
password: gateway.connection.password,
|
||||
})
|
||||
: [],
|
||||
authReady: Boolean(
|
||||
gateway &&
|
||||
(gateway.snapshot.hello ||
|
||||
gateway.connection.token.trim() ||
|
||||
gateway.connection.password.trim()),
|
||||
),
|
||||
};
|
||||
const revision = JSON.stringify({
|
||||
progress: this.lastProgressCard?.revision ?? null,
|
||||
pullRequests: pullRequests
|
||||
@@ -300,6 +317,7 @@ export class SessionProgressHovercardProvider extends ReactiveElement {
|
||||
: null,
|
||||
row: sidebarRow
|
||||
? {
|
||||
channelAvatarUrl: sidebarRow.channelAvatarUrl,
|
||||
label: sidebarRow.label,
|
||||
lastMessagePreview: sidebarRow.lastMessagePreview,
|
||||
owner: sidebarRow.owner?.actor ?? sidebarRow.createdActor,
|
||||
@@ -321,6 +339,7 @@ export class SessionProgressHovercardProvider extends ReactiveElement {
|
||||
render(
|
||||
renderSessionHovercard({
|
||||
row: sidebarRow,
|
||||
channelAvatarAuth,
|
||||
pullRequests,
|
||||
progressCard: this.lastProgressCard,
|
||||
}),
|
||||
|
||||
@@ -83,6 +83,7 @@ suite.define(() => {
|
||||
const now = Date.now();
|
||||
const selectedSessionKey = "agent:main:selected";
|
||||
const sessionKey = "agent:main:other-session";
|
||||
const channelAvatarUrl = `/__openclaw__/channel-avatar/${encodeURIComponent(sessionKey)}`;
|
||||
const initialMarkdown = [
|
||||
"**Building** phase 2",
|
||||
"",
|
||||
@@ -105,6 +106,16 @@ suite.define(() => {
|
||||
viewport: { height: 900, width: 1280 },
|
||||
},
|
||||
async ({ page }) => {
|
||||
await page.route(`**${channelAvatarUrl}`, async (route) => {
|
||||
expect(await route.request().headerValue("authorization")).toBe(
|
||||
"Bearer e2e-device-token",
|
||||
);
|
||||
await route.fulfill({
|
||||
body: `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><rect width="64" height="64" rx="32" fill="#b84cff"/><circle cx="24" cy="27" r="5" fill="white"/><circle cx="43" cy="27" r="5" fill="white"/><path d="M19 43c8 6 19 6 27 0" fill="none" stroke="white" stroke-width="4" stroke-linecap="round"/></svg>`,
|
||||
contentType: "image/svg+xml",
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: [
|
||||
"chat.metadata",
|
||||
@@ -158,6 +169,7 @@ suite.define(() => {
|
||||
kind: "direct",
|
||||
label: "Other session",
|
||||
displayName: "Other session",
|
||||
channelAvatarUrl,
|
||||
startedAt: now - 2 * 60 * 60_000,
|
||||
updatedAt: now - 15 * 60_000,
|
||||
},
|
||||
@@ -184,6 +196,17 @@ suite.define(() => {
|
||||
await expect
|
||||
.poll(() => card.locator(".session-hovercard__meta").textContent())
|
||||
.toContain("Ada King");
|
||||
const avatar = card.locator("openclaw-channel-avatar.session-hovercard__avatar");
|
||||
await avatar.waitFor({ state: "visible" });
|
||||
await expect.poll(() => avatar.locator("img.channel-avatar").count()).toBe(1);
|
||||
expect(
|
||||
await avatar.locator("img.channel-avatar").evaluate((image: HTMLImageElement) => ({
|
||||
complete: image.complete,
|
||||
naturalHeight: image.naturalHeight,
|
||||
naturalWidth: image.naturalWidth,
|
||||
})),
|
||||
).toEqual({ complete: true, naturalHeight: 64, naturalWidth: 64 });
|
||||
expect(await card.locator("span.session-hovercard__avatar").count()).toBe(0);
|
||||
const pullRequest = card.locator(".session-hovercard__pr-chip");
|
||||
await expect
|
||||
.poll(() => pullRequest.locator(".session-hovercard__pr-number").textContent())
|
||||
@@ -222,6 +245,7 @@ suite.define(() => {
|
||||
.poll(() => card.locator(".session-progress-card__step--pending").textContent())
|
||||
.toContain("Publish");
|
||||
expect(await page.evaluate(() => "__progressCardPwned" in window)).toBe(false);
|
||||
await captureProof(page, "sidebar-row-hovercard-avatar.png");
|
||||
await captureProof(page, "sidebar-row-hovercard-progress.png");
|
||||
|
||||
const link = page.locator(
|
||||
@@ -400,6 +424,7 @@ suite.define(() => {
|
||||
it("shows the latest turn when the session has no progress card", async () => {
|
||||
const now = Date.now();
|
||||
const sessionKey = "agent:main:no-progress-card";
|
||||
const channelAvatarUrl = `/__openclaw__/channel-avatar/${encodeURIComponent(sessionKey)}`;
|
||||
const lastMessagePreview =
|
||||
"The final release notes are ready for review, including <strong>plain text</strong>, rollout details, verification notes, compatibility guidance, and a concise operator checklist.";
|
||||
|
||||
@@ -411,6 +436,12 @@ suite.define(() => {
|
||||
viewport: { height: 900, width: 1280 },
|
||||
},
|
||||
async ({ page }) => {
|
||||
await page.route(`**${channelAvatarUrl}`, async (route) => {
|
||||
expect(await route.request().headerValue("authorization")).toBe(
|
||||
"Bearer e2e-device-token",
|
||||
);
|
||||
await route.fulfill({ status: 404 });
|
||||
});
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: ["chat.metadata", "chat.startup", "progressCard.get"],
|
||||
historyMessages: [
|
||||
@@ -424,6 +455,8 @@ suite.define(() => {
|
||||
"progressCard.get": { card: null },
|
||||
"sessions.list": chatSessionListResponse([
|
||||
{
|
||||
channelAvatarUrl,
|
||||
createdActor: { type: "human", id: "profile-ada", label: "Ada King" },
|
||||
key: sessionKey,
|
||||
kind: "direct",
|
||||
label: "No progress card",
|
||||
@@ -446,6 +479,12 @@ suite.define(() => {
|
||||
const card = page.locator(".session-progress-hovercard");
|
||||
await card.waitFor({ state: "visible" });
|
||||
expect(["left", "right"]).toContain(await card.getAttribute("data-side"));
|
||||
const avatar = card.locator("openclaw-channel-avatar.session-hovercard__avatar");
|
||||
await expect
|
||||
.poll(() => avatar.locator(".session-hovercard__avatar-fallback").textContent())
|
||||
.toBe("AK");
|
||||
expect(await avatar.locator("img.channel-avatar").count()).toBe(0);
|
||||
expect(await card.locator("span.session-hovercard__avatar").count()).toBe(0);
|
||||
await expect
|
||||
.poll(() => card.locator(".session-hovercard__excerpt").textContent())
|
||||
.toBe(lastMessagePreview);
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-hovercard__avatar {
|
||||
.session-hovercard__avatar,
|
||||
.session-hovercard__avatar-fallback {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
@@ -31,6 +32,14 @@
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.session-hovercard__avatar > .channel-avatar {
|
||||
box-sizing: border-box;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border));
|
||||
flex: 0 0 34px;
|
||||
}
|
||||
|
||||
.session-hovercard__heading {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
|
||||
Reference in New Issue
Block a user