{
+ 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 && }