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
This commit is contained in:
Val Alexander
2026-04-24 15:50:27 -05:00
committed by GitHub
parent af46830927
commit 86f8c826e2
6 changed files with 167 additions and 25 deletions

View File

@@ -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;

View File

@@ -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;");
});
});

View File

@@ -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<HTMLImageElement>(".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<HTMLElement>(".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");

View File

@@ -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`<div class="chat-avatar ${className}" aria-label="${assistantName}">
${assistantAvatarText}
</div>`;
}
return html`<img
class="chat-avatar ${className} chat-avatar--logo"
src="${agentLogoUrl(basePath ?? "")}"
@@ -733,7 +739,29 @@ function renderAvatar(
}
function isAvatarUrl(value: string): boolean {
return isRenderableControlUiAvatarUrl(value);
const trimmed = value.trim();
return trimmed.startsWith("blob:") || isRenderableControlUiAvatarUrl(trimmed);
}
const UNSAFE_ASSISTANT_TEXT_AVATAR_CHARS = /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u;
export function resolveAssistantTextAvatar(value: string | null | undefined): string | null {
const trimmed = value?.trim();
if (!trimmed || trimmed === DEFAULT_ASSISTANT_AVATAR) {
return null;
}
if (isAvatarUrl(trimmed)) {
return null;
}
if (
trimmed.length > 8 ||
/\s/.test(trimmed) ||
/[\\/.:]/.test(trimmed) ||
UNSAFE_ASSISTANT_TEXT_AVATAR_CHARS.test(trimmed)
) {
return null;
}
return trimmed;
}
function resolveRenderableMessageImages(

View File

@@ -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<HTMLElement>(".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");
});
});

View File

@@ -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`<div class="agent-chat__avatar agent-chat__avatar--logo">
<img src=${logoUrl} alt="OpenClaw" />
</div>`}
: avatarText
? html`<div class="agent-chat__avatar agent-chat__avatar--text" aria-label=${name}>
${avatarText}
</div>`
: html`<div class="agent-chat__avatar agent-chat__avatar--logo">
<img src=${logoUrl} alt="OpenClaw" />
</div>`}
<h2>${name}</h2>
<div class="agent-chat__badges">
<span class="agent-chat__badge"><img src=${logoUrl} alt="" /> Ready to chat</span>
@@ -520,6 +521,23 @@ function renderWelcomeState(props: ChatProps): TemplateResult {
`;
}
function resolveAssistantAvatarUrl(
props: Pick<ChatProps, "assistantAvatar" | "assistantAvatarUrl">,
): string | null {
return resolveChatAvatarRenderUrl(props.assistantAvatarUrl, {
identity: {
avatar: props.assistantAvatar ?? undefined,
avatarUrl: props.assistantAvatarUrl ?? undefined,
},
});
}
function resolveAssistantDisplayAvatar(
props: Pick<ChatProps, "assistantAvatar" | "assistantAvatarUrl">,
): 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);