, rect: DOMRect): ModelPoint {
const inset = MODEL_PAD_INSET_PERCENT / 100;
@@ -284,12 +285,20 @@ export function ModelPickerPad({
+ >
+
+
) : null}
diff --git a/renderer/components/provider-icon.tsx b/renderer/components/provider-icon.tsx
index 100c2d6ce..9eea6ab61 100644
--- a/renderer/components/provider-icon.tsx
+++ b/renderer/components/provider-icon.tsx
@@ -78,6 +78,39 @@ const MULTICOLOR_PROVIDER_ICON_SLUGS = new Set([
"zai-coding-cn",
]);
+const THEMED_MARK_STYLE = {
+ backgroundColor: "currentColor",
+ WebkitMaskPosition: "center",
+ maskPosition: "center",
+ WebkitMaskRepeat: "no-repeat",
+ maskRepeat: "no-repeat",
+ WebkitMaskSize: "contain",
+ maskSize: "contain",
+} as const;
+
+function ThemedProviderMark({
+ iconUrl,
+ mark,
+ className,
+}: {
+ iconUrl: string;
+ mark: string;
+ className?: string;
+}) {
+ return (
+
+ );
+}
+
export function ProviderIcon({
providerId,
providerLabel,
@@ -106,7 +139,7 @@ export function ProviderIcon({
const slug = resolveProviderIconSlug(providerId, modelId);
const iconUrl = slug ? PROVIDER_ICON_URLS[slug] : undefined;
- if (!iconUrl) {
+ if (!slug || !iconUrl) {
const initial = providerLabel.trim().charAt(0).toLocaleUpperCase() || "?";
return (
- );
+ return ;
}
diff --git a/renderer/lib/model-picker-data.test.ts b/renderer/lib/model-picker-data.test.ts
index 3825237ef..70c927032 100644
--- a/renderer/lib/model-picker-data.test.ts
+++ b/renderer/lib/model-picker-data.test.ts
@@ -164,6 +164,25 @@ test("selection encoding preserves model ids and format parsing isolates quantiz
assert.deepEqual(parseModel("gpt-4.1"), { label: "gpt-4.1", format: null });
});
+test("picker entries carry custom provider artwork into the model list and pad", () => {
+ const artwork = normalizeProviderArtwork({
+ mimeType: "image/png",
+ dataBase64:
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
+ });
+ const entries = createModelEntries([
+ provider({
+ id: "custom:connection-abc",
+ label: "Home Lab",
+ artwork,
+ models: ["local-chat"],
+ }),
+ ]);
+
+ assert.equal(entries.length, 1);
+ assert.deepEqual(entries[0]?.providerArtwork, artwork);
+});
+
test("entry creation includes every usable model, keeps hosted providers first, and ignores pins", () => {
const providers = [
provider({
diff --git a/renderer/lib/pi-provider-display.test.ts b/renderer/lib/pi-provider-display.test.ts
index bbe5fa605..4a35f93ee 100644
--- a/renderer/lib/pi-provider-display.test.ts
+++ b/renderer/lib/pi-provider-display.test.ts
@@ -222,6 +222,15 @@ test("provider marks and icon wells remain theme-aware in both appearances", ()
assert.doesNotMatch(multicolorProviderSlugs, /"ant-ling"/u);
assert.match(multicolorProviderSlugs, /"fireworks"/u);
assert.match(providerIconSource, /backgroundColor: "currentColor"/u);
+ assert.match(providerIconSource, /if \(!slug \|\| !iconUrl\) \{/u);
+ assert.match(
+ providerIconSource,
+ /if \(artwork\) \{\s*return \(\s*
["--status-", "--text-", "--syntax-", "--support-"].some(prefix => name.startsWith(prefix)))) {
assert.ok(fallback.includes(`${token}: ${value};`), `${scheme} fallback ${token} matches the resolver`);
}
diff --git a/renderer/shared/appearance.ts b/renderer/shared/appearance.ts
index a5b7c6eae..32b6e99d5 100644
--- a/renderer/shared/appearance.ts
+++ b/renderer/shared/appearance.ts
@@ -759,6 +759,7 @@ export function resolveThemeTokens(
"--border-separator": alphaHex(foreground, 0.055 + contrast * 0.07),
"--accent": variant.accent,
"--accent-foreground": accentForeground,
+ "--model-pad-knob-foreground": foregroundForFill("#FFFFFF", scheme),
"--accent-hover": accentHover,
"--accent-active": accentActive,
"--focus-ring": contrastCorrectColor(
diff --git a/renderer/styles.css b/renderer/styles.css
index 1081f7a6c..e11eba70f 100644
--- a/renderer/styles.css
+++ b/renderer/styles.css
@@ -230,6 +230,7 @@
--border-separator: rgb(61 63 65 / 0.087);
--accent: #006ad6;
--accent-foreground: #ffffff;
+ --model-pad-knob-foreground: #000000;
--accent-hover: #0063c7;
--accent-active: #005bb8;
--focus-ring: var(--text-primary);
@@ -302,6 +303,7 @@
--border-separator: rgb(209 212 218 / 0.097);
--accent: #3e97f6;
--accent-foreground: #000000;
+ --model-pad-knob-foreground: #000000;
--accent-hover: #59a6f7;
--accent-active: #68aef8;
--focus-ring: var(--text-primary);
@@ -1120,13 +1122,16 @@ textarea {
}
.model-pad-knob {
+ color: var(--model-pad-knob-foreground);
transition:
background-color 250ms cubic-bezier(0.25, 1, 0.5, 1),
- box-shadow 250ms cubic-bezier(0.25, 1, 0.5, 1);
+ box-shadow 250ms cubic-bezier(0.25, 1, 0.5, 1),
+ color 250ms cubic-bezier(0.25, 1, 0.5, 1);
}
.model-pad-knob[data-confirmed="true"] {
background: var(--accent);
+ color: var(--accent-foreground);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--accent), transparent 58%),
var(--elevation-control-hover);
diff --git a/tests/e2e/assistant-scheduled-profile.spec.ts b/tests/e2e/assistant-scheduled-profile.spec.ts
index 74f33b262..918a7e454 100644
--- a/tests/e2e/assistant-scheduled-profile.spec.ts
+++ b/tests/e2e/assistant-scheduled-profile.spec.ts
@@ -35,9 +35,16 @@ test("local Assistant, Scheduled, Profile, and About surfaces stay safe to explo
await taskSearch.fill("definitely-not-a-schedule");
await expect(taskSearch).toHaveValue("definitely-not-a-schedule");
await expect(page.getByText("No matching tasks", { exact: true })).toBeVisible();
- // Reset the filter through the input API; native select+Backspace can lose
- // selection on hosted macOS. Keyboard navigation is covered independently.
- await taskSearch.fill("");
+ // Drive the native value setter and input event directly. Empty Playwright
+ // fill can leave this controlled search unchanged on hosted Electron, while
+ // native select+Backspace can lose its selection. Keyboard navigation is
+ // covered independently.
+ await taskSearch.evaluate((element: HTMLInputElement) => {
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
+ if (!setter) throw new Error("HTMLInputElement.value setter is unavailable");
+ setter.call(element, "");
+ element.dispatchEvent(new Event("input", { bubbles: true }));
+ });
await expect(taskSearch).toHaveValue("");
await expect(page.getByText("No matching tasks", { exact: true })).toHaveCount(0);
await page.getByRole("tab", { name: "Active", exact: true }).click();
diff --git a/tests/e2e/model-pad-responsive.spec.ts b/tests/e2e/model-pad-responsive.spec.ts
index 7d864dda2..d3a61919d 100644
--- a/tests/e2e/model-pad-responsive.spec.ts
+++ b/tests/e2e/model-pad-responsive.spec.ts
@@ -18,6 +18,10 @@ type PadReachability = {
test("Model Pad fits resized settings and keeps models usable at native zoom", async ({
aiden,
}) => {
+ // A cold hosted Electron run can spend more than the suite's 90-second
+ // default traversing all 24 size, zoom, and panel combinations. Preserve the
+ // full matrix and its per-state polling while bounding the whole case.
+ test.setTimeout(180_000);
const { page, app } = aiden;
await finishLmStudioOnboarding(page);
await page.getByRole("button", { name: "Settings", exact: true }).click();
@@ -28,6 +32,15 @@ test("Model Pad fits resized settings and keeps models usable at native zoom", a
const pad = page.getByRole("group", { name: "Personal Model Pad arrangement", exact: true });
const browse = page.getByRole("button", { name: "Browse models", exact: true });
const insights = page.getByRole("button", { name: "Benchmark insights", exact: true });
+ await page.evaluate(() => {
+ const sentinel = document.createElement("span");
+ sentinel.style.cssText = "position:fixed;width:1px;height:1px;pointer-events:none;opacity:0";
+ document.body.append(sentinel);
+ sentinel.animate([{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }], {
+ duration: 1_000,
+ iterations: Infinity,
+ });
+ });
for (const [width, height, zoom] of [
[1440, 1000, 1],
@@ -51,6 +64,16 @@ test("Model Pad fits resized settings and keeps models usable at native zoom", a
for (const panel of ["closed", "models", "insights"] as const) {
if (panel === "models") await browse.click();
if (panel === "insights") await insights.click();
+ await page.evaluate(async (settleTimeoutMs) => {
+ const animations = (document.getAnimations?.() ?? []).filter((animation) => {
+ const endTime = animation.effect?.getComputedTiming().endTime;
+ return typeof endTime === "number" && Number.isFinite(endTime);
+ });
+ await Promise.race([
+ Promise.allSettled(animations.map((animation) => animation.finished)),
+ new Promise((resolve) => window.setTimeout(resolve, settleTimeoutMs)),
+ ]);
+ }, 500);
await page.locator(".model-pad-fieldset").evaluate((element) => {
let parent = element.parentElement;
while (parent && !/(auto|scroll)/u.test(getComputedStyle(parent).overflowY))
diff --git a/tests/e2e/provider-artwork.spec.ts b/tests/e2e/provider-artwork.spec.ts
new file mode 100644
index 000000000..5c38700ca
--- /dev/null
+++ b/tests/e2e/provider-artwork.spec.ts
@@ -0,0 +1,114 @@
+import { deflateSync } from "node:zlib";
+import { expect, test } from "./fixtures";
+
+const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
+const PROVIDER_ID = "custom:e2e-retina-artwork";
+type ProviderArtworkBridge = Window & {
+ aidenAPI: {
+ ipc: { invoke(channel: string, ...args: unknown[]): Promise };
+ };
+};
+
+function crc32(value: Buffer): number {
+ let crc = 0xffffffff;
+ for (const byte of value) {
+ crc ^= byte;
+ for (let bit = 0; bit < 8; bit += 1) {
+ crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
+ }
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+}
+
+function chunk(type: string, data = Buffer.alloc(0)): Buffer {
+ const typeBytes = Buffer.from(type, "ascii");
+ const result = Buffer.alloc(12 + data.length);
+ result.writeUInt32BE(data.length, 0);
+ typeBytes.copy(result, 4);
+ data.copy(result, 8);
+ result.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])), 8 + data.length);
+ return result;
+}
+
+function colorPng(width: number, height: number): Buffer {
+ const header = Buffer.alloc(13);
+ header.writeUInt32BE(width, 0);
+ header.writeUInt32BE(height, 4);
+ header[8] = 8;
+ header[9] = 6;
+ const pixels = Buffer.alloc((width * 4 + 1) * height);
+ for (let y = 0; y < height; y += 1) {
+ const row = y * (width * 4 + 1);
+ pixels[row] = 0;
+ for (let x = 0; x < width; x += 1) {
+ const offset = row + 1 + x * 4;
+ pixels[offset] = x < width / 2 ? 232 : 20;
+ pixels[offset + 1] = y < height / 2 ? 70 : 170;
+ pixels[offset + 2] = x < width / 2 ? 45 : 235;
+ pixels[offset + 3] = 255;
+ }
+ }
+ return Buffer.concat([
+ PNG_SIGNATURE,
+ chunk("IHDR", header),
+ chunk("IDAT", deflateSync(pixels)),
+ chunk("IEND"),
+ ]);
+}
+
+function pngDimensions(dataBase64: string): { width: number; height: number; bytes: number } {
+ const png = Buffer.from(dataBase64, "base64");
+ return {
+ width: png.readUInt32BE(16),
+ height: png.readUInt32BE(20),
+ bytes: png.length,
+ };
+}
+
+test.use({ portableConfigSeed: "empty" });
+
+test("oversized provider artwork survives save-time recovery and relaunch", async ({ aiden }) => {
+ const source = colorPng(128, 128).toString("base64");
+ const saved = await aiden.page.evaluate(
+ async ({ providerId, dataBase64 }) => (window as unknown as ProviderArtworkBridge).aidenAPI.ipc.invoke<{
+ artwork?: { mimeType: "image/png"; dataBase64: string };
+ }>("providers:save", {
+ id: providerId,
+ kind: "openai",
+ label: "Oversized artwork test",
+ artwork: { mimeType: "image/png", dataBase64 },
+ baseUrl: "http://127.0.0.1:1234/v1",
+ models: ["e2e-artwork-model"],
+ needsKey: false,
+ }),
+ { providerId: PROVIDER_ID, dataBase64: source },
+ );
+
+ expect(saved.artwork).toBeDefined();
+ expect(saved.artwork?.mimeType).toBe("image/png");
+ const normalized = pngDimensions(saved.artwork!.dataBase64);
+ expect(normalized.width).toBe(64);
+ expect(normalized.height).toBe(64);
+ expect(normalized.bytes).toBeLessThanOrEqual(32 * 1024);
+
+ const decodedSize = await aiden.page.evaluate(async (dataBase64) => {
+ const image = new Image();
+ image.src = `data:image/png;base64,${dataBase64}`;
+ await image.decode();
+ return { width: image.naturalWidth, height: image.naturalHeight };
+ }, saved.artwork!.dataBase64);
+ expect(decodedSize).toEqual({ width: 64, height: 64 });
+
+ const relaunched = await aiden.relaunch();
+ const listed = await relaunched.evaluate(
+ async (providerId) => {
+ const providers = await (window as unknown as ProviderArtworkBridge).aidenAPI.ipc.invoke>("providers:list");
+ return providers.find((provider) => provider.id === providerId);
+ },
+ PROVIDER_ID,
+ );
+ expect(listed?.artwork).toEqual(saved.artwork);
+});