fix(ui): preserve emoji agent avatar initials (#104912)

This commit is contained in:
thomas.szbay
2026-08-01 09:55:02 +08:00
committed by GitHub
parent ca915228d3
commit 5f91afabc7
7 changed files with 282 additions and 6 deletions
+16
View File
@@ -124,6 +124,22 @@ it("falls back to the uppercase agent initial", async () => {
}
});
it("preserves complete grapheme clusters in emoji avatar fallback", async () => {
const agent = { id: "family", name: "👨‍👩‍👧‍👦Family" };
const element = await createAgentSelect({
options: [{ value: agent.id, label: agent.name, agent }],
value: agent.id,
});
try {
expect(element.querySelector(".agent-select__avatar--text")?.getAttribute("data-avatar")).toBe(
"👨‍👩‍👧‍👦",
);
} finally {
element.remove();
}
});
it("fetches local avatars with the bearer credential when token auth is active", async () => {
const createObjectURL = vi.fn(() => "blob:agent-avatar");
const revokeObjectURL = vi.fn();
+2 -2
View File
@@ -7,7 +7,7 @@ import { ref } from "lit/directives/ref.js";
import type { AgentIdentityResult, GatewayAgentRow } from "../api/types.ts";
import { t } from "../i18n/index.ts";
import { resolveAgentTextAvatar } from "../lib/agents/display.ts";
import { resolveAgentAvatarUrl } from "../lib/avatar.ts";
import { deriveAvatarInitial, resolveAgentAvatarUrl } from "../lib/avatar.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import { icons } from "./icons.ts";
import { syncDropdownItemRadio } from "./web-awesome.ts";
@@ -46,7 +46,7 @@ export function renderAgentSelectAvatar(
>`;
}
const text = option.agent ? resolveAgentTextAvatar(option.agent, identity) : null;
const fallback = (option.label[0] ?? "?").toUpperCase();
const fallback = deriveAvatarInitial(option.label) || "?";
return html`
<span
class="agent-select__avatar agent-select__avatar--text"
+2 -2
View File
@@ -11,7 +11,7 @@ import { isNativeWebChromeHost } from "../app/native-web-chrome.ts";
import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts";
import { t } from "../i18n/index.ts";
import { normalizeAgentLabel, resolveAgentTextAvatar } from "../lib/agents/display.ts";
import { resolveAgentAvatarUrl } from "../lib/avatar.ts";
import { deriveAvatarInitial, resolveAgentAvatarUrl } from "../lib/avatar.ts";
import { sessionHasBoard } from "../lib/board/provider.ts";
import {
resolveSessionPreferredFace,
@@ -83,7 +83,7 @@ export function renderAppSidebarBrand(host: AppSidebarRenderHost) {
const approvalCount = host.sessionData.approvalBadgeSnapshot().agentCounts.get(cardAgentId) ?? 0;
const cardAvatarText =
(cardAgent ? resolveAgentTextAvatar(cardAgent, cardIdentity) : cardIdentity?.emoji) ??
(cardName || cardAgentId).slice(0, 1).toUpperCase();
(deriveAvatarInitial(cardName || cardAgentId) || "?");
// The sidebar action follows gateway availability; collapsed native chrome
// keeps its separate offline-tolerant ⌘N mirror.
return html`
+219
View File
@@ -0,0 +1,219 @@
// Control UI E2E: grapheme-aware avatar initials remain intact across every
// live agent-avatar fallback surface.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
canRunPlaywrightChromium,
installMockGateway,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
} from "../test-helpers/control-ui-e2e.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "avatar-initial-emoji");
const emojiAgent = { id: "emoji", identity: { name: "🚀Rocket" }, name: "🚀Rocket" };
const asciiAgent = { id: "main", identity: { name: "Main" }, name: "Main" };
const emojiGrapheme = "🚀";
const agentsList = {
defaultId: "main",
mainKey: "main",
scope: "agent",
agents: [asciiAgent, emojiAgent],
};
const agentIdentities = {
cases: [
{
match: { agentId: "emoji" },
response: { agentId: "emoji", avatar: "", avatarStatus: "none", name: "🚀Rocket" },
},
{
match: { agentId: "main" },
response: { agentId: "main", avatar: "", avatarStatus: "none", name: "Main" },
},
],
};
let browser: Browser;
let server: ControlUiE2eServer;
async function screenshot(page: Page, name: string) {
if (!captureUiProof) {
return;
}
await mkdir(proofDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,
path: path.join(proofDir, name),
});
}
describeControlUiE2e("Control UI grapheme-aware avatar initials", () => {
beforeAll(async () => {
if (!chromiumAvailable) {
throw new Error(`Playwright Chromium is not available at ${chromiumExecutablePath}`);
}
server = await startControlUiE2eServer();
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
});
afterAll(async () => {
await browser?.close();
await server?.close();
});
it("renders the emoji grapheme initial in the sidebar chip and agent menu row", async () => {
const context = await browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1440 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
defaultAgentId: "main",
methodResponses: {
"agent.identity.get": agentIdentities,
"agents.list": agentsList,
"chat.startup": {
agentsList,
messages: [],
metadata: { models: [] },
sessionId: "control-ui-e2e-session",
thinkingLevel: null,
},
"sessions.list": {
count: 0,
defaults: { contextTokens: null, model: null, modelProvider: null },
path: "",
sessions: [],
ts: Date.now(),
},
},
});
try {
const response = await page.goto(`${server.baseUrl}usage`);
expect(response?.status()).toBe(200);
await gateway.waitForRequest("agents.list");
const sidebar = page.locator("openclaw-app-sidebar");
await sidebar.getByRole("button", { name: /Switch agent/ }).click();
const emojiRow = sidebar
.locator("wa-dropdown.sidebar-agent-menu")
.getByRole("menuitemradio", { name: "🚀Rocket", exact: true });
const menuAvatar = emojiRow.locator(".agent-select__avatar--text");
await expect.poll(() => menuAvatar.getAttribute("data-avatar")).toBe(emojiGrapheme);
// The shared picker paints its text through CSS, not a text node.
await expect
.poll(() => menuAvatar.evaluate((element) => getComputedStyle(element, "::before").content))
.toContain(emojiGrapheme);
await screenshot(page, "01-sidebar-menu-emoji.png");
await emojiRow.click();
await expect
.poll(async () =>
(await gateway.getRequests("sessions.list")).some(
(request) =>
request.params && (request.params as { agentId?: string }).agentId === "emoji",
),
)
.toBe(true);
await expect
.poll(() => sidebar.locator(".sidebar-agent-card__avatar-text").textContent())
.toBe(emojiGrapheme);
await screenshot(page, "01-sidebar-chip-emoji.png");
} finally {
await context.close();
}
});
it("renders the emoji grapheme initial in the agent selector dropdown", async () => {
const context = await browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1440 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
defaultAgentId: "main",
methodResponses: {
"agent.identity.get": agentIdentities,
"agents.list": agentsList,
},
});
try {
const response = await page.goto(`${server.baseUrl}agents`);
expect(response?.status()).toBe(200);
await gateway.waitForRequest("agents.list");
const agentSelect = page.locator("openclaw-agents-page openclaw-agent-select");
await agentSelect.locator(".agent-select__trigger").click();
const emojiItem = agentSelect.getByRole("menuitemradio", {
name: "🚀Rocket",
exact: true,
});
const pickerAvatar = emojiItem.locator(".agent-select__avatar--text");
await expect.poll(() => pickerAvatar.getAttribute("data-avatar")).toBe(emojiGrapheme);
await expect
.poll(() =>
pickerAvatar.evaluate((element) => getComputedStyle(element, "::before").content),
)
.toContain(emojiGrapheme);
await screenshot(page, "02-agent-selector-emoji.png");
} finally {
await context.close();
}
});
it("renders the emoji grapheme initial in the agents overview identity editor", async () => {
const context = await browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1440 },
});
const page = await context.newPage();
const config = { agents: { list: [{ id: "main" }, { id: "emoji" }] } };
const gateway = await installMockGateway(page, {
defaultAgentId: "main",
methodResponses: {
"agent.identity.get": agentIdentities,
"agents.list": agentsList,
"config.get": {
config,
sourceConfig: config,
hash: "hash-1",
issues: [],
raw: JSON.stringify(config),
valid: true,
},
},
});
try {
const response = await page.goto(`${server.baseUrl}settings/agents/main/tools`);
expect(response?.status()).toBe(200);
await gateway.waitForRequest("agents.list");
await gateway.waitForRequest("config.get");
const agentSelect = page.locator("openclaw-agents-page openclaw-agent-select");
await agentSelect.locator(".agent-select__trigger").click();
await agentSelect.getByRole("menuitemradio", { name: "🚀Rocket", exact: true }).click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/agents/emoji/tools");
await page.getByRole("tab", { name: "Overview", exact: true }).click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/agents/emoji/overview");
await expect
.poll(() => page.locator(".agent-identity-editor__avatar-text").textContent())
.toBe(emojiGrapheme);
await screenshot(page, "03-agents-overview-emoji.png");
} finally {
await context.close();
}
});
});
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { deriveAvatarInitial } from "./avatar.ts";
describe("deriveAvatarInitial", () => {
it("returns the uppercased first grapheme for ASCII names", () => {
expect(deriveAvatarInitial("Alice")).toBe("A");
expect(deriveAvatarInitial("bob")).toBe("B");
});
it("keeps an emoji initial intact instead of a dangling surrogate half", () => {
expect(deriveAvatarInitial("😀Name")).toBe("😀");
expect(deriveAvatarInitial("🚀")).toBe("🚀");
});
it("preserves complete grapheme clusters for joined emoji and flags", () => {
expect(deriveAvatarInitial("👨‍👩‍👧‍👦Family")).toBe("👨‍👩‍👧‍👦");
expect(deriveAvatarInitial("🇺🇸Flag")).toBe("🇺🇸");
expect(deriveAvatarInitial("👍🏻Thumbs")).toBe("👍🏻");
});
it("preserves existing initial casing and whitespace policy", () => {
expect(deriveAvatarInitial("ßeta")).toBe("SS");
expect(deriveAvatarInitial(" leading")).toBe(" ");
});
it("returns an empty string for empty or missing input", () => {
expect(deriveAvatarInitial("")).toBe("");
expect(deriveAvatarInitial(null)).toBe("");
expect(deriveAvatarInitial(undefined)).toBe("");
});
});
+10
View File
@@ -2,6 +2,7 @@ import { isRenderableAvatarImageDataUrl } from "../../../src/shared/avatar-limit
import type { AgentIdentityResult } from "../api/types.ts";
import { controlUiPublicAssetPath } from "../app/public-assets.ts";
import { DEFAULT_ASSISTANT_AVATAR } from "./assistant-identity.ts";
import { takeGraphemes } from "./graphemes.ts";
import { normalizeOptionalString } from "./string-coerce.ts";
const CONTROL_UI_SAME_ORIGIN_AVATAR_URL_RE = /^\/(?!\/)/;
@@ -48,6 +49,15 @@ export function resolveChatAvatarRenderUrl(
return resolveAgentAvatarUrl(agent, agentIdentity);
}
export function deriveAvatarInitial(value: string | null | undefined): string {
const source = value ?? "";
if (!source) {
return "";
}
// Keep the whole leading grapheme so emoji names never expose a broken surrogate.
return takeGraphemes(source, 1).toUpperCase();
}
export function resolveAssistantTextAvatar(value: string | null | undefined): string | null {
const trimmed = value?.trim();
if (!trimmed || trimmed === DEFAULT_ASSISTANT_AVATAR) {
+2 -2
View File
@@ -23,7 +23,7 @@ import {
resolveModelPrimary,
} from "../../lib/agents/display.ts";
import type { AgentsPanel } from "../../lib/agents/index.ts";
import { resolveAgentAvatarUrl } from "../../lib/avatar.ts";
import { deriveAvatarInitial, resolveAgentAvatarUrl } from "../../lib/avatar.ts";
export type AgentIdentityDraft = {
name: string | null;
@@ -113,7 +113,7 @@ export function renderAgentOverview(params: {
const identityAvatarUrl =
identityDraft.avatar ?? resolveAgentAvatarUrl(agent, params.agentIdentity);
const identityAvatarText =
resolveAgentTextAvatar(agent) ?? (identityName || agent.id).slice(0, 1).toUpperCase();
resolveAgentTextAvatar(agent) ?? (deriveAvatarInitial(identityName || agent.id) || "?");
const identityDirty =
identityDraft.name !== null || identityDraft.emoji !== null || identityDraft.avatar !== null;
const identityInvalid =