mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(ui): preserve emoji agent avatar initials (#104912)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user