feat(ui): show search provider logos across search tools views

Reuse the provider logo label in the search tools table, detail view, and edit form, and fix the provider dropdown search, which matched nothing because antd stringified the JSX option children.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-08-18 06:57:38 +00:00
parent 4fd7a73ef5
commit 2d3be8dc7b
10 changed files with 210 additions and 39 deletions

View file

@ -0,0 +1,74 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
import CreateSearchTool from "./CreateSearchTools";
vi.mock("@/components/networking", () => ({
createSearchTool: vi.fn(),
fetchAvailableSearchProviders: vi.fn(async () => ({
providers: [
{ provider_name: "perplexity", ui_friendly_name: "Perplexity AI" },
{ provider_name: "searxng", ui_friendly_name: "SearXNG" },
],
})),
}));
const renderModal = () =>
renderWithProviders(
<CreateSearchTool
userRole="Admin"
accessToken="sk-test"
onCreateSuccess={vi.fn()}
isModalVisible
setModalVisible={vi.fn()}
/>,
);
const openProviderDropdown = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(await screen.findByRole("combobox"));
return screen.getByRole("listbox").parentElement as HTMLElement;
};
describe("CreateSearchTool provider dropdown", () => {
beforeEach(() => {
testQueryClient.clear();
});
it("keeps a provider listed when its slug is typed into the search box", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const dropdown = await openProviderDropdown(user);
expect(await within(dropdown).findByTitle("SearXNG")).toBeInTheDocument();
await user.type(screen.getByRole("combobox"), "searxng");
expect(within(dropdown).getByTitle("SearXNG")).toBeInTheDocument();
expect(within(dropdown).queryByTitle("Perplexity AI")).not.toBeInTheDocument();
});
it("keeps a provider listed when its display name is typed into the search box", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const dropdown = await openProviderDropdown(user);
await within(dropdown).findByTitle("Perplexity AI");
await user.type(screen.getByRole("combobox"), "perplexity ai");
expect(within(dropdown).getByTitle("Perplexity AI")).toBeInTheDocument();
expect(within(dropdown).queryByTitle("SearXNG")).not.toBeInTheDocument();
});
it("shows the provider logo in the dropdown options", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const dropdown = await openProviderDropdown(user);
expect(await within(dropdown).findByRole("img", { name: "Perplexity AI logo" })).toHaveAttribute(
"src",
expect.stringContaining("perplexity.png"),
);
});
});

View file

@ -4,43 +4,15 @@ import { useQuery } from "@tanstack/react-query";
import { Button, TextInput } from "@tremor/react";
import { Form, Input, Modal, Select, Tooltip, Typography } from "antd";
import React, { useState } from "react";
import { Logo } from "@/components/molecules/logo/Logo";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { createSearchTool, fetchAvailableSearchProviders } from "@/components/networking";
import SearchConnectionTest from "./SearchConnectionTest";
import { SearchProviderLabel } from "./SearchProviderLabel";
import { searchProviderFilterOption } from "./searchProviderFilterOption";
import { AvailableSearchProvider, SearchTool } from "./types";
import dataforseoLogo from "../../../../../public/assets/logos/dataforseo.png";
import exaAiLogo from "../../../../../public/assets/logos/exa_ai.png";
import googlePseLogo from "../../../../../public/assets/logos/google_pse.png";
import nimbleLogo from "../../../../../public/assets/logos/nimble.png";
import parallelAiLogo from "../../../../../public/assets/logos/parallel_ai.png";
import perplexityLogo from "../../../../../public/assets/logos/perplexity.png";
import tavilyLogo from "../../../../../public/assets/logos/tavily.png";
const { TextArea } = Input;
const searchProviderLogoMap: Record<string, string> = {
perplexity: perplexityLogo.src,
tavily: tavilyLogo.src,
parallel_ai: parallelAiLogo.src,
exa_ai: exaAiLogo.src,
google_pse: googlePseLogo.src,
dataforseo: dataforseoLogo.src,
nimble: nimbleLogo.src,
};
interface SearchProviderLabelProps {
providerName: string;
displayName: string;
}
export const SearchProviderLabel: React.FC<SearchProviderLabelProps> = ({ providerName, displayName }) => (
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Logo src={searchProviderLogoMap[providerName]} label={displayName} className="w-5 h-5 object-contain" />
<span>{displayName}</span>
</div>
);
interface CreateSearchToolProps {
userRole: string;
accessToken: string | null;
@ -212,13 +184,14 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
size="large"
loading={isLoadingProviders}
showSearch
optionFilterProp="children"
filterOption={searchProviderFilterOption}
optionLabelProp="label"
>
{availableProviders.map((provider) => (
<Select.Option
key={provider.provider_name}
value={provider.provider_name}
title={provider.ui_friendly_name}
label={
<SearchProviderLabel
providerName={provider.provider_name}

View file

@ -1,6 +1,6 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { SearchProviderLabel } from "./CreateSearchTools";
import { SearchProviderLabel } from "./SearchProviderLabel";
describe("SearchProviderLabel", () => {
it("renders the tavily logo from the static bundle, untouched by server-root prefixing", () => {

View file

@ -0,0 +1,38 @@
import React from "react";
import { Logo } from "@/components/molecules/logo/Logo";
import dataforseoLogo from "../../../../../public/assets/logos/dataforseo.png";
import exaAiLogo from "../../../../../public/assets/logos/exa_ai.png";
import googlePseLogo from "../../../../../public/assets/logos/google_pse.png";
import nimbleLogo from "../../../../../public/assets/logos/nimble.png";
import parallelAiLogo from "../../../../../public/assets/logos/parallel_ai.png";
import perplexityLogo from "../../../../../public/assets/logos/perplexity.png";
import tavilyLogo from "../../../../../public/assets/logos/tavily.png";
const searchProviderLogoMap: Readonly<Record<string, string>> = {
perplexity: perplexityLogo.src,
tavily: tavilyLogo.src,
parallel_ai: parallelAiLogo.src,
exa_ai: exaAiLogo.src,
google_pse: googlePseLogo.src,
dataforseo: dataforseoLogo.src,
nimble: nimbleLogo.src,
};
interface SearchProviderLabelProps {
providerName: string;
displayName: string;
className?: string;
logoClassName?: string;
}
export const SearchProviderLabel: React.FC<SearchProviderLabelProps> = ({
providerName,
displayName,
className = "text-sm",
logoClassName = "w-5 h-5 object-contain",
}) => (
<div className={`flex items-center gap-2 ${className}`}>
<Logo src={searchProviderLogoMap[providerName]} label={displayName} className={logoClassName} />
<span>{displayName}</span>
</div>
);

View file

@ -42,6 +42,27 @@ describe("SearchToolTable", () => {
expect(screen.getByText("DB")).toBeInTheDocument();
});
it("should show the provider logo next to the provider name", () => {
renderWithProviders(<SearchToolTable {...defaultProps} />);
expect(screen.getByRole("img", { name: "Perplexity AI logo" })).toHaveAttribute(
"src",
expect.stringContaining("perplexity.png"),
);
});
it("should fall back to a letter avatar for a provider with no bundled logo", () => {
const tool = makeSearchTool({ litellm_params: { search_provider: "searxng" } });
renderWithProviders(
<SearchToolTable
{...defaultProps}
searchTools={[tool]}
availableProviders={[{ provider_name: "searxng", ui_friendly_name: "SearXNG" }]}
/>,
);
expect(screen.queryByRole("img", { name: /logo/ })).not.toBeInTheDocument();
expect(screen.getByText("SearXNG")).toBeInTheDocument();
});
it("should call onView when the search tool ID is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<SearchToolTable {...defaultProps} />);

View file

@ -15,6 +15,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
import { SearchProviderLabel } from "./SearchProviderLabel";
import { AvailableSearchProvider, SearchTool } from "./types";
const CONFIG_EDIT_HINT = "Config search tools cannot be edited on the dashboard. Please edit the config file.";
@ -120,7 +121,7 @@ export const getSearchToolTableColumns = ({
cell: ({ row }) => {
const provider = row.original.litellm_params.search_provider;
const providerInfo = availableProviders.find((candidate) => candidate.provider_name === provider);
return <span className="text-sm">{providerInfo?.ui_friendly_name || provider}</span>;
return <SearchProviderLabel providerName={provider} displayName={providerInfo?.ui_friendly_name || provider} />;
},
},
{

View file

@ -3,6 +3,7 @@ import { ArrowLeft, Check, Copy } from "lucide-react";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { SearchProviderLabel } from "./SearchProviderLabel";
import { SearchToolTester } from "./SearchToolTester";
import { AvailableSearchProvider, SearchTool } from "./types";
@ -77,9 +78,12 @@ export const SearchToolView: React.FC<SearchToolViewProps> = ({
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Provider</p>
<p className="mt-2 text-lg font-semibold text-foreground">
{getProviderDisplayName(searchTool.litellm_params.search_provider)}
</p>
<SearchProviderLabel
className="mt-2 text-lg font-semibold text-foreground"
logoClassName="w-6 h-6 object-contain"
providerName={searchTool.litellm_params.search_provider}
displayName={getProviderDisplayName(searchTool.litellm_params.search_provider)}
/>
</CardContent>
</Card>

View file

@ -12,6 +12,8 @@ import {
updateSearchTool,
} from "@/components/networking";
import CreateSearchTool from "./CreateSearchTools";
import { SearchProviderLabel } from "./SearchProviderLabel";
import { searchProviderFilterOption } from "./searchProviderFilterOption";
import SearchToolTable from "./SearchToolTable";
import { SearchToolView } from "./SearchToolView";
import { AvailableSearchProvider, SearchTool } from "./types";
@ -168,10 +170,23 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
label="Search Provider"
rules={[{ required: true, message: "Please select a search provider" }]}
>
<Select placeholder="Select a search provider" loading={isLoadingProviders}>
<Select
placeholder="Select a search provider"
loading={isLoadingProviders}
showSearch
filterOption={searchProviderFilterOption}
optionLabelProp="label"
>
{availableProviders.map((provider) => (
<Select.Option key={provider.provider_name} value={provider.provider_name}>
{provider.ui_friendly_name}
<Select.Option
key={provider.provider_name}
value={provider.provider_name}
title={provider.ui_friendly_name}
label={
<SearchProviderLabel providerName={provider.provider_name} displayName={provider.ui_friendly_name} />
}
>
<SearchProviderLabel providerName={provider.provider_name} displayName={provider.ui_friendly_name} />
</Select.Option>
))}
</Select>

View file

@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { searchProviderFilterOption } from "./searchProviderFilterOption";
const searxng = { value: "searxng", title: "SearXNG" };
const googlePse = { value: "google_pse", title: "Google PSE" };
describe("searchProviderFilterOption", () => {
it("matches a provider by its slug", () => {
expect(searchProviderFilterOption("searxng", searxng)).toBe(true);
expect(searchProviderFilterOption("searxng", googlePse)).toBe(false);
});
it("matches a provider by its display name, case-insensitively", () => {
expect(searchProviderFilterOption("google pse", googlePse)).toBe(true);
expect(searchProviderFilterOption("SEARXNG", searxng)).toBe(true);
});
it("matches on a partial slug so results narrow as the user types", () => {
expect(searchProviderFilterOption("sear", searxng)).toBe(true);
expect(searchProviderFilterOption("sear", googlePse)).toBe(false);
});
it("keeps every option when the query is empty or whitespace", () => {
expect(searchProviderFilterOption("", googlePse)).toBe(true);
expect(searchProviderFilterOption(" ", googlePse)).toBe(true);
});
it("does not match an option that has no slug or title", () => {
expect(searchProviderFilterOption("searxng", {})).toBe(false);
expect(searchProviderFilterOption("searxng", undefined)).toBe(false);
});
});

View file

@ -0,0 +1,13 @@
interface SearchProviderOption {
value?: string | number | null;
title?: string;
}
// antd's default filter stringifies option children, which are React elements here, so it matches nothing
export const searchProviderFilterOption = (input: string, option?: SearchProviderOption): boolean => {
const needle = input.trim().toLowerCase();
if (!needle) return true;
return [option?.value, option?.title].some(
(part) => typeof part === "string" && part.toLowerCase().includes(needle),
);
};