mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
@@ -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}"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user