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:
Peter Steinberger
2026-08-19 10:54:56 -07:00
committed by GitHub
parent 1d52299bca
commit 1410ffcd23
5 changed files with 157 additions and 8 deletions
+55 -2
View File
@@ -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(
+34 -5
View File
@@ -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);
+10 -1
View File
@@ -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;