+
+
+);
diff --git a/src/components/ui/typography.tsx b/src/components/ui/typography.tsx
index 47663d0c2..69fdddbe4 100644
--- a/src/components/ui/typography.tsx
+++ b/src/components/ui/typography.tsx
@@ -67,6 +67,9 @@ interface TextProps
*/
className?: string;
+ /** HTML id used to associate text with form controls and ARIA descriptions. */
+ id?: string;
+
/** Native browser tooltip text */
title?: string;
}
diff --git a/src/flags.ts b/src/flags.ts
index f4e5c7287..46150c548 100644
--- a/src/flags.ts
+++ b/src/flags.ts
@@ -58,7 +58,7 @@ export const ExistingFlags: ConfigFlags = {
["component-search-v2"]: {
name: "Component Search V2",
description:
- "Show the experimental Components V2 page in the dashboard. Uses placeholder data for now.",
+ "Show the experimental Components V2 page that searches across standard, published, registered, and user component sources, with optional AI rerank.",
default: false,
category: "beta",
},
diff --git a/src/hooks/useComponentSearchSettings.test.ts b/src/hooks/useComponentSearchSettings.test.ts
new file mode 100644
index 000000000..6200db766
--- /dev/null
+++ b/src/hooks/useComponentSearchSettings.test.ts
@@ -0,0 +1,149 @@
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import { useComponentSearchSettings } from "./useComponentSearchSettings";
+
+const STORAGE_KEY = "tangle.componentSearchV2.config";
+
+describe("useComponentSearchSettings", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ afterEach(() => {
+ window.localStorage.clear();
+ });
+
+ it("returns defaults when nothing is stored", () => {
+ const { result } = renderHook(() => useComponentSearchSettings());
+
+ expect(result.current.config).toEqual({
+ apiBase: "",
+ apiKey: "",
+ model: "",
+ });
+ expect(result.current.isConfigured).toBe(false);
+ });
+
+ it("reads stored values from localStorage", () => {
+ window.localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({
+ apiBase: "https://api.example.com/v1",
+ apiKey: "sk-test",
+ model: "gpt-4o-mini",
+ }),
+ );
+
+ const { result } = renderHook(() => useComponentSearchSettings());
+
+ expect(result.current.config).toEqual({
+ apiBase: "https://api.example.com/v1",
+ apiKey: "sk-test",
+ model: "gpt-4o-mini",
+ });
+ expect(result.current.isConfigured).toBe(true);
+ });
+
+ it("isConfigured requires apiBase, apiKey, and model", () => {
+ window.localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({
+ apiBase: "https://api.example.com/v1",
+ apiKey: "sk-test",
+ model: "",
+ }),
+ );
+
+ const { result } = renderHook(() => useComponentSearchSettings());
+ expect(result.current.isConfigured).toBe(false);
+ });
+
+ it("update() writes to localStorage and merges partial values", () => {
+ const { result } = renderHook(() => useComponentSearchSettings());
+
+ act(() => {
+ result.current.update({
+ apiBase: "https://api.example.com/v1",
+ apiKey: "sk-test",
+ model: "gpt-4o-mini",
+ });
+ });
+
+ expect(result.current.config.model).toBe("gpt-4o-mini");
+ expect(result.current.isConfigured).toBe(true);
+
+ act(() => {
+ result.current.update({ model: "claude-3-5-haiku" });
+ });
+
+ const stored = JSON.parse(
+ window.localStorage.getItem(STORAGE_KEY) as string,
+ );
+ expect(stored).toEqual({
+ apiBase: "https://api.example.com/v1",
+ apiKey: "sk-test",
+ model: "claude-3-5-haiku",
+ });
+ });
+
+ it("clear() removes the stored config", () => {
+ window.localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({
+ apiBase: "https://api.example.com/v1",
+ apiKey: "sk-test",
+ model: "gpt-4o-mini",
+ }),
+ );
+
+ const { result } = renderHook(() => useComponentSearchSettings());
+
+ act(() => {
+ result.current.clear();
+ });
+
+ expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull();
+ expect(result.current.isConfigured).toBe(false);
+ });
+
+ it("migrates legacy `thinkingModel` into `model` when model is unset", () => {
+ window.localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({
+ apiBase: "https://api.example.com/v1",
+ apiKey: "sk-test",
+ thinkingModel: "gpt-5-mini",
+ }),
+ );
+
+ const { result } = renderHook(() => useComponentSearchSettings());
+ expect(result.current.config.model).toBe("gpt-5-mini");
+ });
+
+ it("preserves legacy `thinkingModel` precedence when both models exist", () => {
+ window.localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({
+ apiBase: "https://api.example.com/v1",
+ apiKey: "sk-test",
+ model: "gpt-4o-mini",
+ thinkingModel: "gpt-5-mini",
+ }),
+ );
+
+ const { result } = renderHook(() => useComponentSearchSettings());
+ expect(result.current.config.model).toBe("gpt-5-mini");
+ });
+
+ it("falls back to defaults when stored JSON is malformed", () => {
+ window.localStorage.setItem(STORAGE_KEY, "not json");
+
+ const { result } = renderHook(() => useComponentSearchSettings());
+ expect(result.current.config).toEqual({
+ apiBase: "",
+ apiKey: "",
+ model: "",
+ });
+ });
+});
diff --git a/src/hooks/useComponentSearchSettings.ts b/src/hooks/useComponentSearchSettings.ts
index 84cc4d565..f11cd8af4 100644
--- a/src/hooks/useComponentSearchSettings.ts
+++ b/src/hooks/useComponentSearchSettings.ts
@@ -16,17 +16,14 @@ const STORAGE_KEY = "tangle.componentSearchV2.config";
export interface ComponentSearchConfig {
apiBase: string;
apiKey: string;
- /** Fast / default model. */
+ /** Model id used for AI search reranking (any OpenAI-compatible model). */
model: string;
- /** Better-quality model used when the "Thinking" toggle is on. */
- thinkingModel: string;
}
const DEFAULTS: ComponentSearchConfig = {
apiBase: "",
apiKey: "",
- model: "gemini-2.5-flash-lite",
- thinkingModel: "gpt-5-mini",
+ model: "",
};
function readStoredConfig(): ComponentSearchConfig {
@@ -36,18 +33,32 @@ function readStoredConfig(): ComponentSearchConfig {
if (!raw) return DEFAULTS;
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") return DEFAULTS;
- const p = parsed as Partial;
+ const record = parsed;
+ const legacyThinking =
+ "thinkingModel" in record &&
+ typeof record.thinkingModel === "string" &&
+ record.thinkingModel.trim().length > 0
+ ? record.thinkingModel
+ : "";
+ const storedModel =
+ "model" in record &&
+ typeof record.model === "string" &&
+ record.model.trim().length > 0
+ ? record.model
+ : "";
+ // Migration: previous reranking used `thinkingModel` when present, even if
+ // `model` was also stored. Preserve that precedence for existing users.
+ const model = legacyThinking || storedModel || DEFAULTS.model;
return {
- apiBase: typeof p.apiBase === "string" ? p.apiBase : DEFAULTS.apiBase,
- apiKey: typeof p.apiKey === "string" ? p.apiKey : DEFAULTS.apiKey,
- model:
- typeof p.model === "string" && p.model.trim().length > 0
- ? p.model
- : DEFAULTS.model,
- thinkingModel:
- typeof p.thinkingModel === "string" && p.thinkingModel.trim().length > 0
- ? p.thinkingModel
- : DEFAULTS.thinkingModel,
+ apiBase:
+ "apiBase" in record && typeof record.apiBase === "string"
+ ? record.apiBase
+ : DEFAULTS.apiBase,
+ apiKey:
+ "apiKey" in record && typeof record.apiKey === "string"
+ ? record.apiKey
+ : DEFAULTS.apiKey,
+ model,
};
} catch {
return DEFAULTS;
@@ -116,7 +127,10 @@ export function useComponentSearchSettings() {
window.dispatchEvent(new Event("tangle:component-search-config"));
};
- const isConfigured = config.apiBase.length > 0 && config.apiKey.length > 0;
+ const isConfigured =
+ config.apiBase.length > 0 &&
+ config.apiKey.length > 0 &&
+ config.model.length > 0;
return { config, update, clear, isConfigured };
}
diff --git a/src/hooks/useNaturalLanguageComponentSearch.ts b/src/hooks/useNaturalLanguageComponentSearch.ts
index 94f22b0df..37ae09db1 100644
--- a/src/hooks/useNaturalLanguageComponentSearch.ts
+++ b/src/hooks/useNaturalLanguageComponentSearch.ts
@@ -25,15 +25,10 @@ interface RerankVariables {
export function useNaturalLanguageComponentRerank() {
const { config, isConfigured } = useComponentSearchSettings();
- // Prefer the thinking model for rerank — rerank is the moment we *want*
- // careful judgment, and the payload is small enough that latency is fine.
- // Fall back to the default model when no thinking model is configured.
- const model = config.thinkingModel || config.model;
-
const mutation = useMutation({
mutationFn: ({ query, candidates }) =>
rerankComponentsByNaturalLanguage(query, candidates, {
- model,
+ model: config.model,
apiBase: config.apiBase,
apiKey: config.apiKey,
}),
diff --git a/src/providers/ComponentLibraryProvider/libraries/setup.ts b/src/providers/ComponentLibraryProvider/libraries/setup.ts
new file mode 100644
index 000000000..b72c351f2
--- /dev/null
+++ b/src/providers/ComponentLibraryProvider/libraries/setup.ts
@@ -0,0 +1,28 @@
+import { GitHubFlatComponentLibrary } from "@/components/shared/GitHubLibrary/githubFlatComponentLibrary";
+import { isGitHubLibraryConfiguration } from "@/components/shared/GitHubLibrary/types";
+
+import { registerLibraryFactory } from "./factory";
+
+/**
+ * Idempotent registration of library factories. The provider already registers
+ * the same factories at module load, but the dashboard search page reads
+ * libraries from Dexie directly (without mounting `ComponentLibraryProvider`,
+ * which is editor-scoped and depends on `ComponentSpecProvider`). Anywhere
+ * that needs to instantiate a stored library can call this first.
+ */
+let registered = false;
+
+export function ensureLibraryFactoriesRegistered() {
+ if (registered) return;
+
+ registerLibraryFactory("github", (library) => {
+ if (!isGitHubLibraryConfiguration(library.configuration)) {
+ throw new Error(
+ `GitHub library configuration is not valid for "${library.id}"`,
+ );
+ }
+ return new GitHubFlatComponentLibrary(library.configuration.repo_name);
+ });
+
+ registered = true;
+}
diff --git a/src/routes/Dashboard/DashboardComponentsV2View.test.tsx b/src/routes/Dashboard/DashboardComponentsV2View.test.tsx
new file mode 100644
index 000000000..00d80e0f3
--- /dev/null
+++ b/src/routes/Dashboard/DashboardComponentsV2View.test.tsx
@@ -0,0 +1,55 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ SourceFilterBar,
+ type SourceFilterOption,
+} from "./DashboardComponentsV2View";
+
+const options: SourceFilterOption[] = [
+ {
+ source: { kind: "standard", label: "Standard", id: "standard" },
+ count: 2,
+ },
+ {
+ source: { kind: "registered", label: "GitHub", id: "github-lib" },
+ count: 3,
+ },
+ {
+ source: { kind: "user", label: "User", id: "user" },
+ count: 1,
+ },
+];
+
+describe("SourceFilterBar", () => {
+ it("toggles source buttons and exposes active state", () => {
+ const onToggle = vi.fn();
+ const onEnableAll = vi.fn();
+
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByRole("button", {
+ name: "Hide Standard source (2 components)",
+ }),
+ ).toHaveAttribute("aria-pressed", "true");
+ expect(
+ screen.getByRole("button", { name: "Show GitHub source (3 components)" }),
+ ).toHaveAttribute("aria-pressed", "false");
+
+ fireEvent.click(
+ screen.getByRole("button", { name: "Show GitHub source (3 components)" }),
+ );
+ expect(onToggle).toHaveBeenCalledWith("registered:github-lib");
+
+ fireEvent.click(screen.getByRole("button", { name: "Show all" }));
+ expect(onEnableAll).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/routes/Dashboard/DashboardComponentsV2View.tsx b/src/routes/Dashboard/DashboardComponentsV2View.tsx
index d736182df..5ff2931e4 100644
--- a/src/routes/Dashboard/DashboardComponentsV2View.tsx
+++ b/src/routes/Dashboard/DashboardComponentsV2View.tsx
@@ -1,7 +1,14 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { Link } from "@tanstack/react-router";
-import { type ChangeEvent, useDeferredValue, useState } from "react";
+import { Link, useNavigate, useSearch } from "@tanstack/react-router";
+import { useLiveQuery } from "dexie-react-hooks";
+import { type ChangeEvent, useEffect, useState } from "react";
+import { listApiPublishedComponentsGet } from "@/api/sdk.gen";
+import {
+ ComponentDetail,
+ ComponentDetailSkeleton,
+} from "@/components/shared/ComponentDetail/ComponentDetail";
+import { SuspenseWrapper } from "@/components/shared/SuspenseWrapper";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Icon } from "@/components/ui/icon";
@@ -9,20 +16,31 @@ import { Input } from "@/components/ui/input";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import { Skeleton } from "@/components/ui/skeleton";
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 useToastNotification from "@/hooks/useToastNotification";
+import { cn } from "@/lib/utils";
+import { useBackend } from "@/providers/BackendProvider";
import {
fetchUserComponents,
- filterToUniqueByDigest,
flattenFolders,
} from "@/providers/ComponentLibraryProvider/componentLibrary";
+import { createLibraryObject } from "@/providers/ComponentLibraryProvider/libraries/factory";
+import { ensureLibraryFactoriesRegistered } from "@/providers/ComponentLibraryProvider/libraries/setup";
+import {
+ LibraryDB,
+ type StoredLibrary,
+} from "@/providers/ComponentLibraryProvider/libraries/storage";
import {
buildSearchIndex,
+ type ComponentSource,
type IndexEntry,
type LexicalMatch,
lexicalSearch,
type MatchField,
+ type SourcedReference,
} from "@/services/componentSearchIndex";
import {
fetchAndStoreComponentLibrary,
@@ -33,18 +51,40 @@ import {
NaturalLanguageSearchConfigError,
type RerankedMatch,
} from "@/services/naturalLanguageComponentSearchService";
+import type { ComponentFolder } from "@/types/componentLibrary";
import type {
ComponentReference,
HydratedComponentReference,
} from "@/utils/componentSpec";
-import { HOURS } from "@/utils/constants";
+import { componentMetadata } from "@/utils/componentTracking";
+import { HOURS, TOP_NAV_HEIGHT } from "@/utils/constants";
import { getComponentName } from "@/utils/getComponentName";
+import { tracking } from "@/utils/tracking";
import { APP_ROUTES } from "../router";
+import { copyComponentReferenceToClipboard } from "../v2/shared/clipboard/copyComponentReferenceToClipboard";
// Repeated Tailwind combos extracted as named constants.
const PANEL_CLASS = "p-3 rounded-lg bg-card border border-border";
-const PAGE_CLASS = "max-w-7xl";
+
+// Maps V2's richer ComponentSource.kind onto the analytics-tracking taxonomy
+// (see analytics-tracking skill: `component_source` enum is fixed).
+const TRACKING_SOURCE_BY_KIND = {
+ standard: "library",
+ registered: "library",
+ published: "published",
+ user: "user",
+} as const;
+
+// Source identity is communicated by the package icon's colour instead of a
+// text badge — cleaner card, and the same colour shows up consistently across
+// the list. Hover for the human-readable source name.
+const SOURCE_ICON_TONE_BY_KIND: Record = {
+ standard: "text-blue-500",
+ published: "text-emerald-500",
+ registered: "text-violet-500",
+ user: "text-amber-500",
+};
/** How many lexical hits to display (and to feed into rerank). */
const LEXICAL_RESULT_LIMIT = 20;
@@ -56,75 +96,320 @@ const MATCH_FIELD_LABEL: Record = {
implementation: "command",
};
+export interface SourceFilterOption {
+ source: ComponentSource;
+ count: number;
+}
+
+export function sourceFilterKey(source: ComponentSource): string {
+ return `${source.kind}:${source.id}`;
+}
+
+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: 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: ComponentSource = {
+ kind: "standard",
+ label: "Standard",
+ id: "standard",
+};
+const PUBLISHED_SOURCE: ComponentSource = {
+ kind: "published",
+ label: "Published",
+ id: "published",
+};
+const USER_SOURCE: ComponentSource = {
+ kind: "user",
+ label: "User",
+ id: "user",
+};
+
+function registeredSource(library: StoredLibrary): ComponentSource {
+ return { kind: "registered", label: library.name, id: library.id };
+}
+
type ComponentLibraryFolder = Parameters[0];
type UserFolder = { components?: ComponentReference[] };
interface ComponentCardProps {
reference: ComponentReference;
+ source?: ComponentSource;
matchedFields?: MatchField[];
reason?: string;
+ isSelected?: boolean;
+ /** Position within the current result list — passed to analytics. */
+ position?: number;
+ /** Whether the user had typed a query when this card was rendered. */
+ hadQuery?: boolean;
+ onSelect: (reference: ComponentReference) => void;
}
const ComponentCard = ({
reference,
+ source,
matchedFields,
reason,
+ isSelected,
+ position,
+ hadQuery,
+ onSelect,
}: ComponentCardProps) => {
const name = getComponentName(reference);
const description = reference.spec?.description;
const publishedBy = reference.published_by;
+ const trackingSource = source
+ ? TRACKING_SOURCE_BY_KIND[source.kind]
+ : "unknown";
return (
-
-
-
-
- {name}
-
- {matchedFields?.map((field) => (
-
- matched: {MATCH_FIELD_LABEL[field]}
-
- ))}
-
- {publishedBy && (
-
- by {publishedBy}
-
- )}
- {description && {description}}
- {reason && (
-
- Why: {reason}
-
+
+ {...tracking("component_library.result_card_v2", {
+ ...componentMetadata(reference, trackingSource),
+ surface: "dashboard_v2",
+ result_position: position,
+ had_query: hadQuery,
+ source_kind: source?.kind,
+ })}
+ >
+ {/* min-w-0 so the flex column can shrink below its content width;
+ without this, long unbroken URLs in the description force the
+ card to grow horizontally. */}
+
+
+ {source ? (
+
+
+
+ ) : (
+
+ )}
+
+ {name}
+
+ {matchedFields?.map((field) => (
+
+ matched: {MATCH_FIELD_LABEL[field]}
+
+ ))}
+
+ {publishedBy && (
+
+ by {publishedBy}
+
+ )}
+ {description && (
+ // `[overflow-wrap:anywhere]` breaks at any character if needed —
+ // `break-words` only breaks at word boundaries, which doesn't help
+ // for long URLs without spaces. Pair with `min-w-0` on the parent
+ // so the flex container actually allows shrinking.
+
+ {description}
+
+ )}
+ {reason && (
+
+ Why: {reason}
+
+ )}
+
+
);
};
/**
- * Build the flat, deduped list of unhydrated component references from the
- * two library sources. Hydration happens separately because the static YAML
- * library returns refs with only `url` and `digest` set — we need to fetch
- * each YAML to get name/description/inputs/outputs.
+ * Merge every component source the rest of the app knows about into a single
+ * deduped, source-attributed list.
+ *
+ * Order matters: the first occurrence of a digest wins. Priority is
+ * `standard > published > registered > user` so the most canonical label
+ * sticks when the same component appears in multiple places.
*/
-function collectRawReferences(
- componentLibrary: ComponentLibraryFolder | undefined,
- userFolder: UserFolder | undefined,
-): ComponentReference[] {
- const standard = componentLibrary ? flattenFolders(componentLibrary) : [];
- const user = userFolder?.components ?? [];
- return filterToUniqueByDigest([...standard, ...user]);
+function collectAllSourcedReferences({
+ standardLibrary,
+ publishedRefs,
+ registeredSourced,
+ userFolder,
+}: {
+ standardLibrary: ComponentLibraryFolder | undefined;
+ publishedRefs: ComponentReference[];
+ registeredSourced: SourcedReference[];
+ userFolder: UserFolder | undefined;
+}): SourcedReference[] {
+ const all: SourcedReference[] = [];
+
+ if (standardLibrary) {
+ for (const ref of flattenFolders(standardLibrary)) {
+ all.push({ reference: ref, source: STANDARD_SOURCE });
+ }
+ }
+ for (const ref of publishedRefs) {
+ all.push({ reference: ref, source: PUBLISHED_SOURCE });
+ }
+ for (const sr of registeredSourced) {
+ all.push(sr);
+ }
+ for (const ref of userFolder?.components ?? []) {
+ all.push({ reference: ref, source: USER_SOURCE });
+ }
+
+ // Dedupe by digest, preserving the first occurrence (which carries the
+ // higher-priority source label). Refs without digests are dropped — the
+ // search index requires them for LLM round-trip anyway.
+ const seen = new Set();
+ const out: SourcedReference[] = [];
+ for (const item of all) {
+ const digest = item.reference.digest;
+ if (!digest || seen.has(digest)) continue;
+ seen.add(digest);
+ out.push(item);
+ }
+ return out;
}
export const DashboardComponentsV2View = () => {
const queryClient = useQueryClient();
+ const { backendUrl, configured, available } = useBackend();
const [query, setQuery] = useState("");
+ const [disabledSourceKeys, setDisabledSourceKeys] = useState([]);
- // Deferred query lets the input stay snappy while the (cheap) lexical
- // search runs against the deferred value. React 19 native — no debounce
- // library, no useEffect timers.
- const deferredQuery = useDeferredValue(query);
+ // Detail-pane selection lives in the URL so refreshes preserve it and the
+ // selection can be linked-to. The V2 route has no validateSearch defined.
+ const navigate = useNavigate();
+ const { component: selectedDigest } = useSearch({ strict: false }) as {
+ component?: string;
+ };
+ const selectComponent = (reference: ComponentReference) => {
+ navigate({
+ to: APP_ROUTES.DASHBOARD_COMPONENTS_V2,
+ search: { component: reference.digest },
+ });
+ };
+ const closeDetail = () => {
+ navigate({ to: APP_ROUTES.DASHBOARD_COMPONENTS_V2, search: {} });
+ };
+
+ // Close detail on Escape — only when something is open, so we don't fight
+ // other Esc handlers (e.g. inside Inputs). Navigate inline so the effect has
+ // no callback dep that would re-bind on every render.
+ useEffect(() => {
+ if (!selectedDigest) return;
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ navigate({ to: APP_ROUTES.DASHBOARD_COMPONENTS_V2, search: {} });
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [selectedDigest, navigate]);
+
+ // The dashboard search page doesn't mount `ComponentLibraryProvider` (which
+ // is editor-scoped), so the GitHub library factory isn't auto-registered.
+ // This runs once and is idempotent.
+ useEffect(() => {
+ ensureLibraryFactoriesRegistered();
+ }, []);
const { data: componentLibrary, isLoading: libraryLoading } = useQuery({
queryKey: ["componentLibrary"],
@@ -139,11 +424,94 @@ export const DashboardComponentsV2View = () => {
refetchOnMount: "always",
});
- const rawReferences = collectRawReferences(componentLibrary, userFolder);
+ // Published components (backend). Gated on the backend being reachable; if
+ // it isn't, we silently search without published rather than erroring —
+ // matches the V1 dashboard behaviour.
+ const { data: publishedRefs = [], isLoading: publishedLoading } = useQuery({
+ queryKey: ["component-search-v2", "published", backendUrl],
+ enabled: configured && available,
+ staleTime: HOURS,
+ queryFn: async (): Promise => {
+ const result = await listApiPublishedComponentsGet({});
+ if (result.response.status !== 200 || !result.data) return [];
+ const list = result.data.published_components ?? [];
+ return list
+ .filter((c) => !c.deprecated)
+ .map((c) => ({
+ digest: c.digest,
+ // Backend may return null; normalize to undefined to fit ComponentReference.
+ name: c.name ?? undefined,
+ url: c.url ?? `${backendUrl}/api/components/${c.digest}`,
+ published_by: c.published_by,
+ }));
+ },
+ });
+
+ // Dexie is only the source of which libraries are registered. Fetching
+ // remote/GitHub library contents stays in TanStack Query so loading, errors,
+ // and cache lifetime follow the rest of the app's server-state conventions.
+ const registeredLibraries = useLiveQuery(
+ async () => {
+ ensureLibraryFactoriesRegistered();
+ return LibraryDB.component_libraries.toArray();
+ },
+ [],
+ [],
+ );
+
+ const { data: registeredSourced = [], isLoading: registeredQueryLoading } =
+ useQuery({
+ queryKey: [
+ "component-search-v2",
+ "registered-libraries",
+ registeredLibraries,
+ ],
+ enabled: registeredLibraries !== undefined,
+ staleTime: HOURS,
+ queryFn: async (): Promise => {
+ if (!registeredLibraries || registeredLibraries.length === 0) return [];
+
+ const results = await Promise.allSettled(
+ registeredLibraries.map(async (storage) => {
+ const lib = createLibraryObject(storage);
+ const folder: ComponentFolder = await lib.getComponents({});
+ return { storage, folder };
+ }),
+ );
+
+ const out: SourcedReference[] = [];
+ for (const result of results) {
+ if (result.status !== "fulfilled") {
+ // One broken library shouldn't kill the whole search.
+ console.warn(
+ "Components V2: registered library failed to load",
+ result.reason,
+ );
+ continue;
+ }
+ const source = registeredSource(result.value.storage);
+ for (const ref of flattenFolders(result.value.folder)) {
+ out.push({ reference: ref, source });
+ }
+ }
+ return out;
+ },
+ });
+
+ const registeredLoading =
+ registeredLibraries === undefined || registeredQueryLoading;
+
+ const allSourced = collectAllSourcedReferences({
+ standardLibrary: componentLibrary,
+ publishedRefs,
+ registeredSourced,
+ userFolder,
+ });
+
// Fingerprint of which refs are in play. Changes when the library set
// changes, so the hydration cache invalidates appropriately.
- const referencesFingerprint = rawReferences
- .map((r) => r.digest ?? r.url ?? "")
+ const referencesFingerprint = allSourced
+ .map((s) => s.reference.digest ?? s.reference.url ?? "")
.sort()
.join("|");
@@ -151,18 +519,22 @@ export const DashboardComponentsV2View = () => {
// background refetch shouldn't flip the page back to a skeleton state.
const { data: hydratedReferences, isLoading: hydrating } = useQuery({
queryKey: ["component-search-v2", "hydrate-library", referencesFingerprint],
- enabled: rawReferences.length > 0,
+ enabled: allSourced.length > 0,
staleTime: HOURS,
queryFn: async () => {
const results = await Promise.all(
- rawReferences.map((ref) =>
+ allSourced.map((sourced) =>
// Reuse the same cache key as useHydrateComponentReference so
// individual component cards elsewhere in the app share hydration.
queryClient
.ensureQueryData({
- queryKey: ["component", "hydrate", getComponentQueryKey(ref)],
+ queryKey: [
+ "component",
+ "hydrate",
+ getComponentQueryKey(sourced.reference),
+ ],
staleTime: HOURS,
- queryFn: () => hydrateComponentReference(ref),
+ queryFn: () => hydrateComponentReference(sourced.reference),
})
.catch(() => null),
),
@@ -171,17 +543,35 @@ export const DashboardComponentsV2View = () => {
},
});
- // The search index is a pure derivation from hydrated refs. React
- // Compiler will memoize this; `hydratedReferences` is a stable reference
- // from React Query when nothing has changed.
- const index: IndexEntry[] = buildSearchIndex(hydratedReferences ?? []);
- const total = index.length;
+ // Pair hydrated refs back with their source by digest. Hydration preserves
+ // digests, so this is a straightforward join.
+ const sourceByDigest = new Map(
+ allSourced.map((s) => [s.reference.digest, s.source] as const),
+ );
+ const sourcedHydrated: SourcedReference[] = [];
+ for (const reference of hydratedReferences ?? []) {
+ const source = sourceByDigest.get(reference.digest);
+ if (!source) continue;
+ sourcedHydrated.push({ reference, source });
+ }
+
+ // 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 total = filteredIndex.length;
+ const totalAcrossSources = index.length;
// Alphabetical order for the browse-all view. Predictable scrolling beats
// "whatever order the library happened to load in."
- const sortedIndex = [...index].sort((a, b) => a.name.localeCompare(b.name));
+ const sortedIndex = [...filteredIndex].sort((a, b) =>
+ a.name.localeCompare(b.name),
+ );
- const lexicalMatches: LexicalMatch[] = lexicalSearch(index, deferredQuery, {
+ const lexicalMatches: LexicalMatch[] = lexicalSearch(filteredIndex, query, {
limit: LEXICAL_RESULT_LIMIT,
});
@@ -201,8 +591,6 @@ export const DashboardComponentsV2View = () => {
const handleQueryChange = (event: ChangeEvent) => {
setQuery(event.target.value);
- // Any edit invalidates the rerank. Cheaper to drop it than to think
- // about staleness.
if (rerankedFor !== null) {
setRerankedFor(null);
resetRerank();
@@ -223,8 +611,33 @@ export const DashboardComponentsV2View = () => {
rerank({ query: trimmed, candidates });
};
- const isLoadingLibrary = libraryLoading || userLoading || hydrating;
- const noLibraryData = !isLoadingLibrary && total === 0;
+ const handleSourceToggle = (sourceKey: string) => {
+ setDisabledSourceKeys((current) =>
+ current.includes(sourceKey)
+ ? current.filter((key) => key !== sourceKey)
+ : [...current, sourceKey],
+ );
+ if (rerankedFor !== null) {
+ setRerankedFor(null);
+ resetRerank();
+ }
+ };
+
+ const handleEnableAllSources = () => {
+ setDisabledSourceKeys([]);
+ if (rerankedFor !== null) {
+ setRerankedFor(null);
+ resetRerank();
+ }
+ };
+
+ const isLoadingLibrary =
+ libraryLoading ||
+ userLoading ||
+ publishedLoading ||
+ registeredLoading ||
+ hydrating;
+ const noLibraryData = !isLoadingLibrary && totalAcrossSources === 0;
const trimmedQuery = query.trim();
const isEmpty = trimmedQuery.length === 0;
const isConfigError = rerankError instanceof NaturalLanguageSearchConfigError;
@@ -239,117 +652,272 @@ export const DashboardComponentsV2View = () => {
? mergeRerankIntoLexical(rerankData.matches, lexicalMatches)
: lexicalMatches.map((m) => ({ ...m, reason: undefined }));
- return (
-
-
- Components V2
-
- Type to search your component library. Results match on name,
- description, inputs/outputs, and container command. Use AI search to
- rerank with an LLM when literal matching isn't enough.
-
-
-
-
-
-
-
+ // Resolve the full reference for the selected digest. Prefer the already-
+ // hydrated copy (no extra network), fall back to the un-hydrated index
+ // entry, then to a backend stub. The shared ComponentDetail will suspend on
+ // hydration as needed and shares cache with the rest of the app.
+ const selectedReference: ComponentReference | undefined = (() => {
+ if (!selectedDigest) return undefined;
+ const hydrated = sourcedHydrated.find(
+ (s) => s.reference.digest === selectedDigest,
+ );
+ if (hydrated) return hydrated.reference;
+ const indexed = allSourced.find(
+ (s) => s.reference.digest === selectedDigest,
+ );
+ if (indexed) return indexed.reference;
+ return {
+ digest: selectedDigest,
+ url: `${backendUrl}/api/components/${selectedDigest}`,
+ };
+ })();
+ const isDetailOpen = Boolean(selectedDigest);
+ const notify = useToastNotification();
+
+ const handleCopyToPipeline = async () => {
+ if (!selectedReference) return;
+ try {
+ await copyComponentReferenceToClipboard(selectedReference);
+ notify(
+ "Component copied. Paste (Cmd/Ctrl+V) into a pipeline to add it.",
+ "success",
+ );
+ } catch {
+ notify(
+ "Couldn't copy to clipboard. Check browser permissions and try again.",
+ "error",
+ );
+ }
+ };
- {isLoadingLibrary && (
+ // Render helpers — keeps the JSX below tidy. These read the closed-over
+ // state from the surrounding component; React Compiler memoises them.
+ const renderResults = () => {
+ if (isLoadingLibrary) {
+ return (
- )}
-
- {noLibraryData && (
+ );
+ }
+ if (noLibraryData) {
+ return (
No components found in your library.
- )}
-
- {!isLoadingLibrary && isEmpty && !noLibraryData && (
-
+ );
+ }
+ if (total === 0) {
+ return (
+
+ No components in the selected sources.
+
+ );
+ }
+ if (isEmpty) {
+ return (
+
- {total} components in your library. Start typing to search.
+ {total} component{total === 1 ? "" : "s"} in selected sources. Start
+ typing to search.
- {sortedIndex.map((entry) => (
-
+ {sortedIndex.map((entry, idx) => (
+
))}
- )}
-
- {!isEmpty && lexicalMatches.length === 0 && !isLoadingLibrary && (
+ );
+ }
+ if (lexicalMatches.length === 0) {
+ return (
No components matched “{trimmedQuery}”. Try different terms or check
for typos.
- )}
-
- {!isConfigured && !isEmpty && lexicalMatches.length > 0 && (
-
-
- AI search unavailable
-
-
- Configure an OpenAI-compatible API key to use AI search. Lexical
- results above are unaffected.
-
-
-
- Configure in Settings →
-
-
-
- )}
-
- {rerankError && !isConfigError && rerankError instanceof Error && (
-
- AI search failed: {rerankError.message}
+ );
+ }
+ return (
+
+
+ {rerankActive
+ ? `AI-reranked ${displayedResults.length} result${displayedResults.length === 1 ? "" : "s"} for “${trimmedQuery}”`
+ : `${displayedResults.length} result${displayedResults.length === 1 ? "" : "s"} for “${trimmedQuery}”`}
- )}
+ {displayedResults.map((result, idx) => (
+
+ ))}
+
+ );
+ };
- {!isEmpty && displayedResults.length > 0 && (
-
-
- {rerankActive
- ? `AI-reranked ${displayedResults.length} result${displayedResults.length === 1 ? "" : "s"} for “${trimmedQuery}”`
- : `${displayedResults.length} result${displayedResults.length === 1 ? "" : "s"} for “${trimmedQuery}”`}
-
- {displayedResults.map((result) => (
-
+ {/* Header zone: page title, description, search input. shrink-0 so it
+ never gets squeezed by the body below. */}
+
+
+
+ Components V2
+
+ Type to search across every component source — standard library,
+ your published components, registered libraries, and local user
+ components. Results match on name, description, inputs/outputs,
+ and container command. Use AI search to rerank with an LLM when
+ literal matching isn't enough.
+
+
+
+
- ))}
+
+
+
- )}
-
+
+
+ {/* Body zone: two scroll columns. `min-h-0` is required so the flex
+ children can shrink and scroll instead of growing the parent. */}
+
+ {/* Results column — own scroll. When detail is open, narrows to a
+ fixed width with a divider; otherwise fills the whole body. */}
+
+ {/* AI-search-unavailable banner and rerank error live in the
+ results column — they describe what just happened to the search
+ the user is looking at. */}
+ {!isConfigured && !isEmpty && lexicalMatches.length > 0 && (
+
+
+ AI search unavailable
+
+
+ Configure an OpenAI-compatible API key to use AI search. Lexical
+ results above are unaffected.
+
+
+
+ Configure in Settings →
+
+
+
+ )}
+ {rerankError && !isConfigError && rerankError instanceof Error && (
+
+ AI search failed: {rerankError.message}
+
+ )}
+ {renderResults()}
+
+
+ {/* Detail column — own scroll. Close button sticky to the top of
+ this column's scroll viewport so it stays reachable. */}
+ {isDetailOpen && selectedReference && (
+
+ {/* Sticky action row: copy + close. `float-right` here is
+ intentional — it lets the row sit above the content without
+ taking flow space, and the detail's first heading flows up
+ next to it. Wrapping in a sticky inline-block keeps both
+ buttons pinned together. */}
+
+
+
+
+
+
+ }>
+
+
+
+ )}
+
+
);
};
diff --git a/src/routes/Dashboard/DashboardComponentsView.tsx b/src/routes/Dashboard/DashboardComponentsView.tsx
index 867dc608e..55870c360 100644
--- a/src/routes/Dashboard/DashboardComponentsView.tsx
+++ b/src/routes/Dashboard/DashboardComponentsView.tsx
@@ -3,11 +3,11 @@ import { useNavigate, useSearch } from "@tanstack/react-router";
import { type ReactNode, useEffect, useState } from "react";
import type { ListPublishedComponentsResponse } from "@/api/types.gen";
-import { CodeViewer } from "@/components/shared/CodeViewer";
-import { CopyText } from "@/components/shared/CopyText/CopyText";
+import {
+ ComponentDetail,
+ ComponentDetailSkeleton,
+} from "@/components/shared/ComponentDetail/ComponentDetail";
import { SuspenseWrapper } from "@/components/shared/SuspenseWrapper";
-import { GithubDetails } from "@/components/shared/TaskDetails/GithubDetails";
-import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
@@ -17,8 +17,7 @@ import { Icon } from "@/components/ui/icon";
import { Input } from "@/components/ui/input";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import { Skeleton } from "@/components/ui/skeleton";
-import { Heading, Paragraph, Text } from "@/components/ui/typography";
-import { useHydrateComponentReference } from "@/hooks/useHydrateComponentReference";
+import { Paragraph, Text } from "@/components/ui/typography";
import { cn } from "@/lib/utils";
import { useAnalytics } from "@/providers/AnalyticsProvider";
import { useBackend } from "@/providers/BackendProvider";
@@ -29,11 +28,7 @@ import {
import { APP_ROUTES } from "@/routes/router";
import { fetchAndStoreComponentLibrary } from "@/services/componentService";
import type { ComponentFolder } from "@/types/componentLibrary";
-import type {
- ComponentReference,
- InputSpec,
- OutputSpec,
-} from "@/utils/componentSpec";
+import type { ComponentReference } from "@/utils/componentSpec";
import { componentMetadata } from "@/utils/componentTracking";
import { TOP_NAV_HEIGHT } from "@/utils/constants";
import { fetchWithErrorHandling } from "@/utils/fetchWithErrorHandling";
@@ -365,243 +360,12 @@ const ComponentList = ({
);
};
-// ─── Compact I/O table ───────────────────────────────────────────────────────
-
-const IORow = ({
- name,
- type,
- required,
- defaultValue,
- description,
-}: {
- name: string;
- type?: unknown;
- required?: boolean;
- defaultValue?: unknown;
- description?: string;
-}) => (
-