diff --git a/ui/src/pages/chat/chat-avatar.test.ts b/ui/src/pages/chat/chat-avatar.test.ts index c4d0c3ff3b88..899fe3c8ff6c 100644 --- a/ui/src/pages/chat/chat-avatar.test.ts +++ b/ui/src/pages/chat/chat-avatar.test.ts @@ -86,6 +86,22 @@ describe("renderChatAvatar", () => { expect(textAvatar?.tagName).toBe("DIV"); expect(textAvatar?.textContent?.trim()).toBe("AB"); }); + + it("swaps a failing local user image to initials instead of a broken image", () => { + const container = document.createElement("div"); + render( + renderChatAvatar("user", undefined, { name: "Buns", avatar: "/avatar/user" }), + container, + ); + const slot = container.querySelector(".chat-avatar-slot"); + const image = slot?.querySelector("img"); + expect(image?.getAttribute("src")).toBe("/avatar/user"); + expect(slot?.classList.contains("is-fallback")).toBe(false); + + image?.dispatchEvent(new Event("error")); + expect(slot?.classList.contains("is-fallback")).toBe(true); + expect(slot?.querySelector(".chat-avatar--sender-initials")?.textContent?.trim()).toBe("B"); + }); }); describe("refreshChatAvatar", () => { diff --git a/ui/src/pages/chat/chat-avatar.ts b/ui/src/pages/chat/chat-avatar.ts index 8f1dcc6ed4be..4c7e4fe7b4c0 100644 --- a/ui/src/pages/chat/chat-avatar.ts +++ b/ui/src/pages/chat/chat-avatar.ts @@ -12,6 +12,7 @@ import { identityAvatarClass, renderIdentityAvatarImage, resolveIdentityAvatarView, + type IdentityAvatarView, } from "../../components/identity-avatar-view.ts"; import type { AssistantIdentity } from "../../lib/assistant-identity.ts"; import { @@ -22,6 +23,7 @@ import { import { normalizeRoleForGrouping } from "../../lib/chat/message-normalizer.ts"; import type { SenderIdentity } from "../../lib/chat/sender-label.ts"; import { formatSenderLabel } from "../../lib/chat/sender-label.ts"; +import { resolveAvatarInitials } from "../../lib/identity-avatar.ts"; import { DEFAULT_AGENT_ID, isUiGlobalSessionKey, @@ -41,29 +43,7 @@ export function renderChatAvatar( // Attributed multi-user messages show the author's own avatar (profile // upload → gateway Gravatar proxy → initials), not the local viewer's. if (normalized === "user" && sender) { - const label = formatSenderLabel(sender) ?? ""; - const view = resolveIdentityAvatarView(sender); - const initialsAvatar = html`
- ${view.fallback.initials} -
`; - if (!view.imageUrl) { - return initialsAvatar; - } - // The derived route may 404 (no upload, no Gravatar); swap to initials - // instead of a broken image. Lit reuses DOM parts, so a load must clear a - // prior sender's error state. - return html` - ${renderIdentityAvatarImage({ - view, - fallbackSelector: ".chat-avatar-slot", - className: "chat-avatar user", - alt: label, - })}${initialsAvatar} - `; + return renderUserAvatarSlot(resolveIdentityAvatarView(sender), formatSenderLabel(sender) ?? ""); } const assistantName = assistant?.name?.trim() || "Assistant"; const assistantAvatar = assistant?.avatar?.trim() || ""; @@ -119,7 +99,14 @@ export function renderChatAvatar( : "other"; if (normalized === "user" && userAvatarUrl) { - return html`${userName}`; + return renderUserAvatarSlot( + { + fallback: resolveAvatarInitials({ name: userName }), + imageUrl: userAvatarUrl, + pending: false, + }, + userName, + ); } if (normalized === "user" && userAvatarText) { @@ -166,6 +153,32 @@ export function renderChatAvatar( return html`
${initial}
`; } +/** + * The avatar URL may 404 or be unreachable (missing upload, dead Gravatar, + * stale configured URL); swap to initials instead of a broken image. Lit + * reuses DOM parts, so a load must clear a prior identity's error state. + */ +function renderUserAvatarSlot(view: IdentityAvatarView, label: string) { + const initialsAvatar = html`
+ ${view.fallback.initials} +
`; + if (!view.imageUrl) { + return initialsAvatar; + } + return html` + ${renderIdentityAvatarImage({ + view, + fallbackSelector: ".chat-avatar-slot", + className: "chat-avatar user", + alt: label, + })}${initialsAvatar} + `; +} + function isAvatarUrl(value: string): boolean { const trimmed = value.trim(); return trimmed.startsWith("blob:") || isRenderableControlUiAvatarUrl(trimmed); diff --git a/ui/src/pages/custodian/custodian-surface.ts b/ui/src/pages/custodian/custodian-surface.ts index 17f8e052753b..57fc18159043 100644 --- a/ui/src/pages/custodian/custodian-surface.ts +++ b/ui/src/pages/custodian/custodian-surface.ts @@ -177,7 +177,7 @@ class CustodianSurface extends OpenClawLightDomElement { ${store.sending ? html`
diff --git a/ui/src/styles/settings.css b/ui/src/styles/settings.css index ee8663ca0f48..a10da0a136b9 100644 --- a/ui/src/styles/settings.css +++ b/ui/src/styles/settings.css @@ -160,6 +160,13 @@ cursor: pointer; } +.settings-section__help-button > span { + /* SF's ascent+descent exceed 1em, so a centered line box parks the glyph + below the circle's midline; trim to cap bounds for true centering. */ + display: block; + text-box: trim-both cap alphabetic; +} + .settings-section__help-button:hover, .settings-section__help-button:focus-visible { border-color: color-mix(in srgb, var(--accent) 55%, var(--border));