feat(FN-3618): surface provider icons in settings and dropdowns

Completes Step 3 of FN-3618 by surfacing AI provider icons throughout the Settings modal and model dropdowns, updating both the component styles and adding integration and unit tests to cover the new icon display behavior.

Fusion-Task-Id: FN-3618
This commit is contained in:
Fusion
2026-05-06 21:23:42 -07:00
committed by gsxdsm
parent 81e47e50eb
commit 46f3ac2114
8 changed files with 129 additions and 5 deletions

View File

@@ -216,6 +216,38 @@ function OpencodeIcon({ size, color, label = "Opencode" }: { size: number; color
);
}
function DeepSeekIcon({ size, color, label = "DeepSeek" }: { 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="deepseek-icon"
aria-label={label}
>
<path d="M4 12a8 8 0 1 1 12.7 6.4L12 16h4a4 4 0 1 0-1.6 3.2L12 22l7-1-1.2-3.6A10 10 0 1 0 2 12z" fill={color} />
</svg>
);
}
function CloudflareIcon({ size, color, label = "Cloudflare" }: { 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="cloudflare-icon"
aria-label={label}
>
<path d="M7 16.5h10.8a2.9 2.9 0 0 0 .3-5.8 4.9 4.9 0 0 0-9.3-1.6A3.6 3.6 0 0 0 7 16.5m-1.9 0h3.2a2.5 2.5 0 0 0 .2-5 3.4 3.4 0 0 0-3.4 3.4c0 .6 0 1 .2 1.6" fill={color} />
</svg>
);
}
// Qwen / Tongyi Qianwen monogram — stylized "Q" with the tail piercing
// the ring, a recognizable simplification of the official mark.
function QwenIcon({ size, color, label = "Qwen" }: { size: number; color: string; label?: string }) {
@@ -701,6 +733,13 @@ const providerConfig: Record<
opencode: { component: OpencodeIcon, color: "var(--provider-opencode)" },
"opencode-go": { component: OpencodeIcon, color: "var(--provider-opencode)", label: "Opencode (Go)" },
deepseek: { component: DeepSeekIcon, color: "var(--provider-deepseek)", label: "DeepSeek" },
"deepseek-ai": { component: DeepSeekIcon, color: "var(--provider-deepseek)", label: "DeepSeek" },
"deep-seek": { component: DeepSeekIcon, color: "var(--provider-deepseek)", label: "DeepSeek" },
cloudflare: { component: CloudflareIcon, color: "var(--provider-cloudflare)", label: "Cloudflare" },
cloudflared: { component: CloudflareIcon, color: "var(--provider-cloudflare)", label: "Cloudflare" },
qwen: { component: QwenIcon, color: "var(--provider-qwen)" },
"qwen-ai": { component: QwenIcon, color: "var(--provider-qwen)", label: "Qwen" },
"qwen-coder": { component: QwenIcon, color: "var(--provider-qwen)", label: "Qwen Coder" },

View File

@@ -585,6 +585,12 @@
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
}
.remote-provider-option-content {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.remote-provider-option input:checked + span {
background: var(--card);
color: var(--text);

View File

@@ -4693,11 +4693,25 @@ export function SettingsModal({
<div className="remote-provider-selector" role="radiogroup" aria-label="Remote provider">
<label className="remote-provider-option">
<input type="radio" name="remoteProvider" value="tailscale" checked={activeProvider === "tailscale"} onChange={() => setForm((f) => ({ ...f, remoteActiveProvider: "tailscale" } as SettingsFormState))} />
<span>Tailscale</span>
<span>
<span className="remote-provider-option-content">
<span data-testid="remote-provider-icon-tailscale" aria-hidden="true"><Globe size={16} /></span>
<span>Tailscale</span>
</span>
</span>
</label>
<label className="remote-provider-option">
<input type="radio" name="remoteProvider" value="cloudflare" checked={activeProvider === "cloudflare"} onChange={() => setForm((f) => ({ ...f, remoteActiveProvider: "cloudflare" } as SettingsFormState))} />
<span>Cloudflare</span>
<span>
<span className="remote-provider-option-content">
<span data-testid="remote-provider-icon-cloudflare" aria-hidden="true" className="remote-provider-option-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" data-testid="remote-cloudflare-option-icon">
<path d="M7 16.5h10.8a2.9 2.9 0 0 0 .3-5.8 4.9 4.9 0 0 0-9.3-1.6A3.6 3.6 0 0 0 7 16.5m-1.9 0h3.2a2.5 2.5 0 0 0 .2-5 3.4 3.4 0 0 0-3.4 3.4c0 .6 0 1 .2 1.6" fill="var(--provider-cloudflare)" />
</svg>
</span>
<span>Cloudflare</span>
</span>
</span>
</label>
</div>
{!activeProvider && <small>Select a provider above to configure remote access.</small>}

View File

@@ -11,6 +11,7 @@ const MOCK_MODELS: ModelInfo[] = [
{ provider: "ollama", id: "llama3", name: "Llama 3", reasoning: false, contextWindow: 4096 },
{ provider: "kimi", id: "moonshot-v1-8k", name: "Moonshot V1 8K", reasoning: false, contextWindow: 8192 },
{ provider: "moonshot", id: "moonshot-v1-32k", name: "Moonshot V1 32K", reasoning: false, contextWindow: 32768 },
{ provider: "deepseek", id: "deepseek-chat", name: "DeepSeek Chat", reasoning: false, contextWindow: 64000 },
];
const defaultProps = {
@@ -190,4 +191,20 @@ describe("CustomModelDropdown ProviderIcon Integration", () => {
expect(kimiIcon).toHaveAttribute("aria-label", "Kimi");
expect(kimiIcon.querySelector("path")).toHaveAttribute("fill", "var(--provider-kimi)");
});
it("renders DeepSeek icon in trigger and dropdown group header", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} value="deepseek/deepseek-chat" />);
expect(screen.getByTestId("deepseek-icon")).toBeInTheDocument();
await user.click(screen.getByLabelText("Test Model"));
const deepseekIcons = screen.getAllByTestId("deepseek-icon");
expect(deepseekIcons.length).toBeGreaterThanOrEqual(2);
deepseekIcons.forEach((icon) => {
expect(icon).toHaveAttribute("aria-label", "DeepSeek");
expect(icon.querySelector("path")).toHaveAttribute("fill", "var(--provider-deepseek)");
});
});
});

View File

@@ -632,4 +632,24 @@ describe("ProviderIcon", () => {
render(<ProviderIcon provider="opencode-go" />);
expect(screen.getByTestId("opencode-icon")).toBeInTheDocument();
});
it("renders DeepSeek icon for deepseek aliases", () => {
for (const provider of ["deepseek", "deepseek-ai", "deep-seek"]) {
const { unmount } = render(<ProviderIcon provider={provider} />);
expect(screen.getByTestId("deepseek-icon")).toBeInTheDocument();
expect(screen.getByLabelText("DeepSeek")).toBeInTheDocument();
expect(screen.getByTestId("deepseek-icon").parentElement).toHaveStyle({ color: "var(--provider-deepseek)" });
unmount();
}
});
it("renders Cloudflare icon for cloudflare aliases", () => {
for (const provider of ["cloudflare", "cloudflared"]) {
const { unmount } = render(<ProviderIcon provider={provider} />);
expect(screen.getByTestId("cloudflare-icon")).toBeInTheDocument();
expect(screen.getByLabelText("Cloudflare")).toBeInTheDocument();
expect(screen.getByTestId("cloudflare-icon").parentElement).toHaveStyle({ color: "var(--provider-cloudflare)" });
unmount();
}
});
});

View File

@@ -982,6 +982,7 @@ describe("SettingsModal", () => {
providers: [
{ id: "github", name: "GitHub", authenticated: true, type: "oauth" },
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
{ id: "cloudflare", name: "Cloudflare", authenticated: false, type: "api_key" },
],
});
@@ -990,6 +991,8 @@ describe("SettingsModal", () => {
expect(screen.getByTestId("auth-provider-icon-github")).toBeInTheDocument();
expect(screen.getByTestId("auth-provider-icon-openai")).toBeInTheDocument();
expect(screen.getByTestId("auth-provider-icon-cloudflare")).toBeInTheDocument();
expect(within(screen.getByTestId("auth-provider-icon-cloudflare")).getByTestId("cloudflare-icon")).toBeInTheDocument();
expect(screen.getByTestId("auth-status-github")).toHaveTextContent("✓ Active");
expect(screen.getByTestId("auth-status-openai")).toHaveTextContent("✗ Not connected");
});
@@ -2289,6 +2292,15 @@ describe("SettingsModal", () => {
expect(screen.queryByRole("button", { name: "Install cloudflared" })).not.toBeInTheDocument();
});
it("renders branded Cloudflare icon in Remote provider selector", async () => {
renderModal();
await waitForSettingsModalReady();
await openRemoteSection();
const cloudflareSlot = screen.getByTestId("remote-provider-icon-cloudflare");
expect(within(cloudflareSlot).getByTestId("remote-cloudflare-option-icon")).toBeInTheDocument();
});
it("shows install button when Cloudflare is selected and cloudflared is not available", async () => {
mockFetchRemoteStatus.mockResolvedValue({ provider: "cloudflare", state: "stopped", url: null, lastError: null, cloudflaredAvailable: false });

View File

@@ -157,15 +157,26 @@ export function useMobileKeyboard(
setKeyboardOpen(metrics.open);
};
const timeoutIds: ReturnType<typeof setTimeout>[] = [];
const scheduleUpdate = (delayMs: number) => {
if (typeof window === "undefined") return;
const timeoutId = window.setTimeout(() => {
if (typeof window === "undefined") return;
update();
}, delayMs);
timeoutIds.push(timeoutId);
};
// Re-snapshot once iOS has settled. focusin/page-restore frequently
// fire while the visualViewport is still mid-transition; the
// synchronous read captures stale offsetTop and the chat-thread
// anchors wrong. A short tail of updates catches the settled value.
const updateWithTail = () => {
update();
window.setTimeout(update, 50);
window.setTimeout(update, 200);
window.setTimeout(update, 500);
scheduleUpdate(50);
scheduleUpdate(200);
scheduleUpdate(500);
};
updateWithTail();
@@ -186,6 +197,9 @@ export function useMobileKeyboard(
document.removeEventListener("focusout", update);
document.removeEventListener("visibilitychange", updateWithTail);
window.removeEventListener("pageshow", updateWithTail);
for (const timeoutId of timeoutIds) {
clearTimeout(timeoutId);
}
setKeyboardOverlap(0);
setViewportHeight(null);
setViewportOffsetTop(0);

View File

@@ -260,6 +260,8 @@ html {
--provider-kimi: #6c5ce7;
--provider-bedrock: #ff9900;
--provider-opencode: #38bdf8;
--provider-deepseek: #4d6bfe;
--provider-cloudflare: #f38020;
--provider-qwen: #6149e1;
--provider-lmstudio: #ec4899;
--provider-huggingface: #ffd21e;