diff --git a/src/components/shared/ComponentDetail/ComponentDetail.tsx b/src/components/shared/ComponentDetail/ComponentDetail.tsx
index ca972b380..33d39d281 100644
--- a/src/components/shared/ComponentDetail/ComponentDetail.tsx
+++ b/src/components/shared/ComponentDetail/ComponentDetail.tsx
@@ -183,12 +183,15 @@ interface ComponentDetailProps {
* top nav). In `stacked` layout this caps the inline source card's height.
*/
sourcePanelHeight?: string;
+ /** Hide the source-authored description when the caller renders its own description panel. */
+ hideDescription?: boolean;
}
export const ComponentDetail = ({
reference,
layout = "split",
sourcePanelHeight,
+ hideDescription = false,
}: ComponentDetailProps) => {
const hydrated = useHydrateComponentReference(reference);
@@ -305,7 +308,7 @@ export const ComponentDetail = ({
return (
{header}
- {description}
+ {!hideDescription && description}
{githubLinks}
{io}
{hydrated.text && (
@@ -331,7 +334,7 @@ export const ComponentDetail = ({
{header}
- {description}
+ {!hideDescription && description}
{githubLinks}
{io}
diff --git a/src/flags.ts b/src/flags.ts
index 3eea0a8d2..fed24bb0c 100644
--- a/src/flags.ts
+++ b/src/flags.ts
@@ -70,4 +70,12 @@ export const ExistingFlags: ConfigFlags = {
default: false,
category: "beta",
},
+
+ ["component-search-v2-ai-descriptions"]: {
+ name: "Auto-generate Components V2 AI descriptions",
+ description:
+ "Automatically generate an AI description when viewing a component in Components V2.",
+ default: false,
+ category: "beta",
+ },
};
diff --git a/src/hooks/useNaturalLanguageComponentSearch.ts b/src/hooks/useNaturalLanguageComponentSearch.ts
index 37ae09db1..f0f4a219b 100644
--- a/src/hooks/useNaturalLanguageComponentSearch.ts
+++ b/src/hooks/useNaturalLanguageComponentSearch.ts
@@ -2,16 +2,23 @@ import { useMutation } from "@tanstack/react-query";
import { useComponentSearchSettings } from "@/hooks/useComponentSearchSettings";
import {
+ type ComponentDescriptionResult,
+ generateComponentAiDescription,
type RerankCandidate,
rerankComponentsByNaturalLanguage,
type RerankResult,
} from "@/services/naturalLanguageComponentSearchService";
+import type { ComponentReference } from "@/utils/componentSpec";
interface RerankVariables {
query: string;
candidates: RerankCandidate[];
}
+interface DescriptionVariables {
+ reference: ComponentReference;
+}
+
/**
* Trigger an LLM rerank of pre-filtered candidates. Modeled as a mutation
* rather than a query because rerank is **explicitly initiated** by the user
@@ -36,3 +43,22 @@ export function useNaturalLanguageComponentRerank() {
return { ...mutation, isConfigured };
}
+
+export function useComponentAiDescription() {
+ const { config, isConfigured } = useComponentSearchSettings();
+
+ const mutation = useMutation<
+ ComponentDescriptionResult,
+ Error,
+ DescriptionVariables
+ >({
+ mutationFn: ({ reference }) =>
+ generateComponentAiDescription(reference, {
+ model: config.model,
+ apiBase: config.apiBase,
+ apiKey: config.apiKey,
+ }),
+ });
+
+ return { ...mutation, isConfigured };
+}
diff --git a/src/routes/Dashboard/DashboardComponentsV2SourceFilter.tsx b/src/routes/Dashboard/DashboardComponentsV2SourceFilter.tsx
new file mode 100644
index 000000000..7d39a2d84
--- /dev/null
+++ b/src/routes/Dashboard/DashboardComponentsV2SourceFilter.tsx
@@ -0,0 +1,152 @@
+import { Button } from "@/components/ui/button";
+import { Icon } from "@/components/ui/icon";
+import { BlockStack, InlineStack } from "@/components/ui/layout";
+import { Paragraph, Text } from "@/components/ui/typography";
+import { cn } from "@/lib/utils";
+import type {
+ ComponentSearchSource,
+ IndexEntry,
+} from "@/services/componentSearchIndex";
+import { tracking } from "@/utils/tracking";
+
+const SOURCE_ICON_TONE_BY_KIND: Record =
+ {
+ standard: "text-blue-500",
+ published: "text-emerald-500",
+ registered: "text-violet-500",
+ user: "text-amber-500",
+ };
+
+const SOURCE_FILTER_LABEL_BY_KIND: Record<
+ ComponentSearchSource["kind"],
+ string
+> = {
+ standard: "Standard",
+ published: "Published",
+ registered: "Registered libraries",
+ user: "User generated",
+};
+
+export interface SourceFilterOption {
+ source: ComponentSearchSource;
+ count: number;
+}
+
+function sourceFilterKey(source: ComponentSearchSource): string {
+ return source.kind;
+}
+
+/**
+ * Filter state is keyed by source kind so multiple registered libraries behave
+ * as one source category instead of several per-library toggles.
+ */
+export function createSourceFilterOptions(
+ index: IndexEntry[],
+): SourceFilterOption[] {
+ const optionsByKey = new Map();
+
+ for (const entry of index) {
+ const key = sourceFilterKey(entry.source);
+ const option = optionsByKey.get(key);
+ if (option) {
+ option.count += 1;
+ } else {
+ optionsByKey.set(key, {
+ source: {
+ kind: entry.source.kind,
+ id: entry.source.kind,
+ label: SOURCE_FILTER_LABEL_BY_KIND[entry.source.kind],
+ },
+ count: 1,
+ });
+ }
+ }
+
+ return Array.from(optionsByKey.values());
+}
+
+/** Apply source-type filter state to indexed component results. */
+export function filterIndexByDisabledSourceKeys(
+ index: IndexEntry[],
+ disabledSourceKeys: string[],
+): IndexEntry[] {
+ const disabled = new Set(disabledSourceKeys);
+ return index.filter((entry) => !disabled.has(sourceFilterKey(entry.source)));
+}
+
+interface SourceFilterBarProps {
+ options: SourceFilterOption[];
+ disabledSourceKeys: string[];
+ onToggle: (sourceKey: string) => void;
+ onEnableAll: () => void;
+}
+
+export const SourceFilterBar = ({
+ options,
+ disabledSourceKeys,
+ onToggle,
+ onEnableAll,
+}: SourceFilterBarProps) => {
+ const disabled = new Set(disabledSourceKeys);
+ const activeCount = options.filter(
+ (option) => !disabled.has(sourceFilterKey(option.source)),
+ ).length;
+
+ if (options.length <= 1 && activeCount === options.length) return null;
+
+ return (
+
+
+
+ Sources
+
+ {options.map(({ source, count }) => {
+ const key = sourceFilterKey(source);
+ const active = !disabled.has(key);
+ return (
+
+ );
+ })}
+ {activeCount < options.length && (
+
+ )}
+
+ {activeCount === 0 && (
+
+ No sources selected. Turn on at least one source to show components.
+
+ )}
+
+ );
+};
diff --git a/src/routes/Dashboard/DashboardComponentsV2View.test.tsx b/src/routes/Dashboard/DashboardComponentsV2View.test.tsx
index 58e694209..f77981436 100644
--- a/src/routes/Dashboard/DashboardComponentsV2View.test.tsx
+++ b/src/routes/Dashboard/DashboardComponentsV2View.test.tsx
@@ -1,12 +1,180 @@
-import { fireEvent, render, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import type { StoredLibrary } from "@/providers/ComponentLibraryProvider/libraries/storage";
+import type { IndexEntry } from "@/services/componentSearchIndex";
+import type { ComponentReference } from "@/utils/componentSpec";
+
+interface DashboardComponentsV2Search {
+ component?: string;
+}
+
+const routeMocks = vi.hoisted(() => {
+ const makeComponent = (digest: string, name: string): ComponentReference => ({
+ digest,
+ name,
+ text: "component yaml",
+ spec: {
+ name,
+ description: `${name} description`,
+ implementation: { container: { image: "python:3.11" } },
+ },
+ });
+
+ const search: DashboardComponentsV2Search = {};
+ const descriptionErrorState: { current: Error | null } = { current: null };
+
+ return {
+ standard: makeComponent("standard-digest", "Standard component"),
+ registered: makeComponent("registered-digest", "Registered component"),
+ user: makeComponent("user-digest", "User component"),
+ navigate: vi.fn(),
+ notify: vi.fn(),
+ generateAiDescription: vi.fn(),
+ resetAiDescription: vi.fn(),
+ descriptionErrorState,
+ aiDescriptionsEnabled: false,
+ search,
+ };
+});
+
+vi.mock("@tanstack/react-query", () => ({
+ queryOptions: (options: unknown) => options,
+ useQueryClient: () => ({ ensureQueryData: vi.fn() }),
+ useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => {
+ const key = queryKey[0];
+
+ if (key === "componentLibrary") {
+ return {
+ data: {
+ name: "Standard",
+ components: [routeMocks.standard],
+ folders: [],
+ },
+ isLoading: false,
+ };
+ }
+
+ if (key === "userComponents") {
+ return {
+ data: { components: [routeMocks.user] },
+ isLoading: false,
+ };
+ }
+
+ if (key === "component-search-v2" && queryKey[1] === "published") {
+ return { data: [], isLoading: false };
+ }
+
+ if (
+ key === "component-search-v2" &&
+ queryKey[1] === "registered-libraries"
+ ) {
+ return {
+ data: [
+ {
+ reference: routeMocks.registered,
+ source: {
+ kind: "registered",
+ label: "GitHub library",
+ id: "github-lib",
+ },
+ },
+ ],
+ isLoading: false,
+ };
+ }
+
+ if (key === "component-search-v2" && queryKey[1] === "hydrate-library") {
+ return {
+ data: [routeMocks.standard, routeMocks.registered, routeMocks.user],
+ isLoading: false,
+ };
+ }
+
+ return { data: undefined, isLoading: false };
+ },
+}));
+
+vi.mock("@tanstack/react-router", () => ({
+ Link: ({ children }: { children: React.ReactNode }) => {children},
+ useNavigate: () => routeMocks.navigate,
+ useSearch: () => routeMocks.search,
+}));
+
+vi.mock("dexie-react-hooks", () => ({
+ useLiveQuery: () => [],
+}));
+
+vi.mock("@/providers/BackendProvider", () => ({
+ useBackend: () => ({
+ backendUrl: "https://backend.example",
+ configured: false,
+ available: false,
+ }),
+}));
+
+vi.mock("@/components/shared/Settings/useFlags", () => ({
+ useFlagValue: () => routeMocks.aiDescriptionsEnabled,
+}));
+
+vi.mock("@/hooks/useNaturalLanguageComponentSearch", () => ({
+ useComponentAiDescription: () => ({
+ mutate: routeMocks.generateAiDescription,
+ isPending: false,
+ error: routeMocks.descriptionErrorState.current,
+ reset: routeMocks.resetAiDescription,
+ isConfigured: true,
+ }),
+ useNaturalLanguageComponentRerank: () => ({
+ mutate: vi.fn(),
+ data: undefined,
+ isPending: false,
+ error: null,
+ reset: vi.fn(),
+ isConfigured: false,
+ }),
+}));
+
+vi.mock("@/hooks/useToastNotification", () => ({
+ default: () => routeMocks.notify,
+}));
+
+vi.mock("@/components/shared/ComponentDetail/ComponentDetail", () => ({
+ ComponentDetail: () => Component detail
,
+ ComponentDetailSkeleton: () => Loading component detail
,
+}));
+
+vi.mock("@/components/shared/SuspenseWrapper", () => ({
+ SuspenseWrapper: ({ children }: { children: React.ReactNode }) => (
+ <>{children}>
+ ),
+ withSuspenseWrapper:
+ (Component: React.ComponentType) => (props: Record) => (
+
+ ),
+}));
+
+vi.mock("../v2/shared/clipboard/copyComponentReferenceToClipboard", () => ({
+ copyComponentReferenceToClipboard: vi.fn(),
+}));
+
+vi.mock("../router", () => ({
+ APP_ROUTES: {
+ DASHBOARD_COMPONENTS_V2: "/dashboard/components-v2",
+ SETTINGS_AGENT: "/settings/agent",
+ },
+}));
import {
- createRegisteredLibrariesFingerprint,
+ createSourceFilterOptions,
+ filterIndexByDisabledSourceKeys,
SourceFilterBar,
type SourceFilterOption,
+} from "./DashboardComponentsV2SourceFilter";
+import {
+ createRegisteredLibrariesFingerprint,
+ DashboardComponentsV2View,
} from "./DashboardComponentsV2View";
const options: SourceFilterOption[] = [
@@ -15,15 +183,37 @@ const options: SourceFilterOption[] = [
count: 2,
},
{
- source: { kind: "registered", label: "GitHub", id: "github-lib" },
+ source: {
+ kind: "registered",
+ label: "Registered libraries",
+ id: "registered",
+ },
count: 3,
},
{
- source: { kind: "user", label: "User", id: "user" },
+ source: { kind: "user", label: "User generated", id: "user" },
count: 1,
},
];
+function createIndexEntry(
+ digest: string,
+ source: IndexEntry["source"],
+): IndexEntry {
+ return {
+ digest,
+ source,
+ reference: { digest },
+ name: digest,
+ searchable: {
+ name: digest,
+ description: "",
+ io: "",
+ implementation: "",
+ },
+ };
+}
+
describe("createRegisteredLibrariesFingerprint", () => {
it("excludes secret-bearing library configuration", () => {
const libraries: StoredLibrary[] = [
@@ -47,17 +237,103 @@ describe("createRegisteredLibrariesFingerprint", () => {
expect(fingerprint).not.toContain("ghp_secret-token");
expect(fingerprint).not.toContain("access_token");
});
+
+ it("changes when known digest values change without count changes", () => {
+ const createLibrary = (knownDigests: string[]): StoredLibrary => ({
+ id: "github-lib",
+ name: "GitHub",
+ type: "github",
+ knownDigests,
+ configuration: {
+ repo_name: "owner/repo",
+ last_updated_at: "2026-01-01T00:00:00Z",
+ access_token: "ghp_secret-token",
+ auto_update: true,
+ },
+ });
+
+ expect(
+ createRegisteredLibrariesFingerprint([createLibrary(["a", "b"])]),
+ ).not.toEqual(
+ createRegisteredLibrariesFingerprint([createLibrary(["c", "d"])]),
+ );
+ });
+});
+
+describe("createSourceFilterOptions", () => {
+ it("groups multiple registered libraries into one registered filter", () => {
+ const result = createSourceFilterOptions([
+ createIndexEntry("github-component", {
+ kind: "registered",
+ label: "GitHub library",
+ id: "github-lib",
+ }),
+ createIndexEntry("other-registered-component", {
+ kind: "registered",
+ label: "Other registered library",
+ id: "other-lib",
+ }),
+ createIndexEntry("user-component", {
+ kind: "user",
+ label: "User",
+ id: "user",
+ }),
+ ]);
+
+ expect(result).toEqual(
+ expect.arrayContaining([
+ {
+ source: {
+ kind: "registered",
+ label: "Registered libraries",
+ id: "registered",
+ },
+ count: 2,
+ },
+ {
+ source: {
+ kind: "user",
+ label: "User generated",
+ id: "user",
+ },
+ count: 1,
+ },
+ ]),
+ );
+ });
+});
+
+describe("filterIndexByDisabledSourceKeys", () => {
+ it("removes results from disabled source types", () => {
+ const result = filterIndexByDisabledSourceKeys(
+ [
+ createIndexEntry("registered-component", {
+ kind: "registered",
+ label: "Registered library",
+ id: "registered-lib",
+ }),
+ createIndexEntry("user-component", {
+ kind: "user",
+ label: "User",
+ id: "user",
+ }),
+ ],
+ ["registered"],
+ );
+
+ expect(result.map((entry) => entry.digest)).toEqual(["user-component"]);
+ });
});
describe("SourceFilterBar", () => {
- it("toggles source buttons and exposes active state", () => {
+ it("toggles source type buttons and exposes active state", () => {
const onToggle = vi.fn();
const onEnableAll = vi.fn();
render(
,
@@ -65,19 +341,97 @@ describe("SourceFilterBar", () => {
expect(
screen.getByRole("button", {
- name: "Hide Standard source (2 components)",
+ name: "Standard source (2 components)",
}),
).toHaveAttribute("aria-pressed", "true");
expect(
- screen.getByRole("button", { name: "Show GitHub source (3 components)" }),
+ screen.getByRole("button", {
+ name: "Registered libraries source (3 components)",
+ }),
).toHaveAttribute("aria-pressed", "false");
fireEvent.click(
- screen.getByRole("button", { name: "Show GitHub source (3 components)" }),
+ screen.getByRole("button", {
+ name: "Registered libraries source (3 components)",
+ }),
);
- expect(onToggle).toHaveBeenCalledWith("registered:github-lib");
+ expect(onToggle).toHaveBeenCalledWith("registered");
fireEvent.click(screen.getByRole("button", { name: "Show all" }));
expect(onEnableAll).toHaveBeenCalledTimes(1);
});
});
+
+describe("DashboardComponentsV2View", () => {
+ beforeEach(() => {
+ routeMocks.aiDescriptionsEnabled = false;
+ routeMocks.descriptionErrorState.current = null;
+ routeMocks.search = {};
+ routeMocks.generateAiDescription.mockClear();
+ routeMocks.resetAiDescription.mockClear();
+ });
+
+ it("filters visible component results by source type and restores them", () => {
+ render();
+
+ expect(screen.getByText("Registered component")).toBeInTheDocument();
+ expect(screen.getByText("Standard component")).toBeInTheDocument();
+ expect(screen.getByText("User component")).toBeInTheDocument();
+
+ fireEvent.click(
+ screen.getByRole("button", {
+ name: "Registered libraries source (1 component)",
+ }),
+ );
+
+ expect(screen.queryByText("Registered component")).not.toBeInTheDocument();
+ expect(screen.getByText("Standard component")).toBeInTheDocument();
+ expect(screen.getByText("User component")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "Show all" }));
+
+ expect(screen.getByText("Registered component")).toBeInTheDocument();
+ });
+
+ it("shows a manual generate button when automatic descriptions are disabled", () => {
+ routeMocks.search = { component: "standard-digest" };
+
+ render();
+
+ expect(routeMocks.generateAiDescription).not.toHaveBeenCalled();
+
+ fireEvent.click(
+ screen.getByRole("button", { name: "Generate AI description" }),
+ );
+
+ expect(routeMocks.generateAiDescription).toHaveBeenCalledWith(
+ { reference: routeMocks.standard },
+ expect.any(Object),
+ );
+ });
+
+ it("generates component descriptions automatically when the flag is enabled", async () => {
+ routeMocks.aiDescriptionsEnabled = true;
+ routeMocks.search = { component: "standard-digest" };
+
+ render();
+
+ await waitFor(() => {
+ expect(routeMocks.generateAiDescription).toHaveBeenCalledWith(
+ { reference: routeMocks.standard },
+ expect.any(Object),
+ );
+ });
+ });
+
+ it("does not automatically retry description generation after an error", () => {
+ routeMocks.aiDescriptionsEnabled = true;
+ routeMocks.descriptionErrorState.current = new Error("provider failed");
+ routeMocks.search = { component: "standard-digest" };
+
+ render();
+
+ expect(routeMocks.generateAiDescription).not.toHaveBeenCalled();
+ expect(screen.getByText(/provider failed/)).toBeInTheDocument();
+ });
+});
diff --git a/src/routes/Dashboard/DashboardComponentsV2View.tsx b/src/routes/Dashboard/DashboardComponentsV2View.tsx
index 9dec6cda9..f62e6cd83 100644
--- a/src/routes/Dashboard/DashboardComponentsV2View.tsx
+++ b/src/routes/Dashboard/DashboardComponentsV2View.tsx
@@ -8,6 +8,7 @@ import {
ComponentDetail,
ComponentDetailSkeleton,
} from "@/components/shared/ComponentDetail/ComponentDetail";
+import { useFlagValue } from "@/components/shared/Settings/useFlags";
import { SuspenseWrapper } from "@/components/shared/SuspenseWrapper";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -19,7 +20,10 @@ import { Spinner } from "@/components/ui/spinner";
import { QuickTooltip } from "@/components/ui/tooltip";
import { Heading, Paragraph, Text } from "@/components/ui/typography";
import { getComponentQueryKey } from "@/hooks/useHydrateComponentReference";
-import { useNaturalLanguageComponentRerank } from "@/hooks/useNaturalLanguageComponentSearch";
+import {
+ useComponentAiDescription,
+ useNaturalLanguageComponentRerank,
+} from "@/hooks/useNaturalLanguageComponentSearch";
import useToastNotification from "@/hooks/useToastNotification";
import { cn } from "@/lib/utils";
import { useBackend } from "@/providers/BackendProvider";
@@ -64,6 +68,11 @@ import { isRecord } from "@/utils/typeGuards";
import { APP_ROUTES } from "../router";
import { copyComponentReferenceToClipboard } from "../v2/shared/clipboard/copyComponentReferenceToClipboard";
+import {
+ createSourceFilterOptions,
+ filterIndexByDisabledSourceKeys,
+ SourceFilterBar,
+} from "./DashboardComponentsV2SourceFilter";
// Repeated Tailwind combos extracted as named constants.
const PANEL_CLASS = "p-3 rounded-lg bg-card border border-border";
@@ -101,98 +110,6 @@ const MATCH_FIELD_LABEL: Record = {
implementation: "command",
};
-export interface SourceFilterOption {
- source: ComponentSearchSource;
- count: number;
-}
-
-function sourceFilterKey(source: ComponentSearchSource): string {
- return `${source.kind}:${source.id}`;
-}
-
-function createSourceFilterOptions(index: IndexEntry[]): SourceFilterOption[] {
- const optionsByKey = new Map();
-
- for (const entry of index) {
- const key = sourceFilterKey(entry.source);
- const option = optionsByKey.get(key);
- if (option) {
- option.count += 1;
- } else {
- optionsByKey.set(key, { source: entry.source, count: 1 });
- }
- }
-
- return Array.from(optionsByKey.values());
-}
-
-interface SourceFilterBarProps {
- options: SourceFilterOption[];
- disabledSourceKeys: string[];
- onToggle: (sourceKey: string) => void;
- onEnableAll: () => void;
-}
-
-export const SourceFilterBar = ({
- options,
- disabledSourceKeys,
- onToggle,
- onEnableAll,
-}: SourceFilterBarProps) => {
- if (options.length <= 1) return null;
-
- const disabled = new Set(disabledSourceKeys);
- const activeCount = options.filter(
- (option) => !disabled.has(sourceFilterKey(option.source)),
- ).length;
-
- return (
-
-
-
- Sources
-
- {options.map(({ source, count }) => {
- const key = sourceFilterKey(source);
- const active = !disabled.has(key);
- return (
-
- );
- })}
- {activeCount < options.length && (
-
- )}
-
- {activeCount === 0 && (
-
- No sources selected. Turn on at least one source to show components.
-
- )}
-
- );
-};
-
// Built-in sources are constants — only registered libraries vary per row.
const STANDARD_SOURCE: ComponentSearchSource = {
kind: "standard",
@@ -246,7 +163,7 @@ export function createRegisteredLibrariesFingerprint(
id: library.id,
type: library.type,
name: library.name,
- knownDigestsCount: library.knownDigests.length,
+ knownDigests: [...library.knownDigests].sort(),
configuration: registeredLibraryConfigurationFingerprint(
library.configuration,
),
@@ -362,6 +279,78 @@ const ComponentCard = ({
);
};
+interface ComponentDescriptionPanelProps {
+ prefilledDescription?: string;
+ generatedDescription?: string;
+ isGenerating: boolean;
+ generationError: Error | null;
+ isConfigured: boolean;
+ onGenerate: () => void;
+}
+
+const ComponentDescriptionPanel = ({
+ prefilledDescription,
+ generatedDescription,
+ isGenerating,
+ generationError,
+ isConfigured,
+ onGenerate,
+}: ComponentDescriptionPanelProps) => {
+ return (
+
+
+
+ Prefilled description
+
+
+ {prefilledDescription?.trim() || "No prefilled description provided."}
+
+
+
+
+
+ AI-generated description
+
+ {isGenerating && }
+
+ {!isConfigured ? (
+
+ Configure Agent settings to generate an AI description.
+
+ ) : generationError ? (
+
+
+ Couldn't generate a description: {generationError.message}
+
+
+
+ ) : generatedDescription ? (
+
+ {generatedDescription}
+
+ ) : isGenerating ? (
+
+ Generating description…
+
+ ) : (
+
+ )}
+
+ {!isConfigured && (
+
+
+ Configure in Settings →
+
+
+ )}
+
+ );
+};
+
/**
* Merge every component source the rest of the app knows about into a single
* deduped, source-attributed list.
@@ -414,6 +403,9 @@ function collectAllSourcedReferences({
export const DashboardComponentsV2View = () => {
const queryClient = useQueryClient();
+ const aiDescriptionsEnabled = useFlagValue(
+ "component-search-v2-ai-descriptions",
+ );
const { backendUrl, configured, available } = useBackend();
const [query, setQuery] = useState("");
const [disabledSourceKeys, setDisabledSourceKeys] = useState([]);
@@ -608,9 +600,9 @@ export const DashboardComponentsV2View = () => {
// The search index is a pure derivation. React Compiler will memoize this.
const index: IndexEntry[] = buildSearchIndex(sourcedHydrated);
const sourceFilterOptions = createSourceFilterOptions(index);
- const disabledSourceKeySet = new Set(disabledSourceKeys);
- const filteredIndex = index.filter(
- (entry) => !disabledSourceKeySet.has(sourceFilterKey(entry.source)),
+ const filteredIndex = filterIndexByDisabledSourceKeys(
+ index,
+ disabledSourceKeys,
);
const total = filteredIndex.length;
const totalAcrossSources = index.length;
@@ -633,11 +625,21 @@ export const DashboardComponentsV2View = () => {
reset: resetRerank,
isConfigured,
} = useNaturalLanguageComponentRerank();
+ const {
+ mutate: generateAiDescription,
+ isPending: isGeneratingDescription,
+ error: descriptionError,
+ reset: resetDescription,
+ isConfigured: canGenerateDescription,
+ } = useComponentAiDescription();
// Reranked results are tied to the exact query that triggered them. If the
// user types more, we drop the rerank rather than show results for an old
// query. Tracked here so we can clear on input change.
const [rerankedFor, setRerankedFor] = useState(null);
+ const [generatedDescriptions, setGeneratedDescriptions] = useState<
+ Record
+ >({});
const handleQueryChange = (event: ChangeEvent) => {
setQuery(event.target.value);
@@ -722,8 +724,60 @@ export const DashboardComponentsV2View = () => {
};
})();
const isDetailOpen = Boolean(selectedDigest);
+ const selectedGeneratedDescription = selectedDigest
+ ? generatedDescriptions[selectedDigest]
+ : undefined;
const notify = useToastNotification();
+ const handleGenerateDescription = () => {
+ if (!selectedReference?.digest || !selectedReference.spec) return;
+ if (!canGenerateDescription) return;
+
+ const digest = selectedReference.digest;
+ resetDescription();
+ generateAiDescription(
+ { reference: selectedReference },
+ {
+ onSuccess: (result) => {
+ setGeneratedDescriptions((current) => ({
+ ...current,
+ [digest]: result.description,
+ }));
+ },
+ },
+ );
+ };
+
+ useEffect(() => {
+ resetDescription();
+ }, [resetDescription, selectedDigest]);
+
+ useEffect(() => {
+ if (!selectedReference?.digest || !selectedReference.spec) {
+ resetDescription();
+ return;
+ }
+ if (!aiDescriptionsEnabled) return;
+ if (!canGenerateDescription) {
+ resetDescription();
+ return;
+ }
+ if (isGeneratingDescription) return;
+ if (descriptionError) return;
+ if (generatedDescriptions[selectedReference.digest]) return;
+
+ handleGenerateDescription();
+ }, [
+ aiDescriptionsEnabled,
+ canGenerateDescription,
+ descriptionError,
+ generatedDescriptions,
+ handleGenerateDescription,
+ isGeneratingDescription,
+ resetDescription,
+ selectedReference,
+ ]);
+
const handleCopyToPipeline = async () => {
if (!selectedReference) return;
try {
@@ -962,14 +1016,27 @@ export const DashboardComponentsV2View = () => {
- }>
-
+
-
+ }>
+
+
+
)}
diff --git a/src/routes/Settings/sections/BetaFeaturesSettings.test.tsx b/src/routes/Settings/sections/BetaFeaturesSettings.test.tsx
new file mode 100644
index 000000000..801453780
--- /dev/null
+++ b/src/routes/Settings/sections/BetaFeaturesSettings.test.tsx
@@ -0,0 +1,77 @@
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { Flag } from "@/types/configuration";
+
+const mocks = vi.hoisted(() => {
+ const betaFlags: Flag[] = [];
+ return {
+ betaFlags,
+ handleSetFlag: vi.fn(),
+ track: vi.fn(),
+ };
+});
+
+vi.mock("@/providers/AnalyticsProvider", () => ({
+ useAnalytics: () => ({ track: mocks.track }),
+}));
+
+vi.mock("../SettingsFlagsContext", () => ({
+ useSettingsFlags: () => ({
+ betaFlags: mocks.betaFlags,
+ handleSetFlag: mocks.handleSetFlag,
+ }),
+}));
+
+import { BetaFeaturesSettings } from "./BetaFeaturesSettings";
+
+const componentSearchFlag: Flag = {
+ key: "component-search-v2",
+ name: "Component Search V2",
+ description: "Show Components V2.",
+ default: false,
+ enabled: false,
+ category: "beta",
+};
+
+const aiDescriptionFlag: Flag = {
+ key: "component-search-v2-ai-descriptions",
+ name: "Auto-generate Components V2 AI descriptions",
+ description: "Automatically generate AI descriptions.",
+ default: false,
+ enabled: false,
+ category: "beta",
+};
+
+describe("BetaFeaturesSettings", () => {
+ beforeEach(() => {
+ mocks.betaFlags = [];
+ mocks.handleSetFlag.mockClear();
+ mocks.track.mockClear();
+ });
+
+ it("hides the AI descriptions flag when Components V2 is disabled", () => {
+ mocks.betaFlags = [componentSearchFlag, aiDescriptionFlag];
+
+ render();
+
+ expect(screen.getByText("Component Search V2")).toBeInTheDocument();
+ expect(
+ screen.queryByText("Auto-generate Components V2 AI descriptions"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows the AI descriptions flag when Components V2 is enabled", () => {
+ mocks.betaFlags = [
+ { ...componentSearchFlag, enabled: true },
+ aiDescriptionFlag,
+ ];
+
+ render();
+
+ expect(screen.getByText("Component Search V2")).toBeInTheDocument();
+ expect(
+ screen.getByText("Auto-generate Components V2 AI descriptions"),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/routes/Settings/sections/BetaFeaturesSettings.tsx b/src/routes/Settings/sections/BetaFeaturesSettings.tsx
index f4bfc841f..0b2f3f254 100644
--- a/src/routes/Settings/sections/BetaFeaturesSettings.tsx
+++ b/src/routes/Settings/sections/BetaFeaturesSettings.tsx
@@ -6,6 +6,14 @@ import { useSettingsFlags } from "../SettingsFlagsContext";
export function BetaFeaturesSettings() {
const { betaFlags, handleSetFlag } = useSettingsFlags();
const { track } = useAnalytics();
+ const componentSearchV2Enabled = betaFlags.some(
+ (flag) => flag.key === "component-search-v2" && flag.enabled,
+ );
+ const visibleBetaFlags = componentSearchV2Enabled
+ ? betaFlags
+ : betaFlags.filter(
+ (flag) => flag.key !== "component-search-v2-ai-descriptions",
+ );
const handleChange = (key: string, enabled: boolean) => {
track("settings.toggle_changed", {
@@ -16,5 +24,5 @@ export function BetaFeaturesSettings() {
handleSetFlag(key, enabled);
};
- return ;
+ return ;
}
diff --git a/src/services/naturalLanguageComponentSearchService.test.ts b/src/services/naturalLanguageComponentSearchService.test.ts
index 291c03030..2a5e4dd2d 100644
--- a/src/services/naturalLanguageComponentSearchService.test.ts
+++ b/src/services/naturalLanguageComponentSearchService.test.ts
@@ -5,6 +5,7 @@ import { isRecord } from "@/utils/typeGuards";
import {
componentReferenceToCandidate,
+ generateComponentAiDescription,
NaturalLanguageSearchConfigError,
rerankComponentsByNaturalLanguage,
} from "./naturalLanguageComponentSearchService";
@@ -261,3 +262,67 @@ describe("rerankComponentsByNaturalLanguage", () => {
expect(body.temperature).toBe(0);
});
});
+
+describe("generateComponentAiDescription", () => {
+ beforeEach(() => {
+ global.fetch = vi.fn();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ const reference: ComponentReference = {
+ digest: "abc",
+ spec: {
+ name: "train_model",
+ description: "Trains a model.",
+ inputs: [{ name: "dataset", description: "Training data" }],
+ outputs: [{ name: "model", description: "Trained model" }],
+ implementation: {
+ container: {
+ image: "python:3.12",
+ command: ["python", "train.py"],
+ },
+ },
+ },
+ };
+
+ it("generates a description from a component spec", async () => {
+ vi.mocked(global.fetch).mockResolvedValue(
+ mockChatResponse({
+ description:
+ "This component trains a model from the dataset input and writes the trained model output.",
+ }),
+ );
+
+ const result = await generateComponentAiDescription(
+ reference,
+ VALID_OPTIONS,
+ );
+
+ expect(result.description).toContain("trains a model");
+ const call = vi.mocked(global.fetch).mock.calls[0];
+ const body = parseFetchBody(call);
+ const serializedMessages = JSON.stringify(body.messages);
+ expect(serializedMessages).toContain("train_model");
+ expect(serializedMessages).toContain("dataset");
+ });
+
+ it("requires a hydrated component spec", async () => {
+ await expect(
+ generateComponentAiDescription({ digest: "abc" }, VALID_OPTIONS),
+ ).rejects.toThrow("Component details are not loaded yet");
+ expect(global.fetch).not.toHaveBeenCalled();
+ });
+
+ it("throws when the model returns an empty description", async () => {
+ vi.mocked(global.fetch).mockResolvedValue(
+ mockChatResponse({ description: "" }),
+ );
+
+ await expect(
+ generateComponentAiDescription(reference, VALID_OPTIONS),
+ ).rejects.toThrow("empty description");
+ });
+});
diff --git a/src/services/naturalLanguageComponentSearchService.ts b/src/services/naturalLanguageComponentSearchService.ts
index af2a5bb35..3f1165e73 100644
--- a/src/services/naturalLanguageComponentSearchService.ts
+++ b/src/services/naturalLanguageComponentSearchService.ts
@@ -12,6 +12,7 @@
*/
import type { ComponentReference } from "@/utils/componentSpec";
+import { getComponentName } from "@/utils/getComponentName";
import { isRecord } from "@/utils/typeGuards";
import { extractComponentMetadata } from "./componentSearchIndex";
@@ -43,6 +44,12 @@ export interface RerankResult {
rawContent?: string;
}
+export interface ComponentDescriptionResult {
+ description: string;
+ /** Raw model response, kept for debugging. */
+ rawContent?: string;
+}
+
export class NaturalLanguageSearchConfigError extends Error {
constructor(message: string) {
super(message);
@@ -50,7 +57,7 @@ export class NaturalLanguageSearchConfigError extends Error {
}
}
-interface RerankOptions {
+interface LlmOptions {
signal?: AbortSignal;
/** Model id (OpenAI-compatible). Required. */
model: string;
@@ -122,7 +129,7 @@ export function componentReferenceToCandidate(
};
}
-function buildSystemPrompt(): string {
+function buildRerankSystemPrompt(): string {
return [
"You are a reranker for an ML pipeline component search.",
"The user gives you a natural-language query and a small list of candidate components that were already retrieved by lexical search.",
@@ -141,7 +148,10 @@ function buildSystemPrompt(): string {
].join("\n");
}
-function buildUserPrompt(query: string, candidates: RerankCandidate[]): string {
+function buildRerankUserPrompt(
+ query: string,
+ candidates: RerankCandidate[],
+): string {
// No pretty-printing: indentation adds ~25-30% to the payload for no signal.
// Candidates are wrapped in an explicit delimiter and tagged as untrusted in
// the system prompt — candidate descriptions can come from published/
@@ -157,7 +167,7 @@ function buildUserPrompt(query: string, candidates: RerankCandidate[]): string {
].join("\n");
}
-function validateConfig(options: RerankOptions): {
+function validateConfig(options: LlmOptions): {
base: string;
key: string;
model: string;
@@ -186,7 +196,7 @@ function validateConfig(options: RerankOptions): {
export async function rerankComponentsByNaturalLanguage(
query: string,
candidates: RerankCandidate[],
- options: RerankOptions,
+ options: LlmOptions,
): Promise {
const trimmed = query.trim();
if (trimmed.length === 0) return { matches: [] };
@@ -213,8 +223,8 @@ export async function rerankComponentsByNaturalLanguage(
: { max_tokens: 1500 }),
response_format: { type: "json_object" },
messages: [
- { role: "system", content: buildSystemPrompt() },
- { role: "user", content: buildUserPrompt(trimmed, candidates) },
+ { role: "system", content: buildRerankSystemPrompt() },
+ { role: "user", content: buildRerankUserPrompt(trimmed, candidates) },
],
}),
});
@@ -266,3 +276,140 @@ export async function rerankComponentsByNaturalLanguage(
return { matches, rawContent };
}
+
+interface ComponentDescriptionInput {
+ name: string;
+ prefilledDescription: string;
+ inputs?: Array<{
+ name: string;
+ type?: unknown;
+ description?: string;
+ optional?: boolean;
+ default?: unknown;
+ }>;
+ outputs?: Array<{
+ name: string;
+ type?: unknown;
+ description?: string;
+ }>;
+ implementation: unknown;
+}
+
+function componentReferenceToDescriptionInput(
+ reference: ComponentReference,
+): ComponentDescriptionInput | null {
+ const spec = reference.spec;
+ if (!spec) return null;
+
+ return {
+ name: getComponentName(reference),
+ prefilledDescription: spec.description?.trim() ?? "",
+ ...(spec.inputs && spec.inputs.length > 0
+ ? {
+ inputs: spec.inputs.map((input) => ({
+ name: input.name,
+ type: input.type,
+ description: input.description,
+ optional: input.optional,
+ default: input.default,
+ })),
+ }
+ : {}),
+ ...(spec.outputs && spec.outputs.length > 0
+ ? {
+ outputs: spec.outputs.map((output) => ({
+ name: output.name,
+ type: output.type,
+ description: output.description,
+ })),
+ }
+ : {}),
+ implementation: spec.implementation,
+ };
+}
+
+function buildDescriptionSystemPrompt(): string {
+ return [
+ "You explain ML pipeline components for users deciding whether to add a component to a pipeline.",
+ "Use only the component spec provided. Do not invent behavior that is not supported by the spec.",
+ "Explain exactly what the component does, what it consumes, what it produces, and any important implementation detail visible in the spec.",
+ "Respond with a single JSON object:",
+ '{ "description": "<2-4 sentence precise explanation>" }',
+ "Rules:",
+ "- Be specific and concrete.",
+ "- If the spec is sparse, say what is known and what is not specified.",
+ "- Keep the description under 900 characters.",
+ ].join("\n");
+}
+
+function buildDescriptionUserPrompt(input: ComponentDescriptionInput): string {
+ return ["Component spec summary:", JSON.stringify(input)].join("\n");
+}
+
+function readDescription(rawContent: string): string {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(rawContent);
+ } catch {
+ throw new Error(
+ `Could not parse LLM response as JSON: ${rawContent.slice(0, 200)}`,
+ );
+ }
+
+ if (!isRecord(parsed)) return "";
+ const description = parsed.description;
+ return typeof description === "string" ? description.trim() : "";
+}
+
+export async function generateComponentAiDescription(
+ reference: ComponentReference,
+ options: LlmOptions,
+): Promise {
+ const input = componentReferenceToDescriptionInput(reference);
+ if (!input) {
+ throw new Error("Component details are not loaded yet.");
+ }
+
+ const { base, key, model } = validateConfig(options);
+
+ const response = await fetch(`${base}/chat/completions`, {
+ method: "POST",
+ signal: options.signal,
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${key}`,
+ },
+ body: JSON.stringify({
+ model,
+ ...(usesCompletionTokensParam(model) ? {} : { temperature: 0 }),
+ ...(usesCompletionTokensParam(model)
+ ? { max_completion_tokens: 2000 }
+ : { max_tokens: 900 }),
+ response_format: { type: "json_object" },
+ messages: [
+ { role: "system", content: buildDescriptionSystemPrompt() },
+ { role: "user", content: buildDescriptionUserPrompt(input) },
+ ],
+ }),
+ });
+
+ if (!response.ok) {
+ const detail = await response.text().catch(() => "");
+ throw new Error(
+ `LLM proxy returned ${response.status}: ${detail.slice(0, 200) || response.statusText}`,
+ );
+ }
+
+ const payload: unknown = await response.json();
+ const rawContent = readChatCompletionContent(payload);
+ if (!rawContent) {
+ throw new Error("LLM proxy returned an empty response");
+ }
+
+ const description = readDescription(rawContent);
+ if (!description) {
+ throw new Error("LLM proxy returned an empty description");
+ }
+
+ return { description, rawContent };
+}