From 1410ffcd23dc079d49ece5944fa47040d98716c9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 19 Aug 2026 10:54:56 -0700 Subject: [PATCH] fix(ui): show real avatars in session hovercards (#126372) * fix(ui): show channel avatars in session hovercards * fix(ui): preserve hovercard avatar fallback --- ui/src/components/session-hovercard.test.ts | 57 ++++++++++++++++++- ui/src/components/session-hovercard.ts | 39 +++++++++++-- .../session-progress-hovercard.runtime.ts | 19 +++++++ .../session-progress-hovercard.e2e.test.ts | 39 +++++++++++++ ui/src/styles/chat/progress-card.css | 11 +++- 5 files changed, 157 insertions(+), 8 deletions(-) diff --git a/ui/src/components/session-hovercard.test.ts b/ui/src/components/session-hovercard.test.ts index 547e0c47c5b5..11eddfa94e49 100644 --- a/ui/src/components/session-hovercard.test.ts +++ b/ui/src/components/session-hovercard.test.ts @@ -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 }>( + "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( diff --git a/ui/src/components/session-hovercard.ts b/ui/src/components/session-hovercard.ts index c6ac1dc8ae23..079eda2e4213 100644 --- a/ui/src/components/session-hovercard.ts +++ b/ui/src/components/session-hovercard.ts @@ -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 | 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 `; } -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`` + : 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`
- ${initials - ? html`` - : nothing} + ${row.channelAvatarUrl + ? html`` + : initials + ? html`` + : nothing} ${row.label} ${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`
- ${renderHeader(input.row)} ${renderPullRequestDetails(input.pullRequests)} + ${renderHeader(input.row, input.channelAvatarAuth)} + ${renderPullRequestDetails(input.pullRequests)} ${input.progressCard ? html` ${renderSessionProgressCard(input.progressCard, "hovercard")}` diff --git a/ui/src/components/session-progress-hovercard.runtime.ts b/ui/src/components/session-progress-hovercard.runtime.ts index 9e7313ccd6d2..03dcb108c27b 100644 --- a/ui/src/components/session-progress-hovercard.runtime.ts +++ b/ui/src/components/session-progress-hovercard.runtime.ts @@ -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, }), diff --git a/ui/src/e2e/session-progress-hovercard.e2e.test.ts b/ui/src/e2e/session-progress-hovercard.e2e.test.ts index b45bc4e504bc..cc6b1ddaa5aa 100644 --- a/ui/src/e2e/session-progress-hovercard.e2e.test.ts +++ b/ui/src/e2e/session-progress-hovercard.e2e.test.ts @@ -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: ``, + 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 plain text, 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); diff --git a/ui/src/styles/chat/progress-card.css b/ui/src/styles/chat/progress-card.css index 025ec041203b..bc1f2f8e5ac1 100644 --- a/ui/src/styles/chat/progress-card.css +++ b/ui/src/styles/chat/progress-card.css @@ -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;