diff --git a/ui/src/ui/views/agents-utils.test.ts b/ui/src/ui/views/agents-utils.test.ts index a9b30e549db..958f5ece7e3 100644 --- a/ui/src/ui/views/agents-utils.test.ts +++ b/ui/src/ui/views/agents-utils.test.ts @@ -3,6 +3,7 @@ import { agentLogoUrl, resolveConfiguredCronModelSuggestions, resolveAgentAvatarUrl, + resolveAgentEmoji, resolveEffectiveModelFallbacks, sortLocaleStrings, } from "./agents-utils.ts"; @@ -131,3 +132,21 @@ describe("resolveAgentAvatarUrl", () => { expect(resolveAgentAvatarUrl({ identity: { avatar: "🦞" } })).toBeNull(); }); }); + +describe("resolveAgentEmoji", () => { + it("strips bidi controls from emoji values", () => { + expect(resolveAgentEmoji({ identity: { emoji: "\u202E🤖" } })).toBe("🤖"); + }); + + it("keeps only the first grapheme cluster", () => { + expect(resolveAgentEmoji({ identity: { emoji: "🤖😈" } })).toBe("🤖"); + }); + + it("falls back to empty for control-only emoji strings", () => { + expect(resolveAgentEmoji({ identity: { emoji: "\u202E\u2066" } })).toBe(""); + }); + + it("preserves ZWJ-composed compound emoji intact", () => { + expect(resolveAgentEmoji({ identity: { emoji: "👩‍💻" } })).toBe("👩‍💻"); + }); +}); diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index e0c06c41386..fd3e018e330 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/ui/views/agents-utils.ts @@ -194,7 +194,7 @@ export function normalizeAgentLabel(agent: { return agent.name?.trim() || agent.identity?.name?.trim() || agent.id; } -const AVATAR_URL_RE = /^(https?:\/\/|data:image\/|\/)/i; +const AVATAR_URL_RE = /^(https?:\/\/|data:image\/|blob:|\/)/i; export function resolveAgentAvatarUrl( agent: { identity?: { avatar?: string; avatarUrl?: string } }, @@ -221,6 +221,23 @@ export function agentLogoUrl(basePath: string): string { return base ? `${base}/favicon.svg` : "favicon.svg"; } +const EMOJI_CONTROL_CHARS_RE = /[\u200B\u200C\u200E\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g; + +function sanitizeEmojiValue(value: string): string { + const cleaned = value.replaceAll(EMOJI_CONTROL_CHARS_RE, "").trim(); + if (!cleaned) { + return ""; + } + if (typeof Intl.Segmenter !== "function") { + return cleaned; + } + const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + const first = segmenter.segment(cleaned)[Symbol.iterator]().next().value as + | { segment?: string } + | undefined; + return first?.segment?.trim() || ""; +} + function isLikelyEmoji(value: string) { const trimmed = value.trim(); if (!trimmed) { @@ -245,24 +262,35 @@ function isLikelyEmoji(value: string) { return true; } +function pickEmoji(value?: string | null): string { + if (!value) { + return ""; + } + const sanitized = sanitizeEmojiValue(value); + if (!sanitized) { + return ""; + } + return isLikelyEmoji(sanitized) ? sanitized : ""; +} + export function resolveAgentEmoji( agent: { identity?: { emoji?: string; avatar?: string } }, agentIdentity?: AgentIdentityResult | null, ) { - const identityEmoji = agentIdentity?.emoji?.trim(); - if (identityEmoji && isLikelyEmoji(identityEmoji)) { + const identityEmoji = pickEmoji(agentIdentity?.emoji); + if (identityEmoji) { return identityEmoji; } - const agentEmoji = agent.identity?.emoji?.trim(); - if (agentEmoji && isLikelyEmoji(agentEmoji)) { + const agentEmoji = pickEmoji(agent.identity?.emoji); + if (agentEmoji) { return agentEmoji; } - const identityAvatar = agentIdentity?.avatar?.trim(); - if (identityAvatar && isLikelyEmoji(identityAvatar)) { + const identityAvatar = pickEmoji(agentIdentity?.avatar); + if (identityAvatar) { return identityAvatar; } - const avatar = agent.identity?.avatar?.trim(); - if (avatar && isLikelyEmoji(avatar)) { + const avatar = pickEmoji(agent.identity?.avatar); + if (avatar) { return avatar; } return ""; diff --git a/ui/src/ui/views/agents.ts b/ui/src/ui/views/agents.ts index 4e8b9a065ba..680123090cf 100644 --- a/ui/src/ui/views/agents.ts +++ b/ui/src/ui/views/agents.ts @@ -16,7 +16,12 @@ import { renderAgentCron, } from "./agents-panels-status-files.ts"; import { renderAgentTools, renderAgentSkills } from "./agents-panels-tools-skills.ts"; -import { agentBadgeText, buildAgentContext, normalizeAgentLabel } from "./agents-utils.ts"; +import { + agentBadgeText, + buildAgentContext, + normalizeAgentLabel, + resolveAgentEmoji, +} from "./agents-utils.ts"; export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron"; @@ -149,13 +154,19 @@ export function renderAgents(props: AgentsProps) { ? html` ` - : agents.map( - (agent) => html` + : agents.map((agent) => { + const emoji = resolveAgentEmoji( + agent, + props.agentIdentityById[agent.id] ?? null, + ); + const label = normalizeAgentLabel(agent); + const badge = agentBadgeText(agent.id, defaultId); + return html` - `, - ) + `; + }) }