From 8bed990896bd6a024134c47cce57e75f02a3066f Mon Sep 17 00:00:00 2001 From: mbeaulne Date: Mon, 29 Jun 2026 11:39:14 -0400 Subject: [PATCH] Extend editor component search skeleton bars --- .../FlowSidebar/components/ComponentItem.tsx | 36 +++++++++-- .../ComponentSearchResults.test.tsx | 25 ++++++++ .../components/ComponentSearchResults.tsx | 63 +++++++++++++++---- .../ComponentSearchV2Content.test.tsx | 43 ++++++++++++- .../components/ComponentSearchV2Content.tsx | 45 ++++++++++++- 5 files changed, 192 insertions(+), 20 deletions(-) diff --git a/src/components/shared/ReactFlow/FlowSidebar/components/ComponentItem.tsx b/src/components/shared/ReactFlow/FlowSidebar/components/ComponentItem.tsx index 17d89aab5..efdadbd83 100644 --- a/src/components/shared/ReactFlow/FlowSidebar/components/ComponentItem.tsx +++ b/src/components/shared/ReactFlow/FlowSidebar/components/ComponentItem.tsx @@ -17,7 +17,10 @@ import { formatComponentSearchMatchSummary, formatMatchedFieldsExplanation, } from "@/services/componentSearchExplanations"; -import type { MatchField } from "@/services/componentSearchIndex"; +import type { + ComponentSearchSource, + MatchField, +} from "@/services/componentSearchIndex"; import { type ComponentReference, type TaskSpec } from "@/utils/componentSpec"; import { getComponentName } from "@/utils/getComponentName"; import { isSubgraph } from "@/utils/subgraphUtils"; @@ -36,6 +39,7 @@ interface ComponentMarkupProps { matchedFields?: MatchField[]; rerankScore?: number; rerankReason?: string; + source?: ComponentSearchSource; } type ComponentIconProps = { @@ -96,6 +100,7 @@ const ComponentMarkup = ({ matchedFields, rerankScore, rerankReason, + source, }: ComponentMarkupProps) => { const isRemoteComponentLibrarySearchEnabled = useFlagValue( "remote-component-library-search", @@ -107,7 +112,14 @@ const ComponentMarkup = ({ const carousel = useRef(0); const { notifyNode, getNodeIdsByDigest, fitNodeIntoView } = useNodesOverlay(); - const { spec, digest, url, name, owned } = component; + const { + spec, + digest, + url, + name, + owned, + published_by: publishedBy, + } = component; const displayName = useMemo( () => name ?? getComponentName({ spec, url }), @@ -188,9 +200,13 @@ const ComponentMarkup = ({ "shrink-0", isSubgraphSpec ? "text-violet-500" - : owned - ? "text-orange-500" - : "text-blue-500", + : source?.kind === "published" + ? "text-emerald-500" + : source?.kind === "registered" + ? "text-teal-500" + : source?.kind === "user" || owned + ? "text-orange-500" + : "text-blue-500", ); return ( @@ -251,6 +267,16 @@ const ComponentMarkup = ({ > {displayName} + {publishedBy && ( + + Published by {publishedBy} + + )} {matchExplanation && ( { + it("shows a skeleton while search is pending", () => { + render( + , + ); + + expect(screen.getByTestId("search-results-skeleton")).toHaveAttribute( + "aria-label", + "Loading search results", + ); + expect( + screen.getAllByTestId("component-result-title-skeleton"), + ).toHaveLength(5); + expect(screen.getAllByTestId("component-result-why-skeleton")).toHaveLength( + 5, + ); + expect(screen.queryByText("Why:")).not.toBeInTheDocument(); + expect(screen.queryByText("Searching")).not.toBeInTheDocument(); + }); + it("shows actionable no-results guidance with clickable suggestions", () => { const onSuggestedSearch = vi.fn(); render( diff --git a/src/routes/v2/pages/Editor/components/ComponentSearchResults.tsx b/src/routes/v2/pages/Editor/components/ComponentSearchResults.tsx index 7dc43f60e..d7c40761e 100644 --- a/src/routes/v2/pages/Editor/components/ComponentSearchResults.tsx +++ b/src/routes/v2/pages/Editor/components/ComponentSearchResults.tsx @@ -6,9 +6,10 @@ import { } from "@/components/shared/ReactFlow/FlowSidebar/components/ComponentItem"; import FolderItem from "@/components/shared/ReactFlow/FlowSidebar/components/FolderItem"; import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Separator } from "@/components/ui/separator"; -import { Spinner } from "@/components/ui/spinner"; +import { Skeleton } from "@/components/ui/skeleton"; import { Paragraph, Text } from "@/components/ui/typography"; import type { ComponentSearchSuggestion } from "@/services/componentSearchSuggestions"; import type { UIComponentFolder } from "@/types/componentLibrary"; @@ -21,30 +22,68 @@ interface ComponentSearchResultsProps { browseFolders: UIComponentFolder[]; searchSuggestions: ComponentSearchSuggestion[]; isLoading: boolean; + isSearching: boolean; isRerankActive: boolean; onClearRerank: () => void; onSuggestedSearch: (query: string) => void; } +function ComponentSearchResultsSkeleton() { + return ( + + + Search Results + + + + {Array.from({ length: 5 }, (_, index) => ( + + + + + + + + ))} + + + ); +} + export function ComponentSearchResults({ query, results, browseFolders, searchSuggestions, isLoading, + isSearching, isRerankActive, onClearRerank, onSuggestedSearch, }: ComponentSearchResultsProps) { - if (isLoading) { - return ( - - - Search Results - - - - ); + if (isLoading || isSearching) { + return ; } const isEmptyQuery = query.trim().length === 0; @@ -102,7 +141,7 @@ export function ComponentSearchResults({ )} -
+ {results.length > 0 ? ( )} -
+ ); } diff --git a/src/routes/v2/pages/Editor/components/ComponentSearchV2Content.test.tsx b/src/routes/v2/pages/Editor/components/ComponentSearchV2Content.test.tsx index 4bcbd4c91..244b9dc1b 100644 --- a/src/routes/v2/pages/Editor/components/ComponentSearchV2Content.test.tsx +++ b/src/routes/v2/pages/Editor/components/ComponentSearchV2Content.test.tsx @@ -27,8 +27,17 @@ vi.mock("@/routes/v2/pages/Editor/hooks/useComponentSearchV2State", () => ({ })); vi.mock("./ComponentSearchResults", () => ({ - ComponentSearchResults: ({ query }: { query: string }) => ( -
{query}
+ ComponentSearchResults: ({ + query, + isSearching, + }: { + query: string; + isSearching: boolean; + }) => ( +
+
{query}
+
{String(isSearching)}
+
), })); @@ -62,6 +71,7 @@ describe("ComponentSearchV2Content", () => { expect(input).toHaveValue("csv"); expect(screen.getByTestId("results-query")).toHaveTextContent(""); + expect(screen.getByTestId("results-searching")).toHaveTextContent("true"); await act(async () => { vi.advanceTimersByTime(499); @@ -74,6 +84,35 @@ describe("ComponentSearchV2Content", () => { }); expect(screen.getByTestId("results-query")).toHaveTextContent("csv"); + expect(screen.getByTestId("results-searching")).toHaveTextContent("false"); + }); + + it("shows active AI rerank progress below the search box", async () => { + mocks.useComponentSearchV2State.mockImplementation(() => ({ + results: [], + browseFolders: [], + searchSuggestions: [], + isLoading: false, + canRerank: true, + isReranking: true, + isRerankActive: false, + rerank: vi.fn(), + clearRerank: vi.fn(), + })); + + render(); + + expect(screen.getByRole("status")).toHaveTextContent( + "Scanning component candidates with AI…", + ); + + await act(async () => { + vi.advanceTimersByTime(1200); + }); + + expect(screen.getByRole("status")).toHaveTextContent( + "Comparing component candidates with AI…", + ); }); it("tracks editor component search completions without query text", async () => { diff --git a/src/routes/v2/pages/Editor/components/ComponentSearchV2Content.tsx b/src/routes/v2/pages/Editor/components/ComponentSearchV2Content.tsx index 5d246ed75..3e2c4d400 100644 --- a/src/routes/v2/pages/Editor/components/ComponentSearchV2Content.tsx +++ b/src/routes/v2/pages/Editor/components/ComponentSearchV2Content.tsx @@ -15,13 +15,47 @@ import { tracking } from "@/utils/tracking"; import { ComponentSearchResults } from "./ComponentSearchResults"; const EDITOR_SEARCH_RESULT_DEBOUNCE_MS = 500; +const AI_SEARCH_PROGRESS_VERBS = [ + "Scanning", + "Comparing", + "Scoring", + "Ranking", +]; + +function AiRerankProgress() { + const [verbIndex, setVerbIndex] = useState(0); + + useEffect(() => { + const intervalId = window.setInterval(() => { + setVerbIndex( + (current) => (current + 1) % AI_SEARCH_PROGRESS_VERBS.length, + ); + }, 1200); + return () => window.clearInterval(intervalId); + }, []); + + return ( + + + + {AI_SEARCH_PROGRESS_VERBS[verbIndex]} component candidates with AI… + + + ); +} function DebouncedComponentSearchInput({ initialValue, onCommit, + onLocalChange, }: { initialValue: string; onCommit: (value: string) => void; + onLocalChange: (value: string) => void; }) { const [localValue, setLocalValue] = useDebouncedSearchValue( onCommit, @@ -36,7 +70,10 @@ function DebouncedComponentSearchInput({ placeholder="Search components..." className="w-full pl-8 text-sm h-8 focus-visible:ring-gray-400/50" value={localValue} - onChange={(event) => setLocalValue(event.target.value)} + onChange={(event) => { + setLocalValue(event.target.value); + onLocalChange(event.target.value); + }} aria-label="Search components" autoComplete="off" /> @@ -46,6 +83,7 @@ function DebouncedComponentSearchInput({ export function ComponentSearchV2Content() { const { track } = useAnalytics(); const [query, setQuery] = useState(""); + const [localQuery, setLocalQuery] = useState(""); const deferredQuery = useDeferredValue(query); const [, startSearchTransition] = useTransition(); const { @@ -65,10 +103,12 @@ export function ComponentSearchV2Content() { }; const handleSuggestedSearch = (value: string) => { + setLocalQuery(value); startSearchTransition(() => setQuery(value)); }; const trimmedDeferredQuery = deferredQuery.trim(); + const isSearching = localQuery.trim() !== trimmedDeferredQuery; useEffect(() => { if (isLoading || trimmedDeferredQuery.length === 0) return; @@ -121,6 +161,7 @@ export function ComponentSearchV2Content() { + {isReranking && }