Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 71 additions & 7 deletions src/routes/Dashboard/DashboardComponentsV2View.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,23 @@ interface DashboardComponentsV2Search {
}

const routeMocks = vi.hoisted(() => {
const standardSource = {
kind: "standard",
label: "Standard",
id: "standard",
} as const;
const registeredSource = {
kind: "registered",
label: "GitHub library",
id: "github-lib",
} as const;
const publishedSource = {
kind: "published",
label: "Published",
id: "published",
} as const;
const userSource = { kind: "user", label: "User", id: "user" } as const;

const makeComponent = (digest: string, name: string): ComponentReference => ({
digest,
name,
Expand All @@ -32,8 +49,40 @@ const routeMocks = vi.hoisted(() => {
const search: DashboardComponentsV2Search = {};
const descriptionErrorState: { current: Error | null } = { current: null };

const toIndexEntry = (
reference: ComponentReference,
source:
| typeof standardSource
| typeof registeredSource
| typeof publishedSource
| typeof userSource,
): IndexEntry => ({
reference,
digest: reference.digest!,
name: reference.name!,
source,
searchable: {
name: reference.name!.toLowerCase(),
description: reference.spec?.description?.toLowerCase() ?? "",
io: [
...(reference.spec?.inputs ?? []),
...(reference.spec?.outputs ?? []),
]
.map((io) => io.name)
.join(" ")
.toLowerCase(),
implementation: "",
metadata: "",
},
});

return {
standard: makeComponent("standard-digest", "Standard component"),
standardSource,
registeredSource,
publishedSource,
userSource,
toIndexEntry,
registered: makeComponent("registered-digest", "Registered component"),
published: {
...makeComponent("published-digest", "Published component"),
Expand Down Expand Up @@ -108,14 +157,29 @@ vi.mock("@tanstack/react-query", () => ({
}

if (key === "component-search-v2" && queryKey[1] === "hydrate-library") {
const standardSourced = [
routeMocks.standard,
...routeMocks.extraStandardComponents,
].map((reference) => ({
reference,
source: routeMocks.standardSource,
}));
const sourcedHydrated = [
...standardSourced,
{
reference: routeMocks.registered,
source: routeMocks.registeredSource,
},
{ reference: routeMocks.published, source: routeMocks.publishedSource },
{ reference: routeMocks.user, source: routeMocks.userSource },
];
return {
data: [
routeMocks.standard,
routeMocks.registered,
routeMocks.published,
routeMocks.user,
...routeMocks.extraStandardComponents,
],
data: {
sourcedHydrated,
index: sourcedHydrated.map((item) =>
routeMocks.toIndexEntry(item.reference, item.source),
),
},
isLoading: false,
};
}
Expand Down
109 changes: 69 additions & 40 deletions src/routes/Dashboard/DashboardComponentsV2View.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,7 @@ import {
type RerankedMatch,
} from "@/services/naturalLanguageComponentSearchService";
import type { ComponentFolder } from "@/types/componentLibrary";
import type {
ComponentReference,
HydratedComponentReference,
} from "@/utils/componentSpec";
import type { ComponentReference } from "@/utils/componentSpec";
import { componentMetadata } from "@/utils/componentTracking";
import { HOURS, TOP_NAV_HEIGHT } from "@/utils/constants";
import { getComponentName } from "@/utils/getComponentName";
Expand Down Expand Up @@ -201,6 +198,11 @@ export function createRegisteredLibrariesFingerprint(
type ComponentLibraryFolder = Parameters<typeof flattenFolders>[0];
type UserFolder = { components?: ComponentReference[] };

interface HydratedComponentSearchData {
sourcedHydrated: SourcedReference[];
index: IndexEntry[];
}

interface ComponentCollectionMatch {
id: string;
label: string;
Expand Down Expand Up @@ -714,10 +716,12 @@ function DebouncedComponentSearchInput({
onCommit,
disabled,
initialValue,
onLocalChange,
}: {
onCommit: (value: string) => void;
disabled: boolean;
initialValue: string;
onLocalChange?: (value: string) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [localValue, setLocalValue] = useDebouncedSearchValue(
Expand All @@ -733,7 +737,10 @@ function DebouncedComponentSearchInput({
type="search"
placeholder="e.g. train_test_split, pandas, clean up my data"
value={localValue}
onChange={(event) => setLocalValue(event.target.value)}
onChange={(event) => {
setLocalValue(event.target.value);
onLocalChange?.(event.target.value);
}}
aria-label="Search components"
disabled={disabled}
className="flex-1"
Expand Down Expand Up @@ -796,8 +803,9 @@ export const DashboardComponentsV2View = () => {
const disabledSourceKeysFromUrl = readDisabledSourceKeys(dashboardSearch);
const disabledSourceKeysParam = disabledSourceKeysFromUrl.join(",");
const [query, setQuery] = useState(queryFromUrl);
const [isSearching, setIsSearching] = useState(false);
const deferredQuery = useDeferredValue(query);
const [, startSearchTransition] = useTransition();
const [isSearchPending, startSearchTransition] = useTransition();
const [disabledSourceKeys, setDisabledSourceKeys] = useState<string[]>(
disabledSourceKeysFromUrl,
);
Expand Down Expand Up @@ -984,17 +992,23 @@ export const DashboardComponentsV2View = () => {
// Fingerprint of which refs are in play. Changes when the library set
// changes, so the hydration cache invalidates appropriately.
const referencesFingerprint = allSourced
.map((s) => s.reference.digest ?? s.reference.url ?? "")
.map(
(s) =>
`${s.source.kind}:${s.source.id}:${s.source.label}:${s.reference.digest ?? s.reference.url ?? ""}`,
)
.sort()
.join("|");

// Use `isLoading` (first fetch only), not `isFetching` (any fetch). A
// background refetch shouldn't flip the page back to a skeleton state.
const { data: hydratedReferences, isLoading: hydrating } = useQuery({
// background refetch shouldn't flip the page back to a skeleton state. Build
// the pure search index inside the query as well so expensive hydration/index
// derivation is cached by the component fingerprint instead of repeated on
// every search render.
const { data: searchData, isLoading: hydrating } = useQuery({
queryKey: ["component-search-v2", "hydrate-library", referencesFingerprint],
enabled: allSourced.length > 0,
staleTime: HOURS,
queryFn: async () => {
queryFn: async (): Promise<HydratedComponentSearchData> => {
const results = await Promise.all(
allSourced.map((sourced) =>
// Reuse the same cache key as useHydrateComponentReference so
Expand All @@ -1009,49 +1023,52 @@ export const DashboardComponentsV2View = () => {
staleTime: HOURS,
queryFn: () => hydrateComponentReference(sourced.reference),
})
.then((reference) => ({ reference, source: sourced.source }))
.catch(() => null),
),
);
return results.filter((r): r is HydratedComponentReference => r !== null);

const sourcedHydrated: SourcedReference[] = [];
for (const item of results) {
if (!item?.reference) continue;
sourcedHydrated.push({
reference: item.reference,
source: item.source,
});
}

return {
sourcedHydrated,
index: buildSearchIndex(sourcedHydrated),
};
},
});

// Pair hydrated refs back with their source by digest. Hydration preserves
// digests, so this is a straightforward join.
const sourceByDigest = new Map<string, ComponentSearchSource>();
for (const sourced of allSourced) {
if (sourced.reference.digest) {
sourceByDigest.set(sourced.reference.digest, sourced.source);
}
}
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 sourcedHydrated = searchData?.sourcedHydrated ?? [];
const index = searchData?.index ?? [];
const sourceFilterOptions = createSourceFilterOptions(index);
const filteredIndex = filterIndexByDisabledSourceKeys(
index,
disabledSourceKeys,
);
const total = filteredIndex.length;
const totalAcrossSources = index.length;
const isSearchUiPending = isSearching || isSearchPending;
const activeQuery = isSearchUiPending ? "" : deferredQuery;

// Alphabetical order for the browse-all view. Predictable scrolling beats
// "whatever order the library happened to load in."
const sortedIndex = [...filteredIndex].sort((a, b) =>
a.name.localeCompare(b.name),
);
// "whatever order the library happened to load in." Skip it while search is
// pending because the skeleton is showing and sorting the full index can steal
// the keystroke that should reveal the skeleton.
const sortedIndex = isSearchUiPending
? []
: [...filteredIndex].sort((a, b) => a.name.localeCompare(b.name));

useEffect(() => {
setBrowseResultLimit(BROWSE_RESULT_INITIAL_LIMIT);
}, [deferredQuery, disabledSourceKeysParam, total]);

const trimmedQuery = deferredQuery.trim();
const trimmedQuery = activeQuery.trim();

// One lexical pass at the wider AI-candidate limit; the display list is the
// top slice of that same scored result, so we never score and sort the index
Expand All @@ -1060,7 +1077,7 @@ export const DashboardComponentsV2View = () => {
const broadLexicalMatches: LexicalMatch[] =
trimmedQuery.length === 0
? []
: lexicalSearch(filteredIndex, deferredQuery, {
: lexicalSearch(filteredIndex, activeQuery, {
limit: AI_CANDIDATE_LIMIT,
});

Expand All @@ -1070,12 +1087,8 @@ export const DashboardComponentsV2View = () => {
);
const collectionMatches = buildComponentCollectionMatches(
filteredIndex,
deferredQuery,
activeQuery,
);
const searchSuggestions = buildComponentSearchSuggestions(filteredIndex, {
query: trimmedQuery,
});

const aiCandidateMatches: LexicalMatch[] = (() => {
if (trimmedQuery.length === 0) return [];
return broadLexicalMatches;
Expand Down Expand Up @@ -1110,6 +1123,7 @@ export const DashboardComponentsV2View = () => {
const handleQueryCommit = (value: string) => {
startSearchTransition(() => {
setQuery(value);
setIsSearching(false);
if (rerankedFor !== null) {
clearRerank();
}
Expand All @@ -1119,12 +1133,17 @@ export const DashboardComponentsV2View = () => {
const handleSuggestedSearch = (value: string) => {
startSearchTransition(() => {
setQuery(value);
setIsSearching(false);
if (rerankedFor !== null) {
clearRerank();
}
});
};

const handleLocalQueryChange = (value: string) => {
setIsSearching(value.trim() !== query.trim());
};

const buildEmbeddingMatches = async (
trimmed: string,
limit: number,
Expand Down Expand Up @@ -1268,6 +1287,15 @@ export const DashboardComponentsV2View = () => {
rerankScore: undefined,
}));

const searchSuggestions =
lexicalMatches.length === 0 &&
collectionMatches.length === 0 &&
!rerankActive
? buildComponentSearchSuggestions(filteredIndex, {
query: trimmedQuery,
})
: [];

const trackedSearchResultCount =
displayedResults.length + collectionMatches.length;

Expand Down Expand Up @@ -1387,7 +1415,7 @@ export const DashboardComponentsV2View = () => {
// 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) {
if (isLoadingLibrary || isSearchUiPending) {
return (
<BlockStack gap="2">
<Skeleton className="h-20 w-full" />
Expand Down Expand Up @@ -1572,6 +1600,7 @@ export const DashboardComponentsV2View = () => {
onCommit={handleQueryCommit}
disabled={isLoadingLibrary || noLibraryData}
initialValue={query}
onLocalChange={handleLocalQueryChange}
/>
<Button
variant="secondary"
Expand Down
Loading
Loading