feat(FN-2590): add xAI and opencode provider icon support

- Add dedicated xAI and Opencode SVG icon components in ProviderIcon with provider config entries
- Map grok to the xAI icon and add opencode color token for consistent themed rendering
- Extend UsageIndicator provider normalization to resolve xai/grok and opencode icon keys
- Add ProviderIcon tests for xai, grok alias behavior, and opencode rendering/color normalization
This commit is contained in:
Fusion
2026-04-26 10:03:46 -07:00
committed by gsxdsm
parent 9450abdaab
commit a7ff534a25
4 changed files with 119 additions and 1 deletions

View File

@@ -155,6 +155,44 @@ function KimiIcon({ size, color, label = "Kimi" }: { size: number; color: string
);
}
function XaiIcon({ size, color, label = "xAI" }: { size: number; color: string; label?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
data-testid="xai-icon"
aria-label={label}
>
<path
d="M2.21 3l7.964 11.386L2.13 21h1.974l6.843-7.07L17.165 21H22.1l-8.392-11.97L21.17 3h-1.974l-6.349 6.56L6.845 3z"
fill={color}
/>
</svg>
);
}
function OpencodeIcon({ size, color, label = "Opencode" }: { size: number; color: string; label?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
data-testid="opencode-icon"
aria-label={label}
>
<path
d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6z"
fill={color}
/>
</svg>
);
}
// OpenRouter ring mark — simplified geometric version of the OpenRouter brand symbol.
function OpenRouterIcon({ size, color, label = "OpenRouter" }: { size: number; color: string; label?: string }) {
return (
@@ -258,6 +296,11 @@ const providerConfig: Record<
kimi: { component: KimiIcon, color: "var(--provider-kimi)" },
moonshot: { component: KimiIcon, color: "var(--provider-kimi)" }, // Moonshot alias
"kimi-coding": { component: KimiIcon, color: "var(--provider-kimi)", label: "Kimi" }, // Kimi alias
xai: { component: XaiIcon, color: "var(--text)" },
grok: { component: XaiIcon, color: "var(--text)", label: "xAI" },
opencode: { component: OpencodeIcon, color: "var(--provider-opencode)" },
};
export function ProviderIcon({ provider, size = "sm" }: ProviderIconProps) {

View File

@@ -238,7 +238,13 @@ function getProviderIconKey(providerName: string): string {
if (normalized.includes('kimi') || normalized.includes('moonshot')) {
return 'kimi';
}
if (normalized.includes('xai') || normalized.includes('grok')) {
return 'xai';
}
if (normalized.includes('opencode')) {
return 'opencode';
}
// Return the original name as fallback (ProviderIcon will show a default icon)
return providerName;
}

View File

@@ -355,6 +355,74 @@ describe("ProviderIcon", () => {
expect(paths[0]).toHaveAttribute("fill", "var(--provider-kimi)");
});
// xAI provider tests
it("renders xAI brand icon for xai provider", () => {
render(<ProviderIcon provider="xai" />);
expect(screen.getByTestId("xai-icon")).toBeInTheDocument();
expect(screen.getByLabelText("xAI")).toBeInTheDocument();
});
it("applies theme-safe color for xai", () => {
render(<ProviderIcon provider="xai" />);
const icon = screen.getByTestId("xai-icon").parentElement;
expect(icon).toHaveStyle({ color: "var(--text)" });
});
it("passes correct color to SVG fill for xai", () => {
render(<ProviderIcon provider="xai" />);
const svg = screen.getByTestId("xai-icon");
const paths = svg.querySelectorAll("path");
expect(paths.length).toBeGreaterThan(0);
expect(paths[0]).toHaveAttribute("fill", "var(--text)");
});
it("normalizes Xai (capitalized) to xai", () => {
render(<ProviderIcon provider="Xai" />);
expect(screen.getByTestId("xai-icon")).toBeInTheDocument();
const wrapper = screen.getByTestId("xai-icon").parentElement;
expect(wrapper).toHaveAttribute("data-provider", "xai");
});
it("renders xAI brand icon for grok provider (alias)", () => {
render(<ProviderIcon provider="grok" />);
expect(screen.getByTestId("xai-icon")).toBeInTheDocument();
expect(screen.getByLabelText("xAI")).toBeInTheDocument();
});
it("applies theme-safe color for grok (alias)", () => {
render(<ProviderIcon provider="grok" />);
const icon = screen.getByTestId("xai-icon").parentElement;
expect(icon).toHaveStyle({ color: "var(--text)" });
});
// Opencode provider tests
it("renders Opencode brand icon for opencode provider", () => {
render(<ProviderIcon provider="opencode" />);
expect(screen.getByTestId("opencode-icon")).toBeInTheDocument();
expect(screen.getByLabelText("Opencode")).toBeInTheDocument();
});
it("applies provider-specific color for opencode", () => {
render(<ProviderIcon provider="opencode" />);
const icon = screen.getByTestId("opencode-icon").parentElement;
expect(icon).toHaveStyle({ color: "var(--provider-opencode)" });
});
it("passes correct color to SVG fill for opencode", () => {
render(<ProviderIcon provider="opencode" />);
const svg = screen.getByTestId("opencode-icon");
const paths = svg.querySelectorAll("path");
expect(paths.length).toBeGreaterThan(0);
expect(paths[0]).toHaveAttribute("fill", "var(--provider-opencode)");
});
it("normalizes Opencode (capitalized) to opencode", () => {
render(<ProviderIcon provider="Opencode" />);
expect(screen.getByTestId("opencode-icon")).toBeInTheDocument();
const wrapper = screen.getByTestId("opencode-icon").parentElement;
expect(wrapper).toHaveAttribute("data-provider", "opencode");
});
// Regression test: verify the Kimi icon is the crescent moon shape, not the old "K" placeholder
it("renders crescent moon icon geometry (not the old K placeholder)", () => {
render(<ProviderIcon provider="kimi" />);

View File

@@ -169,6 +169,7 @@
--provider-minimax: #e73562;
--provider-zai: #1a6dff;
--provider-kimi: #6c5ce7;
--provider-opencode: #38bdf8;
--provider-icon-contrast: var(--bg);
/* Task-creation CTA tokens */