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
5 changes: 4 additions & 1 deletion src/routes/Dashboard/DashboardComponentsV2View.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -414,13 +414,16 @@ describe("DashboardComponentsV2View", () => {
expect(screen.getByText("Registered component")).toBeInTheDocument();
});

it("lets AI search run against a bounded candidate pool when literal search has no matches", () => {
it("lets AI search run against a bounded candidate pool when literal search has no matches", async () => {
routeMocks.aiSearchConfigured = true;
render(<DashboardComponentsV2View />);

fireEvent.change(screen.getByLabelText("Search components"), {
target: { value: "find something semantically relevant" },
});
await waitFor(() => {
expect(screen.getByRole("button", { name: "AI search" })).toBeEnabled();
});
fireEvent.click(screen.getByRole("button", { name: "AI search" }));

expect(routeMocks.rerank).toHaveBeenCalledWith({
Expand Down
71 changes: 57 additions & 14 deletions src/routes/Dashboard/DashboardComponentsV2View.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import Bugsnag from "@bugsnag/js";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import { useLiveQuery } from "dexie-react-hooks";
import { type ChangeEvent, useEffect, useState } from "react";
import {
type ChangeEvent,
useDeferredValue,
useEffect,
useRef,
useState,
} from "react";

import { listApiPublishedComponentsGet } from "@/api/sdk.gen";
import {
Expand Down Expand Up @@ -109,6 +115,7 @@ const SOURCE_ICON_TONE_BY_KIND: Record<ComponentSearchSource["kind"], string> =
const LEXICAL_RESULT_LIMIT = 20;
/** Bounded pool sent to AI search on click. */
const AI_CANDIDATE_LIMIT = 80;
const SEARCH_QUERY_DEBOUNCE_MS = 200;

const MATCH_FIELD_LABEL: Record<MatchField, string> = {
name: "name",
Expand Down Expand Up @@ -456,6 +463,44 @@ function mergeUniqueMatches(
return merged;
}

function DebouncedComponentSearchInput({
onCommit,
disabled,
}: {
onCommit: (value: string) => void;
disabled: boolean;
}) {
const [localValue, setLocalValue] = useState("");
const onCommitRef = useRef(onCommit);

useEffect(() => {
onCommitRef.current = onCommit;
}, [onCommit]);

useEffect(() => {
const timeout = window.setTimeout(() => {
onCommitRef.current(localValue);
}, SEARCH_QUERY_DEBOUNCE_MS);
return () => window.clearTimeout(timeout);
}, [localValue]);

const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setLocalValue(event.target.value);
};

return (
<Input
type="search"
placeholder="e.g. train_test_split, pandas, clean up my data"
value={localValue}
onChange={handleChange}
aria-label="Search components"
disabled={disabled}
className="flex-1"
/>
);
}

function collectAllSourcedReferences({
standardLibrary,
publishedRefs,
Expand Down Expand Up @@ -506,6 +551,7 @@ export const DashboardComponentsV2View = () => {
const { backendUrl, configured, available } = useBackend();
const { config: aiConfig } = useAiProviderSettings();
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const [disabledSourceKeys, setDisabledSourceKeys] = useState<string[]>([]);

// Detail-pane selection lives in the URL so refreshes preserve it and the
Expand Down Expand Up @@ -725,7 +771,7 @@ export const DashboardComponentsV2View = () => {
a.name.localeCompare(b.name),
);

const trimmedQuery = query.trim();
const trimmedQuery = deferredQuery.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 @@ -734,7 +780,9 @@ export const DashboardComponentsV2View = () => {
const broadLexicalMatches: LexicalMatch[] =
trimmedQuery.length === 0
? []
: lexicalSearch(filteredIndex, query, { limit: AI_CANDIDATE_LIMIT });
: lexicalSearch(filteredIndex, deferredQuery, {
limit: AI_CANDIDATE_LIMIT,
});

const lexicalMatches: LexicalMatch[] = broadLexicalMatches.slice(
0,
Expand All @@ -759,7 +807,7 @@ export const DashboardComponentsV2View = () => {

const candidates: LexicalMatch[] = [];
const seenDigests = new Set<string>();
const allLexicalMatches = lexicalSearch(filteredIndex, query, {
const allLexicalMatches = lexicalSearch(filteredIndex, deferredQuery, {
limit: filteredIndex.length,
});
for (const match of allLexicalMatches) {
Expand Down Expand Up @@ -793,8 +841,8 @@ export const DashboardComponentsV2View = () => {
const [isEmbeddingSearchPending, setIsEmbeddingSearchPending] =
useState(false);

const handleQueryChange = (event: ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value);
const handleQueryCommit = (value: string) => {
setQuery(value);
if (rerankedFor !== null) {
setRerankedFor(null);
setRerankBaseMatches([]);
Expand Down Expand Up @@ -823,7 +871,7 @@ export const DashboardComponentsV2View = () => {
};

const startAiSearch = async (matches: LexicalMatch[], limit: number) => {
const trimmed = query.trim();
const trimmed = trimmedQuery;
if (trimmed.length === 0 || matches.length === 0) return;

const embeddingMatches = aiConfig.apiBase.trim()
Expand Down Expand Up @@ -1071,14 +1119,9 @@ export const DashboardComponentsV2View = () => {
</Paragraph>
</BlockStack>
<InlineStack gap="3" blockAlign="center" wrap="nowrap">
<Input
type="search"
placeholder="e.g. train_test_split, pandas, clean up my data"
value={query}
onChange={handleQueryChange}
aria-label="Search components"
<DebouncedComponentSearchInput
onCommit={handleQueryCommit}
disabled={isLoadingLibrary || noLibraryData}
className="flex-1"
/>
<Button
variant="secondary"
Expand Down
Loading