diff --git a/src/routes/Dashboard/DashboardComponentsV2View.test.tsx b/src/routes/Dashboard/DashboardComponentsV2View.test.tsx
index cb563d991..d289e419c 100644
--- a/src/routes/Dashboard/DashboardComponentsV2View.test.tsx
+++ b/src/routes/Dashboard/DashboardComponentsV2View.test.tsx
@@ -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();
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({
diff --git a/src/routes/Dashboard/DashboardComponentsV2View.tsx b/src/routes/Dashboard/DashboardComponentsV2View.tsx
index 7bda95495..c2408d4f0 100644
--- a/src/routes/Dashboard/DashboardComponentsV2View.tsx
+++ b/src/routes/Dashboard/DashboardComponentsV2View.tsx
@@ -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 {
@@ -109,6 +115,7 @@ const SOURCE_ICON_TONE_BY_KIND: Record =
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 = {
name: "name",
@@ -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) => {
+ setLocalValue(event.target.value);
+ };
+
+ return (
+
+ );
+}
+
function collectAllSourcedReferences({
standardLibrary,
publishedRefs,
@@ -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([]);
// Detail-pane selection lives in the URL so refreshes preserve it and the
@@ -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
@@ -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,
@@ -759,7 +807,7 @@ export const DashboardComponentsV2View = () => {
const candidates: LexicalMatch[] = [];
const seenDigests = new Set();
- const allLexicalMatches = lexicalSearch(filteredIndex, query, {
+ const allLexicalMatches = lexicalSearch(filteredIndex, deferredQuery, {
limit: filteredIndex.length,
});
for (const match of allLexicalMatches) {
@@ -793,8 +841,8 @@ export const DashboardComponentsV2View = () => {
const [isEmbeddingSearchPending, setIsEmbeddingSearchPending] =
useState(false);
- const handleQueryChange = (event: ChangeEvent) => {
- setQuery(event.target.value);
+ const handleQueryCommit = (value: string) => {
+ setQuery(value);
if (rerankedFor !== null) {
setRerankedFor(null);
setRerankBaseMatches([]);
@@ -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()
@@ -1071,14 +1119,9 @@ export const DashboardComponentsV2View = () => {
-