Skip to content

Commit dc6fc3f

Browse files
NIOcursoragent
andcommitted
fix(ui): keep agent avatar initials on grapheme clusters
Use Intl.Segmenter for Control UI fallback initials so flag/ZWJ emoji stay intact; without Segmenter keep ASCII-only fallback instead of Array.from splits. Co-authored-by: Cursor <[email protected]>
1 parent c95a8e3 commit dc6fc3f

6 files changed

Lines changed: 151 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`

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

Lines changed: 62 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,61 @@ 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+
console.log(
82+
"avatar-initial proof:",
83+
JSON.stringify({
84+
flag: resolveFallbackAvatarInitial("🇺🇸Team"),
85+
zwj: resolveFallbackAvatarInitial("👨‍💻Dev"),
86+
ascii: resolveFallbackAvatarInitial("alpha"),
87+
}),
88+
);
89+
});
90+
91+
it("keeps locale-independent ASCII casing (Turkish i)", () => {
92+
const previous = process.env.LC_ALL;
93+
try {
94+
// Exercise the same toUpperCase path the Control UI fallback uses.
95+
expect(resolveFallbackAvatarInitial("istanbul")).toBe("I");
96+
} finally {
97+
if (previous === undefined) {
98+
delete process.env.LC_ALL;
99+
} else {
100+
process.env.LC_ALL = previous;
101+
}
102+
}
103+
});
104+
105+
it("avoids splitting flag/ZWJ clusters when Intl.Segmenter is unavailable", async () => {
106+
const originalSegmenter = Intl.Segmenter;
107+
Object.defineProperty(Intl, "Segmenter", { configurable: true, value: undefined });
108+
vi.resetModules();
109+
try {
110+
const { resolveFallbackAvatarInitial: resolveWithoutSegmenter } =
111+
await import("./display.ts");
112+
expect(resolveWithoutSegmenter("alpha")).toBe("A");
113+
expect(resolveWithoutSegmenter("🇺🇸Team")).toBe("?");
114+
expect(resolveWithoutSegmenter("👨‍💻Dev")).toBe("?");
115+
console.log(
116+
"avatar-initial Segmenter-fallback proof:",
117+
JSON.stringify({
118+
ascii: resolveWithoutSegmenter("alpha"),
119+
flag: resolveWithoutSegmenter("🇺🇸Team"),
120+
zwj: resolveWithoutSegmenter("👨‍💻Dev"),
121+
}),
122+
);
123+
} finally {
124+
Object.defineProperty(Intl, "Segmenter", { configurable: true, value: originalSegmenter });
125+
vi.resetModules();
126+
}
127+
});
128+
});
129+
70130
describe("resolveAssistantTextAvatar", () => {
71131
it("rejects unsafe invisible controls in assistant text avatars", () => {
72132
expect(resolveAssistantTextAvatar("VC")).toBe("VC");

ui/src/lib/agents/display.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,32 @@ 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+
* First grapheme of a label for avatar fallback text (flags / ZWJ emoji stay intact).
327+
* Correct clustering requires Intl.Segmenter. Engines without it keep ASCII initials
328+
* only — never Array.from / UTF-16 slicing, which splits flags and ZWJ sequences.
329+
*/
330+
export function resolveFallbackAvatarInitial(label: string): string {
331+
const trimmed = label.trim();
332+
if (!trimmed) {
333+
return "?";
334+
}
335+
if (avatarInitialSegmenter) {
336+
for (const { segment } of avatarInitialSegmenter.segment(trimmed)) {
337+
// Keep the prior locale-independent casing contract (toUpperCase, not toLocaleUpperCase).
338+
return segment.toUpperCase();
339+
}
340+
return "?";
341+
}
342+
const ascii = trimmed.match(/^[A-Za-z0-9]/);
343+
return ascii ? ascii[0].toUpperCase() : "?";
344+
}
345+
320346
export function agentBadgeText(agentId: string, defaultId: string | null) {
321347
return defaultId && agentId === defaultId ? "default" : null;
322348
}

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)