diff --git a/packages/app/e2e/browser/provider-catalog-icons.spec.ts b/packages/app/e2e/browser/provider-catalog-icons.spec.ts new file mode 100644 index 0000000000..818d88ab84 --- /dev/null +++ b/packages/app/e2e/browser/provider-catalog-icons.spec.ts @@ -0,0 +1,51 @@ +import { ACP_PROVIDER_ICON_SVGS } from "../../src/assets/acp-provider-icons"; +import { expect, test, type Page } from "../support/fixtures"; +import type { TestInfo } from "@playwright/test"; +import { gotoAppShell, openSettings } from "../support/helpers/app"; +import { getServerId } from "../support/helpers/server-id"; +import { + findAcpCatalogProvider, + openAddProviderArea, + openSettingsHost, + openSettingsHostSection, +} from "../support/helpers/settings"; + +async function openProviderCatalog(page: Page) { + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, getServerId()); + await openSettingsHostSection(page, getServerId(), "providers"); + await openAddProviderArea(page); +} + +async function expectProviderArtwork( + page: Page, + testInfo: TestInfo, + id: "devin" | "hermes" | "kiro", + name: string, +) { + await findAcpCatalogProvider(page, name); + // Catalog tiles have no semantic role; scope artwork to the named provider's tile. + const icon = page.getByTestId(`provider-catalog-tile-${id}`).locator("svg").first(); + await expect(icon).toBeVisible(); + // Provider artwork is 24px; the generic PackagePlus fallback is 20px. + await expect.soft(icon).toHaveAttribute("width", "24"); + const expectedPaths = await page.evaluate((xml) => { + const document = new DOMParser().parseFromString(xml, "image/svg+xml"); + return Array.from(document.querySelectorAll("path"), (path) => path.getAttribute("d")!); + }, ACP_PROVIDER_ICON_SVGS[id]); + await expect(icon.locator("path")).toHaveCount(expectedPaths.length); + for (const [index, path] of expectedPaths.entries()) { + await expect(icon.locator("path").nth(index)).toHaveAttribute("d", path); + } + await page.screenshot({ path: testInfo.outputPath(`${id}-catalog.png`) }); +} + +test("#7087: the provider catalog shows Devin, Hermes, and Kiro artwork", async ({ + page, +}, testInfo) => { + await openProviderCatalog(page); + await expectProviderArtwork(page, testInfo, "devin", "Devin CLI"); + await expectProviderArtwork(page, testInfo, "hermes", "Hermes"); + await expectProviderArtwork(page, testInfo, "kiro", "Kiro CLI"); +}); diff --git a/packages/app/src/assets/acp-provider-icons.ts b/packages/app/src/assets/acp-provider-icons.ts index cebb4ca76d..703473e6be 100644 --- a/packages/app/src/assets/acp-provider-icons.ts +++ b/packages/app/src/assets/acp-provider-icons.ts @@ -53,9 +53,10 @@ export const ACP_PROVIDER_ICON_SVGS = { junie: '\n \n \n \n\n', kilo: '\n \n\n', + kiro: ' ', kimi: '\n \n\n', "minimax-code": - '\n \n\n', + '\n \n\n', "minion-code": '\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n', "mistral-vibe": @@ -74,7 +75,11 @@ export const ACP_PROVIDER_ICON_SVGS = { stakpak: '\n \n\n', traecli: - '\n \n\n', + '\n \n\n', vtcode: '\n \n VT\n\n', + devin: + '\n \n\n', + hermes: + '\n \n\n', } as const; diff --git a/packages/app/src/assets/acp-provider-icons/devin.svg b/packages/app/src/assets/acp-provider-icons/devin.svg new file mode 100644 index 0000000000..e0f58c6513 --- /dev/null +++ b/packages/app/src/assets/acp-provider-icons/devin.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/assets/acp-provider-icons/grok.svg b/packages/app/src/assets/acp-provider-icons/grok.svg new file mode 100644 index 0000000000..35db840319 --- /dev/null +++ b/packages/app/src/assets/acp-provider-icons/grok.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/app/src/assets/acp-provider-icons/hermes.svg b/packages/app/src/assets/acp-provider-icons/hermes.svg new file mode 100644 index 0000000000..c6fdd22500 --- /dev/null +++ b/packages/app/src/assets/acp-provider-icons/hermes.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/assets/acp-provider-icons/kiro.svg b/packages/app/src/assets/acp-provider-icons/kiro.svg new file mode 100644 index 0000000000..31715c335a --- /dev/null +++ b/packages/app/src/assets/acp-provider-icons/kiro.svg @@ -0,0 +1 @@ + diff --git a/packages/app/src/assets/acp-provider-icons/minimax-code.svg b/packages/app/src/assets/acp-provider-icons/minimax-code.svg new file mode 100644 index 0000000000..61fcb8afab --- /dev/null +++ b/packages/app/src/assets/acp-provider-icons/minimax-code.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/assets/acp-provider-icons/traecli.svg b/packages/app/src/assets/acp-provider-icons/traecli.svg new file mode 100644 index 0000000000..6d5388fcaa --- /dev/null +++ b/packages/app/src/assets/acp-provider-icons/traecli.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/components/icons/antigravity-icon.tsx b/packages/app/src/components/icons/antigravity-icon.tsx new file mode 100644 index 0000000000..baf2b4c4a6 --- /dev/null +++ b/packages/app/src/components/icons/antigravity-icon.tsx @@ -0,0 +1,17 @@ +import Svg, { Path } from "react-native-svg"; + +interface AntigravityIconProps { + size?: number; + color?: string; +} + +export function AntigravityIcon({ size = 16, color = "currentColor" }: AntigravityIconProps) { + return ( + + + + ); +} diff --git a/packages/app/src/components/icons/kiro-icon.tsx b/packages/app/src/components/icons/kiro-icon.tsx new file mode 100644 index 0000000000..830e9109aa --- /dev/null +++ b/packages/app/src/components/icons/kiro-icon.tsx @@ -0,0 +1,18 @@ +import Svg, { Path } from "react-native-svg"; + +interface KiroIconProps { + size?: number; + color?: string; +} + +export function KiroIcon({ size = 16, color = "currentColor" }: KiroIconProps) { + return ( + + + + ); +} diff --git a/packages/app/src/components/provider-icon-name.test.ts b/packages/app/src/components/provider-icon-name.test.ts index 65c75166a5..0083926627 100644 --- a/packages/app/src/components/provider-icon-name.test.ts +++ b/packages/app/src/components/provider-icon-name.test.ts @@ -17,8 +17,10 @@ describe("resolveProviderIconName", () => { it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => { expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" }); + expect(resolveProviderIconName("devin")).toEqual({ kind: "catalog", id: "devin" }); expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" }); expect(resolveProviderIconName("gjc")).toEqual({ kind: "catalog", id: "gjc" }); + expect(resolveProviderIconName("hermes")).toEqual({ kind: "catalog", id: "hermes" }); expect(resolveProviderIconName("traecli")).toEqual({ kind: "catalog", id: "traecli" }); }); diff --git a/packages/app/src/components/provider-icons.test.ts b/packages/app/src/components/provider-icons.test.ts index 025ff14bef..aceed473be 100644 --- a/packages/app/src/components/provider-icons.test.ts +++ b/packages/app/src/components/provider-icons.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { act, cleanup, renderHook } from "@testing-library/react"; -import { Bot } from "lucide-react-native"; +import { Bot, PackagePlus } from "lucide-react-native"; import { SvgXml } from "react-native-svg"; import { afterEach, describe, expect, it } from "vitest"; import { replaceProviderSnapshotIcons } from "@/data/provider-icons"; @@ -17,6 +17,16 @@ function renderIcon(Component: ProviderIconComponent) { } describe("useProviderIcon", () => { + it.each(["devin", "hermes", "kiro", "antigravity"])( + "#7087: %s has a provider mark before a host snapshot arrives", + (provider) => { + const Icon = renderHook(() => useProviderIcon(provider)).result.current; + expect(Icon).not.toBe(Bot); + expect(Icon).not.toBe(PackagePlus); + expect(typeof Icon).toBe("function"); + }, + ); + it("renders registered snapshot SVG metadata with the requested size and color", () => { const svg = ''; replaceProviderSnapshotIcons("server-1", [{ provider: "rendered-provider", iconSvg: svg }]); diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index 883b4820f7..d8548cbd16 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -1,12 +1,14 @@ import { useStore } from "zustand"; import { providerSnapshotIcons } from "@/data/provider-icons"; -import { Bot, PackagePlus } from "lucide-react-native"; +import { Bot } from "lucide-react-native"; import { createElement, useCallback, type ComponentType } from "react"; import { SvgXml } from "react-native-svg"; import { TERMINAL_PROFILE_ICON_NAMES } from "@getpaseo/protocol/provider-icon-names"; +import { AntigravityIcon } from "@/components/icons/antigravity-icon"; import { ClaudeIcon } from "@/components/icons/claude-icon"; import { CodexIcon } from "@/components/icons/codex-icon"; import { CopilotIcon } from "@/components/icons/copilot-icon"; +import { KiroIcon } from "@/components/icons/kiro-icon"; import { MiniMaxIcon } from "@/components/icons/minimax-icon"; import { OpenCodeIcon } from "@/components/icons/opencode-icon"; import { OmpIcon } from "@/components/icons/omp-icon"; @@ -23,10 +25,11 @@ export interface ProviderIconProps { export type ProviderIconComponent = ComponentType; const BUILTIN_PROVIDER_ICONS: Record = { + antigravity: AntigravityIcon as unknown as ProviderIconComponent, claude: ClaudeIcon as unknown as ProviderIconComponent, codex: CodexIcon as unknown as ProviderIconComponent, copilot: CopilotIcon as unknown as ProviderIconComponent, - kiro: PackagePlus, + kiro: KiroIcon as unknown as ProviderIconComponent, minimax: MiniMaxIcon as unknown as ProviderIconComponent, omp: OmpIcon as unknown as ProviderIconComponent, opencode: OpenCodeIcon as unknown as ProviderIconComponent, diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index 1bd32db53d..f91a9540f2 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -133,7 +133,7 @@ const CATALOG_DATA = [ title: "Devin CLI", description: "Cognition's Devin for Terminal via Agent Client Protocol", version: "manual", - iconId: null, + iconId: "devin", installLink: "https://cli.devin.ai/docs", command: ["devin", "acp"], }, @@ -235,7 +235,7 @@ const CATALOG_DATA = [ title: "Hermes", description: "Nous Research self-improving AI agent", version: "manual", - iconId: null, + iconId: "hermes", installLink: "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp", command: ["hermes", "acp"], }, @@ -272,7 +272,7 @@ const CATALOG_DATA = [ title: "Kiro CLI", description: "Amazon's AI coding agent with native ACP support", version: "manual", - iconId: null, + iconId: "kiro", installLink: "https://kiro.dev/docs/cli/acp/", command: ["kiro-cli", "acp"], }, diff --git a/packages/app/src/hooks/use-acp-provider-catalog.test.ts b/packages/app/src/hooks/use-acp-provider-catalog.test.ts index 201c17c022..c5fd59d53e 100644 --- a/packages/app/src/hooks/use-acp-provider-catalog.test.ts +++ b/packages/app/src/hooks/use-acp-provider-catalog.test.ts @@ -11,6 +11,13 @@ function findProvider(id: string) { } describe("ACP provider catalog", () => { + it.each(["devin", "hermes", "kiro"])( + "#7087: %s supplies its provider mark to the catalog picker", + (provider) => { + expect(findProvider(provider).iconSvg).toContain(" { const ids = new Set(); diff --git a/packages/protocol/src/provider-icon-names.ts b/packages/protocol/src/provider-icon-names.ts index 4ca8ea44e3..5f707dfeff 100644 --- a/packages/protocol/src/provider-icon-names.ts +++ b/packages/protocol/src/provider-icon-names.ts @@ -1,4 +1,5 @@ export const BUILTIN_PROVIDER_ICON_NAMES = [ + "antigravity", "claude", "codex", "copilot", @@ -22,6 +23,7 @@ export const ACP_PROVIDER_ICON_NAMES = [ "crow-cli", "cursor", "deepagents", + "devin", "dimcode", "dirac", "factory-droid", @@ -31,6 +33,7 @@ export const ACP_PROVIDER_ICON_NAMES = [ "glm-acp-agent", "goose", "grok", + "hermes", "hoplite", "junie", "kilo", diff --git a/packages/website/e2e/agent-icons.spec.ts b/packages/website/e2e/agent-icons.spec.ts new file mode 100644 index 0000000000..4bc828475e --- /dev/null +++ b/packages/website/e2e/agent-icons.spec.ts @@ -0,0 +1,26 @@ +import { expect, test, type Page } from "playwright/test"; + +test("keeps unrelated agent artwork unchanged while adding the Hermes mark", async ({ page }) => { + await openSupportedAgents(page); + await expectGenericAgentMark(page, "CodeWhale"); + await expectVendoredAgentMark(page, "Hermes Agent"); +}); + +async function openSupportedAgents(page: Page) { + await page.goto("/agents"); + await expect(page.getByRole("heading", { level: 1, name: "Supported agents" })).toBeVisible(); +} + +async function expectGenericAgentMark(page: Page, name: string) { + const card = page.getByRole("link", { name, exact: true }); + await expect(card).toBeVisible(); + // Decorative SVGs have no accessible role; the fallback is Lucide's bot mark. + await expect(card.locator("svg.lucide-bot")).toBeVisible(); +} + +async function expectVendoredAgentMark(page: Page, name: string) { + const card = page.getByRole("link", { name, exact: true }); + await expect(card).toBeVisible(); + await expect(card.locator("svg")).toBeVisible(); + await expect(card.locator("svg.lucide-bot")).toHaveCount(0); +} diff --git a/packages/website/src/assets/agent-icons/hermes.svg b/packages/website/src/assets/agent-icons/hermes.svg new file mode 100644 index 0000000000..c6fdd22500 --- /dev/null +++ b/packages/website/src/assets/agent-icons/hermes.svg @@ -0,0 +1,3 @@ + + +