From 9ad0a978f7feb7d9a9ee30fbcef0925ca121b294 Mon Sep 17 00:00:00 2001 From: Leon-SK668 <0668001470@xydigit.com> Date: Tue, 21 Jul 2026 15:17:15 +0800 Subject: [PATCH] fix(ui): preserve graphemes in provider icons (#109509) * fix(ui): preserve graphemes in provider icons * fix(ui): clamp uppercase provider icon graphemes * test(ui): cover provider icon graphemes in Chromium * test(ui): ignore fallback icon template whitespace * test(ui): capture complete fallback icon proof * test(ui): align provider probe E2E scope Co-authored-by: Leon-SK668 <17695126+Leon-SK668@users.noreply.github.com> --------- Co-authored-by: Leon-SK668 <17695126+Leon-SK668@users.noreply.github.com> Co-authored-by: Peter Steinberger --- ui/src/components/provider-icon.ts | 3 +- ui/src/e2e/model-providers.e2e.test.ts | 60 ++++++++++++++++++++++- ui/src/lib/graphemes.ts | 20 ++++++++ ui/src/pages/model-providers/view.test.ts | 24 +++++++++ ui/src/pages/plugins/presentation.ts | 22 +-------- ui/src/pages/plugins/view.test.ts | 2 + 6 files changed, 108 insertions(+), 23 deletions(-) create mode 100644 ui/src/lib/graphemes.ts diff --git a/ui/src/components/provider-icon.ts b/ui/src/components/provider-icon.ts index 48d34b0185b6..eef66cfee9d9 100644 --- a/ui/src/components/provider-icon.ts +++ b/ui/src/components/provider-icon.ts @@ -4,6 +4,7 @@ // shared styles live under .provider-brand-icon in styles/components.css. import { html } from "lit"; import { inferControlUiPublicAssetPath } from "../app/public-assets.ts"; +import { takeGraphemes } from "../lib/graphemes.ts"; const PROVIDER_ICON_NAMES = new Set([ "abacus", @@ -120,7 +121,7 @@ export function renderProviderBrandIcon(provider: string, options?: { className? const surfaceClass = options?.className ? ` ${options.className}` : ""; const icon = resolveProviderIconName(provider); if (!icon) { - const letter = (provider.trim().charAt(0) || "?").toUpperCase(); + const letter = takeGraphemes(provider.trim().toUpperCase(), 1) || "?"; return html` { } }); + it("renders one complete uppercased grapheme in custom provider fallback icons", async () => { + const bottomProviderId = "e\u0301-proxy"; + const cases = [ + { id: "ß-provider", expected: "S" }, + { id: "🧭-proxy", expected: "🧭" }, + { id: "πŸ‡ΊπŸ‡Έ-proxy", expected: "πŸ‡ΊπŸ‡Έ" }, + { id: "πŸ‘©β€πŸ’»-proxy", expected: "πŸ‘©β€πŸ’»" }, + { id: bottomProviderId, expected: "E\u0301" }, + ]; + const context = await browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 1000, width: 1280 }, + ...(recordVisuals + ? { recordVideo: { dir: artifactDir, size: { height: 1000, width: 1280 } } } + : {}), + }); + const page = await context.newPage(); + await installMockGateway(page, { + models: cases.map(({ id }) => ({ + id: "test-model", + name: "Test Model", + provider: id, + available: true, + })), + methodResponses: { + "models.authStatus": { ts: NOW, providers: [] }, + "usage.status": { updatedAt: NOW, providers: [] }, + "sessions.usage": { aggregates: { byProvider: [] } }, + }, + }); + + try { + await page.goto(`${server.baseUrl}settings/model-providers`); + await page.locator(".page-title", { hasText: "Model Providers" }).first().waitFor(); + + for (const { id, expected } of cases) { + const icon = page.locator(`[data-provider-id="${id}"] .provider-brand-icon--fallback`); + await icon.waitFor(); + await expect.poll(async () => (await icon.textContent())?.trim()).toBe(expected); + } + + if (recordVisuals) { + await page.screenshot({ + path: path.join(artifactDir, "03-unicode-fallback-icons.png"), + fullPage: true, + }); + await page.locator(`[data-provider-id="${bottomProviderId}"]`).scrollIntoViewIfNeeded(); + await page.screenshot({ + path: path.join(artifactDir, "04-unicode-fallback-icons-bottom.png"), + fullPage: true, + }); + } + } finally { + await context.close(); + } + }); + it("configures credentials, probes a provider, and changes default models", async () => { const context = await browser.newContext({ locale: "en-US", @@ -346,7 +404,7 @@ describeControlUiE2e("Control UI Model Providers mocked Gateway E2E", () => { await openaiCard.getByRole("button", { name: "Test connection" }).click(); const probe = await gateway.waitForRequest("models.probe"); - expect(probe.params).toEqual({ provider: "openai" }); + expect(probe.params).toEqual({ provider: "openai", agentId: "main" }); await expect.poll(async () => openaiCard.textContent()).toContain("87 ms"); const primary = page.locator(".model-providers__defaults select").first(); diff --git a/ui/src/lib/graphemes.ts b/ui/src/lib/graphemes.ts new file mode 100644 index 000000000000..43a5aec3244b --- /dev/null +++ b/ui/src/lib/graphemes.ts @@ -0,0 +1,20 @@ +const graphemeSegmenter = + typeof Intl.Segmenter === "function" + ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) + : null; + +export function takeGraphemes(input: string, limit: number): string { + if (!graphemeSegmenter) { + return Array.from(input).slice(0, limit).join(""); + } + let result = ""; + let count = 0; + for (const { segment } of graphemeSegmenter.segment(input)) { + result += segment; + count += 1; + if (count >= limit) { + break; + } + } + return result; +} diff --git a/ui/src/pages/model-providers/view.test.ts b/ui/src/pages/model-providers/view.test.ts index 6ba4eff586b3..10ea1aa23fa4 100644 --- a/ui/src/pages/model-providers/view.test.ts +++ b/ui/src/pages/model-providers/view.test.ts @@ -148,6 +148,30 @@ describe("renderModelProviders", () => { expect(text(provider)).toContain("Global session spend Β· 30d"); }); + it("preserves complete graphemes in custom provider fallback icons", () => { + const cases = [ + { id: "🧭-proxy", expected: "🧭" }, + { id: "πŸ‡ΊπŸ‡Έ-proxy", expected: "πŸ‡ΊπŸ‡Έ" }, + { id: "πŸ‘©β€πŸ’»-proxy", expected: "πŸ‘©β€πŸ’»" }, + { id: "e\u0301-proxy", expected: "E\u0301" }, + { id: "ß-provider", expected: "S" }, + ]; + const container = mount( + props({ + cards: cases.map(({ id }) => card({ id, displayName: id, credentialProviderIds: [id] })), + }), + ); + + for (const { id, expected } of cases) { + const row = [...container.querySelectorAll("[data-provider-id]")].find( + (candidate) => candidate.dataset.providerId === id, + ); + expect(row?.querySelector(".provider-brand-icon--fallback")?.textContent?.trim()).toBe( + expected, + ); + } + }); + it("shows config key provenance when auth status is unavailable", () => { const container = mount( props({ diff --git a/ui/src/pages/plugins/presentation.ts b/ui/src/pages/plugins/presentation.ts index 18450a1faf9a..6fb5952aee92 100644 --- a/ui/src/pages/plugins/presentation.ts +++ b/ui/src/pages/plugins/presentation.ts @@ -3,6 +3,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { inferControlUiPublicAssetPath } from "../../app/public-assets.ts"; import { t } from "../../i18n/index.ts"; +import { takeGraphemes } from "../../lib/graphemes.ts"; /** * Cover art bundled at ui/public/plugin-art/.webp. The gateway CSP is @@ -206,27 +207,6 @@ const FALLBACK_GRADIENTS: ReadonlyArray = [ ["#fb7185", "#9f1239"], ]; -const graphemeSegmenter = - typeof Intl.Segmenter === "function" - ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) - : null; - -function takeGraphemes(input: string, limit: number): string { - if (!graphemeSegmenter) { - return Array.from(input).slice(0, limit).join(""); - } - let result = ""; - let count = 0; - for (const { segment } of graphemeSegmenter.segment(input)) { - result += segment; - count += 1; - if (count >= limit) { - break; - } - } - return result; -} - export function pluginFallbackGradient(id: string): readonly [string, string] { let hash = 0; for (const char of id) { diff --git a/ui/src/pages/plugins/view.test.ts b/ui/src/pages/plugins/view.test.ts index bee347bfc8b8..e2203b34c7f6 100644 --- a/ui/src/pages/plugins/view.test.ts +++ b/ui/src/pages/plugins/view.test.ts @@ -200,6 +200,7 @@ describe("renderPlugins", () => { it("keeps plugin monograms usable when Intl.Segmenter is unavailable", async () => { const originalSegmenter = Intl.Segmenter; Object.defineProperty(Intl, "Segmenter", { configurable: true, value: undefined }); + vi.resetModules(); try { const freshModulePath = "./presentation.ts?without-intl-segmenter"; @@ -208,6 +209,7 @@ describe("renderPlugins", () => { expect(pluginMonogram("πŸ‘©β€πŸ’» Tools")).toBe("πŸ‘©T"); } finally { Object.defineProperty(Intl, "Segmenter", { configurable: true, value: originalSegmenter }); + vi.resetModules(); } });