Skip to content

Commit adf983f

Browse files
author
NIO
committed
fix(ui): keep agent avatar initials on grapheme clusters
1 parent ec01949 commit adf983f

7 files changed

Lines changed: 262 additions & 7 deletions

File tree

ui/src/components/app-sidebar-agent-menu.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import { titleForRoute, type NavigationRouteId } from "../app-navigation.ts";
66
import type { ApplicationNavigationOptions } from "../app/context.ts";
77
import type { ThemeMode } from "../app/theme.ts";
88
import { t } from "../i18n/index.ts";
9-
import { normalizeAgentLabel, resolveAgentTextAvatar } from "../lib/agents/display.ts";
9+
import {
10+
normalizeAgentLabel,
11+
resolveAgentTextAvatar,
12+
resolveFallbackAvatarInitial,
13+
} from "../lib/agents/display.ts";
1014
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../lib/external-link.ts";
1115
import { openExternalUrlSafe } from "../lib/open-external-url.ts";
1216
import { normalizeAgentId } from "../lib/sessions/session-key.ts";
@@ -129,7 +133,7 @@ function renderAgentRow(agent: AgentMenuAgent, params: SidebarAgentMenuParams) {
129133
approvals === 1 ? "execApproval.agentPendingOne" : "execApproval.agentPending",
130134
{ count: String(approvals) },
131135
);
132-
const initial = resolveAgentTextAvatar(agent) ?? (label || agent.id).slice(0, 1).toUpperCase();
136+
const initial = resolveAgentTextAvatar(agent) ?? resolveFallbackAvatarInitial(label || agent.id);
133137
return html`
134138
<wa-dropdown-item
135139
class="sidebar-customize-menu__item sidebar-agent-menu__agent-switch"

ui/src/components/app-sidebar.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import { beginNativeWindowDragFromTopInset } from "../app/native-window-drag.ts"
1111
import { controlUiPublicAssetPath } from "../app/public-assets.ts";
1212
import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts";
1313
import { t } from "../i18n/index.ts";
14-
import { normalizeAgentLabel, resolveAgentTextAvatar } from "../lib/agents/display.ts";
14+
import {
15+
normalizeAgentLabel,
16+
resolveAgentTextAvatar,
17+
resolveFallbackAvatarInitial,
18+
} from "../lib/agents/display.ts";
1519
import { resolveAgentAvatarUrl } from "../lib/avatar.ts";
1620
import "./menu-surface.ts";
1721
import "./session-menu.ts";
@@ -155,7 +159,7 @@ class AppSidebar extends AppSidebarSessionListElement {
155159
const approvalCount = this.approvalBadgeSnapshot().agentCounts.get(cardAgentId) ?? 0;
156160
const cardAvatarText =
157161
(cardAgent ? resolveAgentTextAvatar(cardAgent) : null) ??
158-
(cardName || cardAgentId).slice(0, 1).toUpperCase();
162+
resolveFallbackAvatarInitial(cardName || cardAgentId);
159163
// The sidebar action follows gateway availability; collapsed native chrome
160164
// keeps its separate offline-tolerant ⌘N mirror.
161165
return html`
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Control UI proof: agent chip avatar fallbacks keep complete graphemes for
2+
// flag/ZWJ emoji and ordinary Unicode letters (no UTF-16 mid-cluster cuts).
3+
import { mkdir } from "node:fs/promises";
4+
import path from "node:path";
5+
import { chromium, type Browser } from "playwright";
6+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
7+
import {
8+
canRunPlaywrightChromium,
9+
installMockGateway,
10+
resolvePlaywrightChromiumExecutablePath,
11+
startControlUiE2eServer,
12+
type ControlUiE2eServer,
13+
} from "../test-helpers/control-ui-e2e.ts";
14+
15+
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
16+
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
17+
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
18+
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
19+
20+
const artifactDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "avatar-initials");
21+
22+
const CASES = [
23+
{ assistantName: "🇺🇸Team", expectedInitial: "🇺🇸", shot: "flag-avatar.png" },
24+
{ assistantName: "👨‍💻Dev", expectedInitial: "👨‍💻", shot: "zwj-avatar.png" },
25+
{ assistantName: "東京", expectedInitial: "東", shot: "tokyo-avatar.png" },
26+
] as const;
27+
28+
let browser: Browser;
29+
let server: ControlUiE2eServer;
30+
31+
describeControlUiE2e("Control UI avatar grapheme initials E2E", () => {
32+
beforeAll(async () => {
33+
if (!chromiumAvailable) {
34+
throw new Error(`Playwright Chromium is unavailable at ${chromiumExecutablePath}`);
35+
}
36+
server = await startControlUiE2eServer();
37+
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
38+
await mkdir(artifactDir, { recursive: true });
39+
});
40+
41+
afterAll(async () => {
42+
await browser?.close();
43+
await server?.close();
44+
});
45+
46+
it("renders complete flag, ZWJ, and Unicode letter initials in the agent chip", async () => {
47+
const proof: Record<string, string> = {};
48+
49+
for (const testCase of CASES) {
50+
const context = await browser.newContext({
51+
locale: "en-US",
52+
serviceWorkers: "block",
53+
viewport: { height: 900, width: 1280 },
54+
});
55+
const page = await context.newPage();
56+
await installMockGateway(page, { assistantName: testCase.assistantName });
57+
58+
try {
59+
const response = await page.goto(server.baseUrl);
60+
expect(response?.status()).toBe(200);
61+
62+
const avatar = page.locator(".sidebar-agent-card__avatar-text");
63+
await avatar.waitFor();
64+
await expect.poll(() => avatar.textContent()).toBe(testCase.expectedInitial);
65+
proof[testCase.shot] = (await avatar.textContent()) ?? "";
66+
67+
await page.locator(".sidebar-agent-card").screenshot({
68+
path: path.join(artifactDir, testCase.shot),
69+
});
70+
} finally {
71+
await context.close();
72+
}
73+
}
74+
75+
console.log("control-ui avatar-initials e2e proof:", JSON.stringify(proof));
76+
console.log("control-ui avatar-initials artifact dir:", artifactDir);
77+
});
78+
});

ui/src/lib/agents/display.test.ts

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Control UI tests cover agents utils behavior.
2-
import { describe, expect, it } from "vitest";
2+
import { describe, expect, it, vi } from "vitest";
33
import { AVATAR_MAX_DATA_URL_CHARS } from "../../../../src/shared/avatar-limits.js";
44
import {
55
assistantAvatarFallbackUrl,
@@ -8,7 +8,12 @@ import {
88
resolveAssistantTextAvatar,
99
resolveChatAvatarRenderUrl,
1010
} from "../avatar.ts";
11-
import { buildAgentContext, formatBytes, resolveEffectiveModelFallbacks } from "./display.ts";
11+
import {
12+
buildAgentContext,
13+
formatBytes,
14+
resolveEffectiveModelFallbacks,
15+
resolveFallbackAvatarInitial,
16+
} from "./display.ts";
1217

1318
describe("formatBytes", () => {
1419
it("preserves the Control UI byte-size display contract", () => {
@@ -67,6 +72,73 @@ describe("assistantAvatarFallbackUrl", () => {
6772
});
6873
});
6974

75+
describe("resolveFallbackAvatarInitial", () => {
76+
it("keeps flag and ZWJ emoji clusters intact", () => {
77+
expect(resolveFallbackAvatarInitial("🇺🇸Team")).toBe("🇺🇸");
78+
expect(resolveFallbackAvatarInitial("👨‍💻Dev")).toBe("👨‍💻");
79+
expect(resolveFallbackAvatarInitial("alpha")).toBe("A");
80+
expect(resolveFallbackAvatarInitial("東京")).toBe("東");
81+
expect(resolveFallbackAvatarInitial("Émile")).toBe("É");
82+
expect(resolveFallbackAvatarInitial(" ")).toBe("?");
83+
console.log(
84+
"avatar-initial proof:",
85+
JSON.stringify({
86+
flag: resolveFallbackAvatarInitial("🇺🇸Team"),
87+
zwj: resolveFallbackAvatarInitial("👨‍💻Dev"),
88+
ascii: resolveFallbackAvatarInitial("alpha"),
89+
tokyo: resolveFallbackAvatarInitial("東京"),
90+
accented: resolveFallbackAvatarInitial("Émile"),
91+
}),
92+
);
93+
});
94+
95+
it("keeps locale-independent ASCII casing (Turkish i)", () => {
96+
const previous = process.env.LC_ALL;
97+
try {
98+
// Exercise the same toUpperCase path the Control UI fallback uses.
99+
expect(resolveFallbackAvatarInitial("istanbul")).toBe("I");
100+
} finally {
101+
if (previous === undefined) {
102+
delete process.env.LC_ALL;
103+
} else {
104+
process.env.LC_ALL = previous;
105+
}
106+
}
107+
});
108+
109+
it("preserves safe BMP letters/digits without Segmenter; refuses flag/ZWJ splits", async () => {
110+
const originalSegmenter = Intl.Segmenter;
111+
Object.defineProperty(Intl, "Segmenter", { configurable: true, value: undefined });
112+
vi.resetModules();
113+
try {
114+
const { resolveFallbackAvatarInitial: resolveWithoutSegmenter } =
115+
await import("./display.ts");
116+
expect(resolveWithoutSegmenter("alpha")).toBe("A");
117+
expect(resolveWithoutSegmenter("東京")).toBe("東");
118+
expect(resolveWithoutSegmenter("Émile")).toBe("É");
119+
expect(resolveWithoutSegmenter("9lives")).toBe("9");
120+
expect(resolveWithoutSegmenter("🇺🇸")).toBe("?");
121+
expect(resolveWithoutSegmenter("🇺🇸Team")).toBe("?");
122+
expect(resolveWithoutSegmenter("👨‍💻")).toBe("?");
123+
expect(resolveWithoutSegmenter("👨‍💻Dev")).toBe("?");
124+
console.log(
125+
"avatar-initial Segmenter-fallback proof:",
126+
JSON.stringify({
127+
ascii: resolveWithoutSegmenter("alpha"),
128+
tokyo: resolveWithoutSegmenter("東京"),
129+
accented: resolveWithoutSegmenter("Émile"),
130+
digit: resolveWithoutSegmenter("9lives"),
131+
flag: resolveWithoutSegmenter("🇺🇸"),
132+
zwj: resolveWithoutSegmenter("👨‍💻"),
133+
}),
134+
);
135+
} finally {
136+
Object.defineProperty(Intl, "Segmenter", { configurable: true, value: originalSegmenter });
137+
vi.resetModules();
138+
}
139+
});
140+
});
141+
70142
describe("resolveAssistantTextAvatar", () => {
71143
it("rejects unsafe invisible controls in assistant text avatars", () => {
72144
expect(resolveAssistantTextAvatar("VC")).toBe("VC");

ui/src/lib/agents/display.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,53 @@ export function resolveAgentTextAvatar(
317317
return null;
318318
}
319319

320+
const avatarInitialSegmenter =
321+
typeof Intl.Segmenter === "function"
322+
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
323+
: null;
324+
325+
/**
326+
* Without Segmenter, keep one safe BMP letter/digit code point.
327+
* Non-BMP starters (flags, most emoji) and non-letter/digit BMP (ZWJ, symbols)
328+
* need Segmenter clustering — return "?" rather than a half glyph.
329+
*/
330+
function resolveBmpSafeAvatarInitial(trimmed: string): string {
331+
// Code-point aware: [...str] never splits a surrogate pair the way .slice(0, 1) does.
332+
const first = [...trimmed][0];
333+
if (!first) {
334+
return "?";
335+
}
336+
const codePoint = first.codePointAt(0);
337+
if (codePoint === undefined || codePoint > 0xffff) {
338+
return "?";
339+
}
340+
if (!/^\p{L}$/u.test(first) && !/^\p{Nd}$/u.test(first)) {
341+
return "?";
342+
}
343+
// Keep the prior locale-independent casing contract (toUpperCase, not toLocaleUpperCase).
344+
return first.toUpperCase();
345+
}
346+
347+
/**
348+
* First grapheme of a label for avatar fallback text (flags / ZWJ emoji stay intact).
349+
* Correct clustering requires Intl.Segmenter. Without it, keep a single safe BMP
350+
* letter/digit — never UTF-16 slicing or non-BMP first-code-point emoji halves.
351+
*/
352+
export function resolveFallbackAvatarInitial(label: string): string {
353+
const trimmed = label.trim();
354+
if (!trimmed) {
355+
return "?";
356+
}
357+
if (avatarInitialSegmenter) {
358+
for (const { segment } of avatarInitialSegmenter.segment(trimmed)) {
359+
// Keep the prior locale-independent casing contract (toUpperCase, not toLocaleUpperCase).
360+
return segment.toUpperCase();
361+
}
362+
return "?";
363+
}
364+
return resolveBmpSafeAvatarInitial(trimmed);
365+
}
366+
320367
export function agentBadgeText(agentId: string, defaultId: string | null) {
321368
return defaultId && agentId === defaultId ? "default" : null;
322369
}

ui/src/pages/agents/panels-overview.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
resolveAgentConfig,
1717
resolveAgentRuntimeLabel,
1818
resolveAgentTextAvatar,
19+
resolveFallbackAvatarInitial,
1920
resolveModelFallbacks,
2021
resolveModelLabel,
2122
resolveModelPrimary,
@@ -110,7 +111,7 @@ export function renderAgentOverview(params: {
110111
const identityAvatarUrl =
111112
identityDraft.avatar ?? resolveAgentAvatarUrl(agent, params.agentIdentity);
112113
const identityAvatarText =
113-
resolveAgentTextAvatar(agent) ?? (identityName || agent.id).slice(0, 1).toUpperCase();
114+
resolveAgentTextAvatar(agent) ?? resolveFallbackAvatarInitial(identityName || agent.id);
114115
const identityDirty =
115116
identityDraft.name !== null || identityDraft.emoji !== null || identityDraft.avatar !== null;
116117
const identityInvalid =

ui/src/test-helpers/app-sidebar-cases/agent-menu.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it, vi } from "vitest";
22
import type { GatewayBrowserClient } from "../../api/gateway.ts";
3+
import type { AgentsListResult } from "../../api/types.ts";
34
import {
45
createGateway,
56
createGatewayHarness,
@@ -259,4 +260,52 @@ describe("AppSidebar agent chip", () => {
259260
expect(rows).toHaveLength(10);
260261
expect(rows.some((row) => row.textContent?.includes("agent-12"))).toBe(true);
261262
});
263+
264+
it("renders complete flag and ZWJ graphemes in the agent chip avatar slot", async () => {
265+
const gateway = createGateway({} as GatewayBrowserClient);
266+
const agentsList = {
267+
defaultId: "flag",
268+
mainKey: "main",
269+
scope: "agent",
270+
agents: [
271+
{ id: "flag", identity: { name: "🇺🇸Team" } },
272+
{ id: "dev", identity: { name: "👨‍💻Dev" } },
273+
],
274+
} as AgentsListResult;
275+
const { sidebar } = await mountSidebar(
276+
gateway,
277+
createSessions("flag", ["agent:flag:main"]),
278+
"panel",
279+
agentsList,
280+
);
281+
sidebar.connected = true;
282+
await sidebar.updateComplete;
283+
284+
// Production path: sidebar agent card avatar text when no URL/text avatar is set.
285+
expect(sidebar.querySelector(".sidebar-agent-card__avatar-text")?.textContent).toBe("🇺🇸");
286+
287+
sidebar.querySelector<HTMLButtonElement>(".sidebar-agent-card__main")?.click();
288+
await sidebar.updateComplete;
289+
const switchRows = [
290+
...sidebar.querySelectorAll(
291+
".sidebar-agent-menu wa-dropdown-item.sidebar-agent-menu__agent-switch",
292+
),
293+
];
294+
const flagAvatar = switchRows
295+
.find((row) => row.textContent?.includes("🇺🇸Team"))
296+
?.querySelector(".sidebar-agent-section__avatar");
297+
const zwjAvatar = switchRows
298+
.find((row) => row.textContent?.includes("👨‍💻Dev"))
299+
?.querySelector(".sidebar-agent-section__avatar");
300+
expect(flagAvatar?.textContent).toBe("🇺🇸");
301+
expect(zwjAvatar?.textContent).toBe("👨‍💻");
302+
console.log(
303+
"sidebar avatar-slot proof:",
304+
JSON.stringify({
305+
chip: sidebar.querySelector(".sidebar-agent-card__avatar-text")?.textContent,
306+
flagMenu: flagAvatar?.textContent,
307+
zwjMenu: zwjAvatar?.textContent,
308+
}),
309+
);
310+
});
262311
});

0 commit comments

Comments
 (0)