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:
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user