Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -36,6 +39,7 @@ interface ComponentMarkupProps {
matchedFields?: MatchField[];
rerankScore?: number;
rerankReason?: string;
source?: ComponentSearchSource;
}

type ComponentIconProps = {
Expand Down Expand Up @@ -96,6 +100,7 @@ const ComponentMarkup = ({
matchedFields,
rerankScore,
rerankReason,
source,
}: ComponentMarkupProps) => {
const isRemoteComponentLibrarySearchEnabled = useFlagValue(
"remote-component-library-search",
Expand All @@ -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 }),
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -251,6 +267,16 @@ const ComponentMarkup = ({
>
{displayName}
</Text>
{publishedBy && (
<Text
size="xs"
tone="subdued"
className="min-w-0 truncate"
title={`Published by ${publishedBy}`}
>
Published by {publishedBy}
</Text>
)}
{matchExplanation && (
<Text
size="xs"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,37 @@ const baseProps = {
{ label: "dataset", kind: "type" },
] satisfies ComponentSearchSuggestion[],
isLoading: false,
isSearching: false,
isRerankActive: false,
onClearRerank: vi.fn(),
onSuggestedSearch: vi.fn(),
};

describe("ComponentSearchResults", () => {
it("shows a skeleton while search is pending", () => {
render(
<ComponentSearchResults
{...baseProps}
query="csv"
results={[]}
isSearching
/>,
);

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(
Expand Down
63 changes: 51 additions & 12 deletions src/routes/v2/pages/Editor/components/ComponentSearchResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 (
<BlockStack
gap="2"
className="px-2 min-h-0 flex-1"
data-testid="search-results-skeleton"
aria-label="Loading search results"
>
<Text tone="subdued" data-testid="search-results-header">
Search Results
</Text>
<Separator />
<BlockStack align="stretch">
{Array.from({ length: 5 }, (_, index) => (
<InlineStack
key={index}
gap="2"
blockAlign="start"
wrap="nowrap"
className="px-3 py-2"
>
<Icon name="Package" size="sm" className="shrink-0 text-gray-300" />
<BlockStack gap="1">
<Skeleton
size="full"
shape="circle"
color="dark"
data-testid="component-result-title-skeleton"
/>
<Skeleton
size="full"
shape="circle"
className="h-3.5 bg-gray-100"
data-testid="component-result-why-skeleton"
/>
</BlockStack>
</InlineStack>
))}
</BlockStack>
</BlockStack>
);
}

export function ComponentSearchResults({
query,
results,
browseFolders,
searchSuggestions,
isLoading,
isSearching,
isRerankActive,
onClearRerank,
onSuggestedSearch,
}: ComponentSearchResultsProps) {
if (isLoading) {
return (
<BlockStack gap="2" className="px-2">
<InlineStack align="start" gap="1">
<Text tone="subdued">Search Results </Text>
<Spinner />
</InlineStack>
</BlockStack>
);
if (isLoading || isSearching) {
return <ComponentSearchResultsSkeleton />;
}

const isEmptyQuery = query.trim().length === 0;
Expand Down Expand Up @@ -102,7 +141,7 @@ export function ComponentSearchResults({
)}
</InlineStack>
<Separator />
<div className="min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden scrollbar-thin">
<BlockStack className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden scrollbar-thin">
{results.length > 0 ? (
<BlockStack
as="ul"
Expand Down Expand Up @@ -137,7 +176,7 @@ export function ComponentSearchResults({
/>
</BlockStack>
)}
</div>
</BlockStack>
</BlockStack>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,17 @@ vi.mock("@/routes/v2/pages/Editor/hooks/useComponentSearchV2State", () => ({
}));

vi.mock("./ComponentSearchResults", () => ({
ComponentSearchResults: ({ query }: { query: string }) => (
<div data-testid="results-query">{query}</div>
ComponentSearchResults: ({
query,
isSearching,
}: {
query: string;
isSearching: boolean;
}) => (
<div>
<div data-testid="results-query">{query}</div>
<div data-testid="results-searching">{String(isSearching)}</div>
</div>
),
}));

Expand Down Expand Up @@ -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);
Expand All @@ -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(<ComponentSearchV2Content />);

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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<InlineStack
gap="2"
blockAlign="center"
className="rounded-md bg-muted/50 px-3 py-2 text-muted-foreground"
>
<Spinner size={14} />
<Text size="xs" tone="subdued" role="status" aria-live="polite">
{AI_SEARCH_PROGRESS_VERBS[verbIndex]} component candidates with AI…
</Text>
</InlineStack>
);
}

function DebouncedComponentSearchInput({
initialValue,
onCommit,
onLocalChange,
}: {
initialValue: string;
onCommit: (value: string) => void;
onLocalChange: (value: string) => void;
}) {
const [localValue, setLocalValue] = useDebouncedSearchValue(
onCommit,
Expand All @@ -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"
/>
Expand All @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -121,6 +161,7 @@ export function ComponentSearchV2Content() {
<DebouncedComponentSearchInput
initialValue={query}
onCommit={handleQueryCommit}
onLocalChange={setLocalQuery}
/>
</div>
<Button
Expand All @@ -141,13 +182,15 @@ export function ComponentSearchV2Content() {
{isReranking ? <Spinner size={14} /> : <Icon name="Sparkles" />}
</Button>
</InlineStack>
{isReranking && <AiRerankProgress />}
</BlockStack>
<ComponentSearchResults
query={deferredQuery}
results={results}
browseFolders={browseFolders}
searchSuggestions={searchSuggestions}
isLoading={isLoading}
isSearching={isSearching}
isRerankActive={isRerankActive}
onClearRerank={clearRerank}
onSuggestedSearch={handleSuggestedSearch}
Expand Down
Loading