From 96ea6698d722eec23167c175a2a6c2b440035bbb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 9 Sep 2026 11:06:29 +0200 Subject: [PATCH] fix(app): preserve plugin provider icons in model buttons (#4549) * fix(app): preserve plugin provider icons in model buttons Pass the selected host to the composer glyph so it resolves the same plugin SVG as the model picker. Require the nullable glyph host prop to catch omitted identity at compile time. * test(app): isolate plugin icon regression setup --- .../e2e/browser/plugin-provider-icons.spec.ts | 16 ++ .../support/helpers/plugin-provider-icons.ts | 175 ++++++++++++++++++ .../components/combined-model-selector.tsx | 1 + packages/app/src/components/model-browser.tsx | 2 +- 4 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 packages/app/e2e/browser/plugin-provider-icons.spec.ts create mode 100644 packages/app/e2e/support/helpers/plugin-provider-icons.ts diff --git a/packages/app/e2e/browser/plugin-provider-icons.spec.ts b/packages/app/e2e/browser/plugin-provider-icons.spec.ts new file mode 100644 index 0000000000..32093cb275 --- /dev/null +++ b/packages/app/e2e/browser/plugin-provider-icons.spec.ts @@ -0,0 +1,16 @@ +import { + test, + verifyProviderSettings, + verifyNewWorkspaceModelIcon, + verifyCompactModelIcon, + verifyExistingAgentModelIcon, +} from "../support/helpers/plugin-provider-icons"; + +test("plugin provider icons follow the model through settings and wide and compact composers", async ({ + providerIcons, +}) => { + await verifyProviderSettings(providerIcons); + await verifyNewWorkspaceModelIcon(providerIcons); + await verifyCompactModelIcon(providerIcons); + await verifyExistingAgentModelIcon(providerIcons); +}); diff --git a/packages/app/e2e/support/helpers/plugin-provider-icons.ts b/packages/app/e2e/support/helpers/plugin-provider-icons.ts new file mode 100644 index 0000000000..22a8e46ecc --- /dev/null +++ b/packages/app/e2e/support/helpers/plugin-provider-icons.ts @@ -0,0 +1,175 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import type { Locator, TestInfo } from "@playwright/test"; +import { expect, test as base, type Page } from "../fixtures"; +import { gotoAppShell, openSettings } from "./app"; +import { openModelPicker } from "./agent-profiles"; +import { openAgentRoute } from "./mock-agent"; +import { connectNewWorkspaceDaemonClient, openGlobalNewWorkspaceComposer } from "./new-workspace"; +import { copyPluginExample } from "./plugin-fixture"; +import { seedWorkspace, type SeededWorkspace } from "./seed-client"; +import { getServerId } from "./server-id"; +import { openSettingsHostSection } from "./settings"; + +const MODEL_LABEL = "Select model (Example 1)"; +const WIDE = { width: 1400, height: 950 }; +const COMPACT = { width: 390, height: 844 }; + +async function readIconPaths(page: Page, pluginDirectory: string): Promise { + const svg = await readFile(path.join(pluginDirectory, "icon.svg"), "utf8"); + return page.evaluate( + (source) => + Array.from( + new DOMParser().parseFromString(source, "image/svg+xml").querySelectorAll("path"), + (element) => element.getAttribute("d") ?? "", + ), + svg, + ); +} + +function readIconDrawings(icons: SVGElement[]): string[][] { + return icons.map((icon) => + Array.from(icon.querySelectorAll("path"), (element) => element.getAttribute("d") ?? ""), + ); +} + +async function expectProviderIcon(surface: Locator, paths: string[]): Promise { + await expect(surface).toBeVisible(); + // Provider icons are decorative SVGs without accessible names. Compare the + // plugin asset's drawing, allowing surface-specific size and theme colours. + await expect + .poll(() => surface.locator("svg").evaluateAll(readIconDrawings)) + .toContainEqual(paths); +} + +async function selectPluginModel(page: Page): Promise { + await openModelPicker(page); + await page.getByRole("button", { name: "Back", exact: true }).click(); + await page.getByText("Direct provider example", { exact: true }).click(); + await page.getByText("Example 1", { exact: true }).click(); + await expect(page.getByRole("button", { name: MODEL_LABEL, exact: true })).toBeVisible(); +} + +async function capture(page: Page, testInfo: TestInfo, name: string): Promise { + const screenshot = testInfo.outputPath(`${name}.png`); + await page.screenshot({ path: screenshot }); + await testInfo.attach(name, { path: screenshot, contentType: "image/png" }); +} + +interface ProviderIconJourney { + page: Page; + testInfo: TestInfo; + iconPaths: string[]; + workspace: SeededWorkspace; +} + +export const test = base.extend<{ providerIcons: ProviderIconJourney }>({ + providerIcons: async ({ page }, provide, testInfo) => { + const client = await connectNewWorkspaceDaemonClient(); + try { + const previous = await client.getDaemonConfig(); + const plugin = await copyPluginExample("provider-direct"); + try { + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(plugin.directory); + const workspace = await seedWorkspace({ repoPrefix: "plugin-provider-icons-" }); + try { + await page.setViewportSize(WIDE); + await gotoAppShell(page); + const iconPaths = await readIconPaths(page, plugin.directory); + await provide({ page, testInfo, iconPaths, workspace }); + } finally { + await workspace.cleanup(); + } + } finally { + try { + await client.removePlugin("provider-direct-example"); + } finally { + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }); + } + } + } finally { + await plugin.cleanup(); + } + } finally { + await client.close(); + } + }, +}); + +export async function verifyProviderSettings({ + page, + iconPaths, + testInfo, +}: ProviderIconJourney): Promise { + await test.step("provider settings render the registered icon", async () => { + await openSettings(page); + await openSettingsHostSection(page, getServerId(), "providers"); + await expectProviderIcon( + page.getByRole("button", { name: "Direct provider example provider details", exact: true }), + iconPaths, + ); + await capture(page, testInfo, "provider-settings"); + await page.getByTestId("settings-back-to-workspace").click(); + }); +} + +export async function verifyNewWorkspaceModelIcon({ + page, + iconPaths, + testInfo, +}: ProviderIconJourney): Promise { + await test.step("new workspace keeps the picker icon on its selected model button", async () => { + await openGlobalNewWorkspaceComposer(page); + await selectPluginModel(page); + await openModelPicker(page); + await expectProviderIcon(page.getByTestId("model-row-direct-example-example-1"), iconPaths); + await capture(page, testInfo, "new-workspace-picker"); + await page.keyboard.press("Escape"); + await expectProviderIcon( + page.getByRole("button", { name: MODEL_LABEL, exact: true }), + iconPaths, + ); + await capture(page, testInfo, "new-workspace-selected-model"); + }); +} + +export async function verifyCompactModelIcon({ + page, + iconPaths, + testInfo, +}: ProviderIconJourney): Promise { + await test.step("compact composer preserves the same provider icon", async () => { + await page.setViewportSize(COMPACT); + await expectProviderIcon( + page.getByRole("button", { name: MODEL_LABEL, exact: true }), + iconPaths, + ); + await capture(page, testInfo, "compact-selected-model"); + }); +} + +export async function verifyExistingAgentModelIcon({ + page, + iconPaths, + testInfo, + workspace, +}: ProviderIconJourney): Promise { + await test.step("existing agent composer uses the same icon", async () => { + const agent = await workspace.client.createAgent({ + provider: "direct-example", + model: "example-1", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Plugin icon agent", + }); + await page.setViewportSize(WIDE); + await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id }); + await expectProviderIcon( + page.getByRole("button", { name: MODEL_LABEL, exact: true }), + iconPaths, + ); + await capture(page, testInfo, "agent-selected-model"); + }); +} diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index dda77246c0..8473760c92 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -255,6 +255,7 @@ export function CombinedModelSelector({ diff --git a/packages/app/src/components/model-browser.tsx b/packages/app/src/components/model-browser.tsx index f724619c1f..67cb8d23ce 100644 --- a/packages/app/src/components/model-browser.tsx +++ b/packages/app/src/components/model-browser.tsx @@ -219,7 +219,7 @@ export function ModelProviderGlyph({ tone = "muted", }: { provider: string; - serverId?: string | null; + serverId: string | null; size: number; tone?: ProviderGlyphTone; }) {