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 <steipete@gmail.com>
This commit is contained in:
Leon-SK668
2026-07-21 15:17:15 +08:00
committed by GitHub
parent cec527a58a
commit 9ad0a978f7
6 changed files with 108 additions and 23 deletions
+2 -1
View File
@@ -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`
<span
class="provider-brand-icon provider-brand-icon--fallback${surfaceClass}"
+59 -1
View File
@@ -183,6 +183,64 @@ describeControlUiE2e("Control UI Model Providers mocked Gateway E2E", () => {
}
});
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();
+20
View File
@@ -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;
}
+24
View File
@@ -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<HTMLElement>("[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({
+1 -21
View File
@@ -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/<slug>.webp. The gateway CSP is
@@ -206,27 +207,6 @@ const FALLBACK_GRADIENTS: ReadonlyArray<readonly [string, string]> = [
["#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) {
+2
View File
@@ -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();
}
});