From d32974d398bccef2b2e02defd42cad70cccbd565 Mon Sep 17 00:00:00 2001 From: Marcus Widing Date: Sat, 14 Mar 2026 09:39:18 +0100 Subject: [PATCH 1/9] fix(ui): restore agent emoji in selector dropdown Fixes #45755 The agent emoji was lost during the dashboard-v2 refactor. This restores it by calling resolveAgentEmoji() and prepending the emoji to the label in the agent select dropdown. --- ui/src/ui/views/agents.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/ui/src/ui/views/agents.ts b/ui/src/ui/views/agents.ts index 4e8b9a065ba..325584171a6 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,16 @@ export function renderAgents(props: AgentsProps) { ? html` ` - : agents.map( - (agent) => html` + : agents.map((agent) => { + const emoji = resolveAgentEmoji(agent); + const label = normalizeAgentLabel(agent); + const badge = agentBadgeText(agent.id, defaultId); + return html` - `, - ) + `; + }) } From 447e51fb701d9ef666e420c82796298a53507c59 Mon Sep 17 00:00:00 2001 From: Marcus Widing Date: Sat, 14 Mar 2026 09:48:23 +0100 Subject: [PATCH 2/9] fix: pass agentIdentity to resolveAgentEmoji for dynamic emoji lookup --- ui/src/ui/views/agents.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/src/ui/views/agents.ts b/ui/src/ui/views/agents.ts index 325584171a6..680123090cf 100644 --- a/ui/src/ui/views/agents.ts +++ b/ui/src/ui/views/agents.ts @@ -155,7 +155,10 @@ export function renderAgents(props: AgentsProps) { ` : agents.map((agent) => { - const emoji = resolveAgentEmoji(agent); + const emoji = resolveAgentEmoji( + agent, + props.agentIdentityById[agent.id] ?? null, + ); const label = normalizeAgentLabel(agent); const badge = agentBadgeText(agent.id, defaultId); return html` From 4f6cf1567596ee196474d9b08c5be23dfee829c5 Mon Sep 17 00:00:00 2001 From: Marcus Widing Date: Sat, 14 Mar 2026 11:31:56 +0100 Subject: [PATCH 3/9] chore: trigger webhook test From 37b40667b623bfe3bcce7a51b5065dcf51bd7ef0 Mon Sep 17 00:00:00 2001 From: Marcus Widing Date: Sat, 14 Mar 2026 11:33:21 +0100 Subject: [PATCH 4/9] chore: webhook test 2 From c99ead8a87bd72eedb0ba461f2875d5a91d286ea Mon Sep 17 00:00:00 2001 From: Marcus Widing Date: Sat, 14 Mar 2026 11:36:08 +0100 Subject: [PATCH 5/9] chore: webhook test 3 From 7690395efe784b80f4e8e66c4e3de131115e1ee2 Mon Sep 17 00:00:00 2001 From: Marcus Widing Date: Mon, 16 Mar 2026 22:37:34 +0100 Subject: [PATCH 6/9] harden avatar URL validation to block root-relative paths --- ui/src/ui/views/agents-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index e0c06c41386..1bda0e8daa8 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 } }, From 7943602d0cb9a5fa033ee9c952b70754be136f9b Mon Sep 17 00:00:00 2001 From: Marcus Widing Date: Tue, 17 Mar 2026 11:37:34 +0100 Subject: [PATCH 7/9] fix(ui): restore root-relative avatar URL support --- ui/src/ui/views/agents-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index 1bda0e8daa8..8245438f1ff 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\/|blob:)/i; +const AVATAR_URL_RE = /^(https?:\/\/|data:image\/|blob:|\/)/i; export function resolveAgentAvatarUrl( agent: { identity?: { avatar?: string; avatarUrl?: string } }, From a0575539a02d3fabb50d13c2f03c1ba9e7d6bcca Mon Sep 17 00:00:00 2001 From: Marcus Widing Date: Wed, 18 Mar 2026 17:37:30 +0100 Subject: [PATCH 8/9] fix(ui): sanitize agent emoji display values --- ui/src/ui/views/agents-utils.test.ts | 15 ++++++++++ ui/src/ui/views/agents-utils.ts | 44 +++++++++++++++++++++++----- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/ui/src/ui/views/agents-utils.test.ts b/ui/src/ui/views/agents-utils.test.ts index a9b30e549db..daa909e2b2c 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,17 @@ 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(""); + }); +}); diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index 8245438f1ff..33734505cb9 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/ui/views/agents-utils.ts @@ -221,6 +221,23 @@ export function agentLogoUrl(basePath: string): string { return base ? `${base}/favicon.svg` : "favicon.svg"; } +const EMOJI_CONTROL_CHARS_RE = /[\u200B-\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 ""; From dd874d52f6314e05c0a2078dd3b64caed8f61f0c Mon Sep 17 00:00:00 2001 From: Marcus Widing Date: Thu, 19 Mar 2026 11:36:12 +0100 Subject: [PATCH 9/9] fix(ui): preserve ZWJ in emoji sanitization --- ui/src/ui/views/agents-utils.test.ts | 4 ++++ ui/src/ui/views/agents-utils.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/src/ui/views/agents-utils.test.ts b/ui/src/ui/views/agents-utils.test.ts index daa909e2b2c..958f5ece7e3 100644 --- a/ui/src/ui/views/agents-utils.test.ts +++ b/ui/src/ui/views/agents-utils.test.ts @@ -145,4 +145,8 @@ describe("resolveAgentEmoji", () => { 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 33734505cb9..fd3e018e330 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/ui/views/agents-utils.ts @@ -221,7 +221,7 @@ export function agentLogoUrl(basePath: string): string { return base ? `${base}/favicon.svg` : "favicon.svg"; } -const EMOJI_CONTROL_CHARS_RE = /[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g; +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();