From ba5f48a4239147878112aaeed19bd7ac98185b37 Mon Sep 17 00:00:00 2001 From: mbeaulne Date: Thu, 18 Jun 2026 14:51:32 -0400 Subject: [PATCH] fix search input lag --- .../DashboardComponentsV2View.test.tsx | 5 +- .../Dashboard/DashboardComponentsV2View.tsx | 71 +++++++++++++++---- 2 files changed, 61 insertions(+), 15 deletions(-) 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 = () => { -