From 86f8c826e20a01d1bba67c0ba6e73182fbf0455f Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:50:27 -0500 Subject: [PATCH] fix(ui): render assistant identity avatars in chat Render assistant text avatars from IDENTITY.md consistently in the Control UI chat welcome state and transcript groups. Also supports authenticated blob avatar URLs in grouped messages and rejects bidi/invisible controls in assistant text avatars. Verification: - pnpm test ui/src/ui/chat/grouped-render.test.ts ui/src/ui/views/chat.test.ts ui/src/styles/chat/layout.test.ts - pnpm check:changed - GitHub CI green - Review threads resolved --- ui/src/styles/chat/layout.css | 13 ++++++++ ui/src/styles/chat/layout.test.ts | 26 +++++++++++---- ui/src/ui/chat/grouped-render.test.ts | 47 +++++++++++++++++++++++++++ ui/src/ui/chat/grouped-render.ts | 32 ++++++++++++++++-- ui/src/ui/views/chat.test.ts | 30 +++++++++++++++++ ui/src/ui/views/chat.ts | 44 ++++++++++++++++--------- 6 files changed, 167 insertions(+), 25 deletions(-) diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 50e511937d5..ca4e4230e13 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -1115,6 +1115,19 @@ overflow: hidden; } +.agent-chat__avatar--text { + width: 56px; + height: 56px; + border-radius: var(--radius-lg); + background: var(--secondary); + border: 1px solid var(--border); + color: var(--foreground); + display: grid; + place-items: center; + font-size: 20px; + font-weight: 700; +} + .agent-chat__avatar--logo img { width: 32px; height: 32px; diff --git a/ui/src/styles/chat/layout.test.ts b/ui/src/styles/chat/layout.test.ts index bfbeb7b9c93..9c0a001651c 100644 --- a/ui/src/styles/chat/layout.test.ts +++ b/ui/src/styles/chat/layout.test.ts @@ -2,18 +2,30 @@ import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; -describe("chat steer styles", () => { +function readLayoutCss(): string { + const cssPath = [ + resolve(process.cwd(), "src/styles/chat/layout.css"), + resolve(process.cwd(), "ui/src/styles/chat/layout.css"), + ].find((candidate) => existsSync(candidate)); + expect(cssPath).toBeTruthy(); + return readFileSync(cssPath!, "utf8"); +} + +describe("chat layout styles", () => { it("styles queued-message steering controls and pending indicators", () => { - const cssPath = [ - resolve(process.cwd(), "src/styles/chat/layout.css"), - resolve(process.cwd(), "ui/src/styles/chat/layout.css"), - ].find((candidate) => existsSync(candidate)); - expect(cssPath).toBeTruthy(); - const css = readFileSync(cssPath!, "utf8"); + const css = readLayoutCss(); expect(css).toContain(".chat-queue__steer"); expect(css).toContain(".chat-queue__actions"); expect(css).toContain(".chat-queue__item--steered"); expect(css).toContain(".chat-queue__badge"); }); + + it("includes assistant text avatar styles for configured IDENTITY avatars", () => { + const css = readLayoutCss(); + + expect(css).toContain(".agent-chat__avatar--text"); + expect(css).toContain("font-size: 20px;"); + expect(css).toContain("place-items: center;"); + }); }); diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index 0c4ea05696e..62be0cd51f0 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -6,6 +6,7 @@ import { getSafeLocalStorage } from "../../local-storage.ts"; import type { MessageGroup } from "../types/chat-types.ts"; import { renderMessageGroup, + resolveAssistantTextAvatar, resetAssistantAttachmentAvailabilityCacheForTest, } from "./grouped-render.ts"; import { normalizeMessage } from "./message-normalizer.ts"; @@ -260,6 +261,52 @@ describe("grouped chat rendering", () => { expect(avatar?.getAttribute("src")).toBe("/openclaw-logo.svg"); }); + it("renders a blob: assistant avatar as an image", () => { + const container = document.createElement("div"); + + renderAssistantMessage( + container, + { + role: "assistant", + content: "hello", + timestamp: 1000, + }, + { assistantAvatar: "blob:managed-image", assistantName: "Val" }, + ); + + const avatar = container.querySelector(".chat-avatar.assistant"); + expect(avatar).not.toBeNull(); + expect(avatar?.tagName).toBe("IMG"); + expect(avatar?.getAttribute("src")).toBe("blob:managed-image"); + }); + + it("renders a configured assistant text avatar", () => { + const container = document.createElement("div"); + + renderAssistantMessage( + container, + { + role: "assistant", + content: "hello", + timestamp: 1000, + }, + { assistantAvatar: "VC", assistantName: "Val" }, + ); + + const avatar = container.querySelector(".chat-avatar.assistant"); + expect(avatar).not.toBeNull(); + expect(avatar?.tagName).toBe("DIV"); + expect(avatar?.textContent).toContain("VC"); + expect(avatar?.getAttribute("aria-label")).toBe("Val"); + }); + + it("rejects unsafe invisible controls in assistant text avatars", () => { + expect(resolveAssistantTextAvatar("VC")).toBe("VC"); + expect(resolveAssistantTextAvatar("\u{1F43E}")).toBe("\u{1F43E}"); + expect(resolveAssistantTextAvatar("V\u202eC")).toBeNull(); + expect(resolveAssistantTextAvatar("V\u200bC")).toBeNull(); + }); + it("includes cache tokens when rendering assistant context usage", () => { const container = document.createElement("div"); diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 6a04e3f5ec2..cc89d7f61e5 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -2,7 +2,7 @@ import { html, nothing } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { until } from "lit/directives/until.js"; import { getSafeLocalStorage } from "../../local-storage.ts"; -import type { AssistantIdentity } from "../assistant-identity.ts"; +import { DEFAULT_ASSISTANT_AVATAR, type AssistantIdentity } from "../assistant-identity.ts"; import type { EmbedSandboxMode } from "../embed-sandbox.ts"; import { icons } from "../icons.ts"; import { toSanitizedMarkdownHtml } from "../markdown.ts"; @@ -638,6 +638,7 @@ function renderAvatar( const normalized = normalizeRoleForGrouping(role); const assistantName = assistant?.name?.trim() || "Assistant"; const assistantAvatar = assistant?.avatar?.trim() || ""; + const assistantAvatarText = resolveAssistantTextAvatar(assistantAvatar); const userName = resolveLocalUserName(user); const userAvatarUrl = resolveLocalUserAvatarUrl(user); const userAvatarText = resolveLocalUserAvatarText(user); @@ -712,6 +713,11 @@ function renderAvatar( alt="${assistantName}" />`; } + if (assistantAvatarText) { + return html`
+ ${assistantAvatarText} +
`; + } return html` 8 || + /\s/.test(trimmed) || + /[\\/.:]/.test(trimmed) || + UNSAFE_ASSISTANT_TEXT_AVATAR_CHARS.test(trimmed) + ) { + return null; + } + return trimmed; } function resolveRenderableMessageImages( diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 938246be3ae..a1acbaf183d 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -105,3 +105,33 @@ describe("chat view queue steering", () => { expect(container.querySelector(".chat-queue__steer")).toBeNull(); }); }); + +describe("renderChat", () => { + afterEach(() => { + cleanupChatModuleState(); + }); + + it("renders configured assistant text avatars in transcript groups", () => { + const container = document.createElement("div"); + + render( + renderChat( + createProps({ + assistantName: "Val", + assistantAvatar: "VC", + assistantAvatarUrl: null, + messages: [{ role: "assistant", content: "hello", timestamp: 1000 }], + stream: null, + streamStartedAt: null, + }), + ), + container, + ); + + const avatar = container.querySelector(".chat-group.assistant .chat-avatar"); + expect(avatar).not.toBeNull(); + expect(avatar?.tagName).toBe("DIV"); + expect(avatar?.textContent).toContain("VC"); + expect(avatar?.getAttribute("aria-label")).toBe("Val"); + }); +}); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index ae8621787f3..3be87867d90 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -14,6 +14,7 @@ import { renderMessageGroup, renderReadingIndicatorGroup, renderStreamingGroup, + resolveAssistantTextAvatar, } from "../chat/grouped-render.ts"; import { InputHistory } from "../chat/input-history.ts"; import { PinnedMessages } from "../chat/pinned-messages.ts"; @@ -475,12 +476,8 @@ const WELCOME_SUGGESTIONS = [ function renderWelcomeState(props: ChatProps): TemplateResult { const name = props.assistantName || "Assistant"; - const avatar = resolveChatAvatarRenderUrl(props.assistantAvatarUrl, { - identity: { - avatar: props.assistantAvatar ?? undefined, - avatarUrl: props.assistantAvatarUrl ?? undefined, - }, - }); + const avatar = resolveAssistantAvatarUrl(props); + const avatarText = avatar ? null : resolveAssistantTextAvatar(props.assistantAvatar); const logoUrl = agentLogoUrl(props.basePath ?? ""); return html` @@ -492,9 +489,13 @@ function renderWelcomeState(props: ChatProps): TemplateResult { alt=${name} style="width:56px; height:56px; border-radius:50%; object-fit:cover;" />` - : html``} + : avatarText + ? html`
+ ${avatarText} +
` + : html``}

${name}

Ready to chat @@ -520,6 +521,23 @@ function renderWelcomeState(props: ChatProps): TemplateResult { `; } +function resolveAssistantAvatarUrl( + props: Pick, +): string | null { + return resolveChatAvatarRenderUrl(props.assistantAvatarUrl, { + identity: { + avatar: props.assistantAvatar ?? undefined, + avatarUrl: props.assistantAvatarUrl ?? undefined, + }, + }); +} + +function resolveAssistantDisplayAvatar( + props: Pick, +): string | null { + return resolveAssistantAvatarUrl(props) ?? resolveAssistantTextAvatar(props.assistantAvatar); +} + function renderSearchBar(requestUpdate: () => void): TemplateResult | typeof nothing { if (!vs.searchOpen) { return nothing; @@ -755,13 +773,7 @@ export function renderChat(props: ChatProps) { const showReasoning = props.showThinking && reasoningLevel !== "off"; const assistantIdentity = { name: props.assistantName, - avatar: - resolveChatAvatarRenderUrl(props.assistantAvatarUrl, { - identity: { - avatar: props.assistantAvatar ?? undefined, - avatarUrl: props.assistantAvatarUrl ?? undefined, - }, - }) ?? null, + avatar: resolveAssistantDisplayAvatar(props), }; const pinned = getPinnedMessages(props.sessionKey); const deleted = getDeletedMessages(props.sessionKey);