From d76c96548091b22377460ee7707b1590bbeb8568 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Fri, 31 Jul 2026 16:11:32 -0700 Subject: [PATCH] refactor: simplify homepage catalog controls (#3364) --- .../home-listing-section.claw591.test.tsx | 54 +- src/__tests__/home-listing-section.test.tsx | 338 +-------- src/__tests__/ui-design-contract.test.ts | 24 +- src/components/HomeListingCategorySelect.tsx | 205 ------ src/components/HomeListingSection.tsx | 670 ++---------------- src/lib/homeListingData.ts | 8 +- src/styles.css | 53 +- 7 files changed, 108 insertions(+), 1244 deletions(-) delete mode 100644 src/components/HomeListingCategorySelect.tsx diff --git a/src/__tests__/home-listing-section.claw591.test.tsx b/src/__tests__/home-listing-section.claw591.test.tsx index 532a6f6b..3b235987 100644 --- a/src/__tests__/home-listing-section.claw591.test.tsx +++ b/src/__tests__/home-listing-section.claw591.test.tsx @@ -122,8 +122,7 @@ describe("HomeListingSection", () => { expect(screen.queryByText("8K")).toBeNull(); expect(screen.queryByText("skills.sh")).toBeNull(); - fireEvent.click(screen.getByRole("button", { name: "Grid view" })); - expect(screen.getAllByLabelText("24-hour downloads")).toHaveLength(2); + expect(screen.queryByRole("button", { name: "Grid view" })).toBeNull(); }); it("hides unavailable Trending and falls back to the Featured feed", async () => { @@ -222,7 +221,7 @@ describe("HomeListingSection", () => { expect.objectContaining({ sort: "newest", createdAfter: expect.any(Number) }), ); }); - expect(screen.getByRole("combobox", { name: "Category" })).toBeTruthy(); + expect(screen.queryByRole("combobox", { name: "Category" })).toBeNull(); fireEvent.click(screen.getByRole("tab", { name: "Featured" })); await waitFor(() => { @@ -271,42 +270,6 @@ describe("HomeListingSection", () => { expect(screen.getByTitle("First Skill")).toBeTruthy(); expect(screen.queryByText("24-hour Trending unavailable")).toBeNull(); }); - - it("keeps search as a separate relevance-first interaction", async () => { - convexActionMock.mockResolvedValue([ - { - skill: makeNativeSkill("search-hit", "Search Hit"), - ownerHandle: "builder", - }, - ]); - render(); - - fireEvent.click(screen.getByRole("button", { name: "Search catalog" })); - fireEvent.change(screen.getByRole("searchbox", { name: "Search skills" }), { - target: { value: "search" }, - }); - - expect(await screen.findByText("Search Hit")).toBeTruthy(); - expect(convexActionMock).toHaveBeenCalledWith( - "search:searchNativeSkills", - expect.objectContaining({ query: "search" }), - ); - }); - - it("supports list and grid presentation without changing feed order", () => { - const first = makeTrending("first", "First Skill", 17, 9000); - const second = makeTrending("second", "Second Skill", 3, 8000); - render(); - - fireEvent.click(screen.getByRole("button", { name: "Grid view" })); - expect(document.querySelector(".home-v2-listing-grid")).toBeTruthy(); - expect( - Array.from( - document.querySelectorAll(".home-v2-listing-card-name"), - (node) => node.textContent, - ), - ).toEqual(["First Skill", "Second Skill"]); - }); }); function initialTrending( @@ -372,16 +335,3 @@ function makeTrending( }, }; } - -function makeNativeSkill(slug: string, displayName: string) { - return { - _id: `skills:${slug}`, - slug, - displayName, - summary: `${displayName} summary`, - stats: { comments: 0, downloads: 0, installs: 0, stars: 0, versions: 1 }, - tags: {}, - createdAt: 1, - updatedAt: 1, - }; -} diff --git a/src/__tests__/home-listing-section.test.tsx b/src/__tests__/home-listing-section.test.tsx index 675e604e..4536442b 100644 --- a/src/__tests__/home-listing-section.test.tsx +++ b/src/__tests__/home-listing-section.test.tsx @@ -137,12 +137,15 @@ describe("HomeListingSection", () => { }); }); - it("renders a provided Featured plugin listing as a list", async () => { + it("renders listing tabs left, content tabs right, and only the list presentation", async () => { render(); - const contentTypeButtons = screen - .getByRole("group", { name: "Content type" }) - .querySelectorAll("button"); + const toolbar = document.querySelector(".home-v2-listing-toolbar"); + const sortTabs = screen.getByRole("tablist", { name: "Sort" }); + const contentType = screen.getByRole("group", { name: "Content type" }); + const contentTypeButtons = contentType.querySelectorAll("button"); + expect(toolbar?.firstElementChild?.contains(sortTabs)).toBe(true); + expect(toolbar?.lastElementChild).toBe(contentType); expect(Array.from(contentTypeButtons, (button) => button.textContent)).toEqual([ "Skills", "Plugins", @@ -153,14 +156,15 @@ describe("HomeListingSection", () => { expect(screen.getByRole("tab", { name: "Featured" }).getAttribute("aria-selected")).toBe( "true", ); - expect(screen.getByRole("button", { name: "List view" }).getAttribute("aria-pressed")).toBe( - "true", - ); expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual([ "Featured", "Official", "New", ]); + expect(screen.queryByRole("button", { name: "Search catalog" })).toBeNull(); + expect(screen.queryByRole("combobox", { name: "Category" })).toBeNull(); + expect(screen.queryByRole("button", { name: "List view" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Grid view" })).toBeNull(); expect(screen.getByText("Demo Plugin")).toBeTruthy(); expect(document.querySelector(".home-v2-listing-list")).toBeTruthy(); expect(document.querySelector(".marketplace-icon-image")?.getAttribute("src")).toBe( @@ -263,148 +267,6 @@ describe("HomeListingSection", () => { }); }); - it("opens listing search from the toolbar icon and with slash", async () => { - convexActionMock.mockResolvedValue([ - { - skill: { - _id: "skills:1", - slug: "alpha-skill", - displayName: "Alpha Skill", - summary: "Alpha", - stats: { stars: 1, downloads: 1 }, - }, - ownerHandle: "builder", - }, - ]); - - renderSkillsListing(); - - fireEvent.click(screen.getByRole("button", { name: "Search catalog" })); - expect(document.querySelector(".home-v2-listing-search.is-open")).toBeTruthy(); - - fireEvent.click(screen.getByRole("button", { name: "Close search" })); - expect(document.querySelector(".home-v2-listing-search.is-open")).toBeNull(); - - fireEvent.keyDown(document, { key: "/" }); - - const searchInput = await screen.findByRole("searchbox", { name: "Search skills" }); - expect(document.querySelector(".home-v2-listing-search.is-open")).toBeTruthy(); - - fireEvent.change(searchInput, { target: { value: "alpha" } }); - - await waitFor(() => { - expect(convexActionMock).toHaveBeenCalledWith("search:searchNativeSkills", { - query: "alpha", - limit: 20, - }); - expect(screen.getByText("Alpha Skill")).toBeTruthy(); - }); - }); - - it("searches skills inside the selected category before truncating results", async () => { - convexActionMock.mockResolvedValue([ - { - skill: { - _id: "skills:dev-alpha", - slug: "dev-alpha", - displayName: "Dev Alpha", - summary: "Alpha", - categories: ["development"], - stats: { stars: 1, downloads: 1 }, - }, - ownerHandle: "builder", - }, - ]); - - renderSkillsListing(); - - fireEvent.click(screen.getByRole("combobox", { name: "Category" })); - fireEvent.click(screen.getByRole("option", { name: "Development" })); - - await waitFor(() => { - expect(screen.getByRole("combobox", { name: "Category" }).textContent).toContain( - "Development", - ); - }); - - fireEvent.click(screen.getByRole("button", { name: "Search catalog" })); - const searchInput = await screen.findByRole("searchbox", { name: "Search skills" }); - fireEvent.change(searchInput, { target: { value: "alpha" } }); - - await waitFor(() => { - expect(convexActionMock).toHaveBeenCalledWith("search:searchNativeSkills", { - query: "alpha", - limit: 20, - categorySlug: "development", - }); - expect(screen.getByText("Dev Alpha")).toBeTruthy(); - }); - }); - - it("keeps listing search fetch-on-query instead of serving repeated queries from tab cache", async () => { - convexActionMock.mockResolvedValue([ - { - skill: { - _id: "skills:alpha", - slug: "alpha-skill", - displayName: "Alpha Skill", - summary: "Alpha", - stats: { stars: 1, downloads: 1 }, - }, - ownerHandle: "builder", - }, - ]); - - renderSkillsListing(); - - fireEvent.click(screen.getByRole("button", { name: "Search catalog" })); - const searchInput = await screen.findByRole("searchbox", { name: "Search skills" }); - fireEvent.change(searchInput, { target: { value: "alpha" } }); - - await waitFor(() => { - expect(convexActionMock).toHaveBeenCalledTimes(1); - }); - - fireEvent.change(searchInput, { target: { value: "" } }); - await waitFor(() => { - expect(screen.queryByText("Alpha Skill")).toBeNull(); - }); - - fireEvent.change(searchInput, { target: { value: "alpha" } }); - - await waitFor(() => { - expect(convexActionMock).toHaveBeenCalledTimes(2); - }); - }); - - it("renders the canonical skill and plugin category definitions", async () => { - renderSkillsListing(); - - await waitFor(() => { - expect(screen.getByText("Demo Skill").textContent).toBe("Demo Skill"); - }); - - const categorySelect = screen.getByRole("combobox", { name: "Category" }); - expect(categorySelect.getAttribute("aria-expanded")).toBe("false"); - expect(categorySelect.textContent).toContain("All categories"); - - fireEvent.click(categorySelect); - expect( - screen.getByRole("listbox", { name: "Category" }).getAttribute("aria-multiselectable"), - ).toBe("true"); - expect(screen.getByRole("option", { name: "All categories" }).textContent).toContain( - "All categories", - ); - expect(screen.getByRole("option", { name: "Integrations" }).textContent).toContain( - "Integrations", - ); - expect(screen.getByRole("option", { name: "Security" }).textContent).toContain("Security"); - - fireEvent.click(screen.getByRole("button", { name: "Plugins" })); - expect(screen.getByRole("option", { name: "Channels" }).textContent).toContain("Channels"); - expect(screen.getByRole("option", { name: "Runtime" }).textContent).toContain("Runtime"); - }); - it("expands the listing preview when see more is clicked", async () => { const rows = Array.from({ length: 35 }, (_, index) => ({ skill: { @@ -482,50 +344,6 @@ describe("HomeListingSection", () => { expect(convexQueryMock).toHaveBeenCalledTimes(2); }); - it("keeps Featured active for skill search", async () => { - convexActionMock.mockResolvedValue([ - { - skill: { - _id: "skills:featured-search", - slug: "featured-search", - displayName: "Featured Search Skill", - stats: { installs: 0 }, - }, - ownerHandle: "builder", - }, - ]); - - render(); - fireEvent.click(screen.getByRole("button", { name: "Skills" })); - fireEvent.click(screen.getByRole("tab", { name: "Featured" })); - fireEvent.click(screen.getByRole("button", { name: "Search catalog" })); - fireEvent.change(screen.getByRole("searchbox"), { target: { value: "search" } }); - - await waitFor(() => { - expect(convexActionMock).toHaveBeenCalledWith("search:searchNativeSkills", { - query: "search", - limit: 20, - highlightedOnly: true, - }); - expect(screen.getByText("Featured Search Skill")).toBeTruthy(); - }); - }); - - it("keeps Featured active for plugin search", async () => { - render(); - fireEvent.click(screen.getByRole("button", { name: "Search catalog" })); - fireEvent.change(screen.getByRole("searchbox"), { target: { value: "search" } }); - - await waitFor(() => { - expect(fetchPluginCatalogMock).toHaveBeenLastCalledWith( - expect.objectContaining({ - featured: true, - q: "search", - }), - ); - }); - }); - it("reuses cached plugin tabs instead of refetching when switching back", async () => { fetchPluginCatalogMock.mockImplementation((args: { isOfficial?: boolean }) => Promise.resolve({ @@ -628,138 +446,4 @@ describe("HomeListingSection", () => { expect.objectContaining({ cursor: "skills-cursor-2" }), ); }); - - it("keeps load more available when selected skill categories overflow after merging", async () => { - const makeEntry = (index: number, category: string) => ({ - skill: { - _id: `skills:${category}:${index}`, - slug: `${category}-skill-${index}`, - displayName: `${category} Skill ${index}`, - summary: "Category skill.", - categories: [category], - stats: { installs: 100 - index }, - }, - ownerHandle: "builder", - }); - const development = Array.from({ length: 12 }, (_, index) => makeEntry(index, "development")); - const security = Array.from({ length: 12 }, (_, index) => makeEntry(index, "security")); - - convexQueryMock.mockImplementation((name, args?: { categorySlug?: string }) => { - if (name !== "skills:listPublicPageV4") { - return Promise.resolve({ items: [], nextCursor: null }); - } - if (args?.categorySlug === "development") { - return Promise.resolve({ page: development, hasMore: false, nextCursor: null }); - } - if (args?.categorySlug === "security") { - return Promise.resolve({ page: security, hasMore: false, nextCursor: null }); - } - return Promise.resolve({ page: development, hasMore: false, nextCursor: null }); - }); - - renderSkillsListing(); - - await waitFor(() => { - expect(screen.getByText("development Skill 0")).toBeTruthy(); - }); - - fireEvent.click(screen.getByRole("combobox", { name: "Category" })); - fireEvent.click(screen.getByRole("option", { name: "Development" })); - - await waitFor(() => { - expect(screen.getByRole("combobox", { name: "Category" }).textContent).toContain( - "Development", - ); - }); - - fireEvent.click(screen.getByRole("option", { name: "Security" })); - - await waitFor(() => { - expect(screen.getByRole("combobox", { name: "Category" }).textContent).toContain( - "2 categories", - ); - expect(screen.getByRole("button", { name: "Load more" })).toBeTruthy(); - }); - expect(screen.queryByText("security Skill 11")).toBeNull(); - - fireEvent.click(screen.getByRole("button", { name: "Load more" })); - - await waitFor(() => { - expect(screen.getByText("security Skill 11")).toBeTruthy(); - }); - }); - - it("allows selecting multiple skill categories and refetches each selected category", async () => { - convexQueryMock.mockResolvedValue({ - page: [ - { - skill: { - _id: "skills:inferred", - slug: "inferred-skill", - displayName: "Inferred Skill", - summary: "Uses inferred category metadata.", - inferredCategories: ["development"], - latestVersionId: "versions:1", - inferredFromVersionId: "versions:1", - stats: { installs: 10 }, - }, - ownerHandle: "builder", - }, - ], - hasMore: false, - nextCursor: null, - }); - - renderSkillsListing(); - - await waitFor(() => { - expect(screen.getByText("Inferred Skill")).toBeTruthy(); - }); - - convexQueryMock.mockClear(); - fireEvent.click(screen.getByRole("combobox", { name: "Category" })); - fireEvent.click(screen.getByRole("option", { name: "Development" })); - - await waitFor(() => { - expect(convexQueryMock).toHaveBeenCalledWith( - "skills:listPublicPageV4", - expect.objectContaining({ categorySlug: "development" }), - ); - }); - expect(screen.getByRole("combobox", { name: "Category" }).textContent).toContain("Development"); - await waitFor(() => { - expect(screen.getByText("Inferred Skill")).toBeTruthy(); - }); - - convexQueryMock.mockClear(); - fireEvent.click(screen.getByRole("option", { name: "Security" })); - - await waitFor(() => { - expect(convexQueryMock).toHaveBeenCalledWith( - "skills:listPublicPageV4", - expect.objectContaining({ categorySlug: "development" }), - ); - expect(convexQueryMock).toHaveBeenCalledWith( - "skills:listPublicPageV4", - expect.objectContaining({ categorySlug: "security" }), - ); - }); - expect(screen.getByRole("combobox", { name: "Category" }).textContent).toContain( - "2 categories", - ); - expect(screen.getByRole("option", { name: "Development" }).getAttribute("aria-selected")).toBe( - "true", - ); - expect(screen.getByRole("option", { name: "Security" }).getAttribute("aria-selected")).toBe( - "true", - ); - - fireEvent.click(screen.getByRole("option", { name: "All categories" })); - - await waitFor(() => { - expect(screen.getByRole("combobox", { name: "Category" }).textContent).toContain( - "All categories", - ); - }); - }); }); diff --git a/src/__tests__/ui-design-contract.test.ts b/src/__tests__/ui-design-contract.test.ts index 0a553623..06ffdffc 100644 --- a/src/__tests__/ui-design-contract.test.ts +++ b/src/__tests__/ui-design-contract.test.ts @@ -103,13 +103,6 @@ describe("restored UI design contract", () => { it("uses semantic design-system geometry across landing controls and surfaces", () => { const css = styles(); - for (const selector of [ - ".home-v2-listing-search-bar", - ".home-v2-listing-search-close", - ".home-v2-listing-category-trigger", - ]) { - expect(cssRule(css, selector)).toContain("border-radius: var(--oc-radius-control)"); - } expect(cssRule(css, ".promotion-bar-icon")).toContain("border-radius: var(--oc-radius-inset)"); expect(cssRule(css, ".home-v2-apps-workflow-tile")).toContain( "border-radius: var(--oc-radius-surface)", @@ -354,14 +347,16 @@ describe("restored UI design contract", () => { expect(css.lastIndexOf(".home-v2-static-headline")).toBeGreaterThan( css.lastIndexOf(".home-v2-action-word"), ); - expect(listingSource).toContain("home-v2-listing-card oc-card oc-card-interactive"); + expect(listingSource).toContain('className="home-v2-listing-list"'); expect(listingSource).toContain("home-v2-listing-kind clawhub-segmented oc-segmented"); expect(listingSource).toContain( "home-v2-listing-kind-btn clawhub-segmented-btn oc-segmented-item", ); - expect(listingSource).toContain("home-v2-listing-view clawhub-segmented oc-segmented"); - expect(listingSource).toContain( - "home-v2-listing-view-btn clawhub-segmented-btn oc-segmented-item", + expect(listingSource).not.toContain("home-v2-listing-view"); + expect(listingSource).not.toContain("HomeListingCategorySelect"); + expect(listingSource).not.toContain("Search catalog"); + expect(listingSource.indexOf('className="home-v2-listing-sort"')).toBeLessThan( + listingSource.indexOf('className="home-v2-listing-kind'), ); expect(appsSource).toContain('className="home-v2-apps-tile"'); expect(appsSource).toContain('className="home-v2-apps-workflow-header"'); @@ -385,13 +380,8 @@ describe("restored UI design contract", () => { expect(homeSource).not.toContain("What are you looking for?"); expect(homeSource).not.toContain("Featured skills"); expect(homeSource).not.toContain("Trending Now"); - expect(listingSource).toContain("SKILL_CATEGORIES"); - expect(listingSource).toContain("PLUGIN_CATEGORIES"); - expect(listingSource).toContain("HomeListingCategorySelect"); expect(cssRule(css, ".home-v2-listing-toolbar")).toContain("display: flex"); - expect(cssRule(css, ".home-v2-listing-grid")).toContain( - "grid-template-columns: repeat(3, minmax(0, 1fr))", - ); + expect(cssRule(css, ".home-v2-listing-kind")).toContain("margin-left: auto"); }); it("requires the restored footer columns and mobile section toggles", () => { diff --git a/src/components/HomeListingCategorySelect.tsx b/src/components/HomeListingCategorySelect.tsx deleted file mode 100644 index 77ef11c1..00000000 --- a/src/components/HomeListingCategorySelect.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { Check, ChevronDown, Search } from "lucide-react"; -import { useEffect, useId, useMemo, useRef, useState } from "react"; -import { BrowseCategoryIcon } from "../lib/browseCategoryIcons"; -import type { BrowseCategory } from "../lib/categories"; - -type HomeListingCategorySelectProps = { - categories: readonly BrowseCategory[]; - value: readonly string[]; - onChange: (slugs: string[]) => void; -}; - -function CategoryOption({ - slug, - label, - icon, - selected, - onSelect, - reset = false, -}: { - slug: string | null; - label: string; - icon?: string | null; - selected: boolean; - onSelect: () => void; - reset?: boolean; -}) { - return ( -
  • - -
  • - ); -} - -export function HomeListingCategorySelect({ - categories, - value, - onChange, -}: HomeListingCategorySelectProps) { - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); - const rootRef = useRef(null); - const searchRef = useRef(null); - const listboxId = useId(); - - const selectedLabel = useMemo(() => { - if (value.length === 0) return "All categories"; - if (value.length === 1) { - return categories.find((category) => category.slug === value[0])?.label ?? "All categories"; - } - return `${value.length} categories`; - }, [categories, value]); - const selectedCategory = useMemo( - () => (value.length === 1 ? categories.find((category) => category.slug === value[0]) : null), - [categories, value], - ); - - const selectedSet = useMemo(() => new Set(value), [value]); - - const filteredCategories = useMemo(() => { - const normalized = query.trim().toLowerCase(); - if (!normalized) return categories; - return categories.filter( - (category) => - category.label.toLowerCase().includes(normalized) || category.slug.includes(normalized), - ); - }, [categories, query]); - - const closeMenu = () => { - setOpen(false); - setQuery(""); - }; - - useEffect(() => { - if (!open) return undefined; - const onPointerDown = (event: MouseEvent) => { - if (rootRef.current?.contains(event.target as Node)) return; - closeMenu(); - }; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") closeMenu(); - }; - document.addEventListener("mousedown", onPointerDown); - document.addEventListener("keydown", onKeyDown); - return () => { - document.removeEventListener("mousedown", onPointerDown); - document.removeEventListener("keydown", onKeyDown); - }; - }, [open]); - - useEffect(() => { - if (open) searchRef.current?.focus(); - }, [open]); - - const pick = (slug: string | null) => { - if (slug === null) { - onChange([]); - return; - } - if (selectedSet.has(slug)) { - onChange(value.filter((selectedSlug) => selectedSlug !== slug)); - return; - } - onChange([...value, slug]); - }; - - return ( -
    - - - {open ? ( -
    -
    -
    -
      - {!query.trim() ? ( - pick(null)} - reset - /> - ) : null} - {filteredCategories.map((category) => ( - pick(category.slug)} - /> - ))} - {filteredCategories.length === 0 ? ( -
    • - No categories match -
    • - ) : null} -
    -
    - ) : null} -
    - ); -} diff --git a/src/components/HomeListingSection.tsx b/src/components/HomeListingSection.tsx index 3e11f7d1..cbb5fbcf 100644 --- a/src/components/HomeListingSection.tsx +++ b/src/components/HomeListingSection.tsx @@ -1,40 +1,12 @@ import { Link } from "@tanstack/react-router"; -import { isPluginCategorySlug, isSkillCategorySlug } from "clawhub-schema"; -import { - Binoculars, - Bookmark, - CloudOff, - Download, - LayoutGrid, - Loader2, - Moon, - Plus, - Rows3, - Search, - X, -} from "lucide-react"; -import { - type FormEvent, - type ReactNode, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { api } from "../../convex/_generated/api"; -import { convexHttp } from "../convex/client"; -import { PLUGIN_CATEGORIES, SKILL_CATEGORIES, type BrowseCategory } from "../lib/categories"; +import { Bookmark, CloudOff, Download, Loader2, Moon, Plus } from "lucide-react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { fetchHomePluginListing as fetchPluginListing, fetchHomeSkillListing as fetchSkillListing, HOME_LISTING_PAGE_SIZE, homeListingCacheKey as listingCacheKey, isHomeTrendingSkillEntry, - itemMatchesAnyHomeCategory as itemMatchesAnyCategory, - skillMatchesAnyHomeCategory as skillMatchesAnyCategory, - uniqueHomePlugins as uniquePlugins, - uniqueHomeSkillEntries as uniqueSkillEntries, type HomeListingCacheEntry, type HomeListingInitialData, type HomeListingKind as ListingKind, @@ -44,18 +16,14 @@ import { type TrendingFeedState, } from "../lib/homeListingData"; import { formatCompactStat } from "../lib/numberFormat"; -import { fetchPluginCatalog, type PackageListItem } from "../lib/packageApi"; +import type { PackageListItem } from "../lib/packageApi"; import { buildPluginDetailHref } from "../lib/pluginRoutes"; import { presentationTitle } from "../lib/presentationTitle"; -import type { PublicSkill, PublicUser } from "../lib/publicUser"; import { PUBLIC_CATALOG_NAME_PREVIEW_LENGTH, truncateText } from "../lib/truncateText"; -import { HomeListingCategorySelect } from "./HomeListingCategorySelect"; import { MarketplaceIcon } from "./MarketplaceIcon"; import { OfficialBadge } from "./OfficialBadge"; import { BrowseResultsSkeleton } from "./skeletons/BrowseResultsSkeleton"; -type ListingView = "list" | "grid"; - const SKILL_LISTING_TABS: Array<{ id: ListingTab; label: string }> = [ { id: "trending", label: "Trending" }, { id: "featured", label: "Featured" }, @@ -73,43 +41,14 @@ const PLUGIN_LISTING_TABS: Array<{ ]; const LISTING_PAGE_SIZE = HOME_LISTING_PAGE_SIZE; -const LISTING_SEARCH_DEBOUNCE_MS = 220; - -const HOME_SKILL_LISTING_CATEGORIES: BrowseCategory[] = SKILL_CATEGORIES.map( - ({ slug, label, icon }) => ({ slug, label, icon }), -); - -function isTypingTarget(target: EventTarget | null) { - if (!(target instanceof HTMLElement)) return false; - return ( - target.isContentEditable || - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.tagName === "SELECT" - ); -} - -type SkillSearchHit = { - skill: PublicSkill; - ownerHandle?: string | null; - owner?: PublicUser | null; -}; +const EMPTY_CATEGORY_SLUGS: string[] = []; function HomeListingEmptyPanel({ variant, - query, - onClearSearch, }: { - variant: "error" | "search" | "filter" | "trendingEmpty" | "trendingUnavailable"; - query?: string; - onClearSearch?: () => void; + variant: "error" | "empty" | "trendingEmpty" | "trendingUnavailable"; }) { - const Icon = - variant === "error" || variant === "trendingUnavailable" - ? CloudOff - : variant === "search" - ? Binoculars - : Moon; + const Icon = variant === "error" || variant === "trendingUnavailable" ? CloudOff : Moon; const title = variant === "trendingUnavailable" ? "24-hour Trending unavailable" @@ -117,11 +56,7 @@ function HomeListingEmptyPanel({ ? "No 24-hour activity yet" : variant === "error" ? "Listings took a coffee break" - : variant === "search" - ? query - ? `No claws for “${query}”` - : "No claws in this view" - : "Quiet shelf"; + : "Quiet shelf"; const body = variant === "trendingUnavailable" ? "The canonical 24-hour feed isn't available right now. Try another tab." @@ -129,9 +64,7 @@ function HomeListingEmptyPanel({ ? "No skills have eligible activity in the current 24-hour window." : variant === "error" ? "We couldn't load this slice of the catalog. Give it another try in a moment." - : variant === "search" - ? "Try another query or clear the search." - : "Nothing on this tab right now. Peek at another tab or widen the category."; + : "Nothing on this tab right now. Peek at another tab."; return (
    @@ -140,33 +73,23 @@ function HomeListingEmptyPanel({

    {title}

    {body}

    - {variant === "search" && onClearSearch ? ( - - ) : null} ); } function HomeListingResults({ - view, showMore, loadingMore, onSeeMore, children, }: { - view: ListingView; showMore: boolean; loadingMore: boolean; onSeeMore: () => void; children: ReactNode; }) { return ( -
    +
    {children} {showMore ? (
    @@ -313,124 +236,6 @@ function HomeListingPluginRow({ plugin }: { plugin: PackageListItem }) { ); } -function HomeListingSkillCard({ entry, showStats }: { entry: SkillPageEntry; showStats: boolean }) { - if (isHomeTrendingSkillEntry(entry)) { - const item = entry.trending; - const owner = item.publisher?.handle; - return ( - -
    - -
    - - {truncateText(item.displayName, PUBLIC_CATALOG_NAME_PREVIEW_LENGTH)} - - {owner ? @{owner} : null} -
    -
    -

    - {truncateText(item.summary || "Agent-ready skill pack.", 80)} -

    - {typeof item.metrics.trending24hDownloads === "number" ? ( -
    - - -
    - ) : null} - - ); - } - const handle = entry.ownerHandle || entry.owner?.handle; - const name = presentationTitle(entry.skill.displayName, entry.skill.slug); - - return ( - -
    - -
    - - {truncateText(name, PUBLIC_CATALOG_NAME_PREVIEW_LENGTH)} - - {handle ? @{handle} : null} -
    -
    -

    - {truncateText(entry.skill.summary || "Agent-ready skill pack.", 80)} -

    - {showStats ? ( -
    - - - - -
    - ) : null} - - ); -} - -function HomeListingPluginCard({ plugin }: { plugin: PackageListItem }) { - const name = presentationTitle(plugin.displayName, plugin.name); - const pluginHref = buildPluginDetailHref(plugin.name, { ownerHandle: plugin.ownerHandle }); - - return ( - -
    - -
    - - {truncateText(name, PUBLIC_CATALOG_NAME_PREVIEW_LENGTH)} - - - {plugin.ownerHandle ? ( - @{plugin.ownerHandle} - ) : null} - {plugin.isOfficial ? : null} - -
    -
    -

    - {truncateText(plugin.summary || "Gateway plugin for OpenClaw workflows.", 80)} -

    -
    - - -
    - - ); -} - type HomeListingSectionProps = { initialListing?: HomeListingInitialData | null; }; @@ -462,8 +267,6 @@ function createInitialListingCache(initialListing: HomeListingInitialData | null } export function HomeListingSection({ initialListing = null }: HomeListingSectionProps = {}) { - const searchInputRef = useRef(null); - const searchRequestRef = useRef(0); const listingCacheRef = useRef | null>(null); listingCacheRef.current ??= createInitialListingCache(initialListing); const listingCache = listingCacheRef.current; @@ -474,8 +277,6 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection ? "featured" : (initialListing?.tab ?? "trending"); const [tab, setTab] = useState(initialTab); - const [view, setView] = useState("list"); - const [categorySlugs, setCategorySlugs] = useState([]); const [visibleCount, setVisibleCount] = useState(LISTING_PAGE_SIZE); const [fetchLimit, setFetchLimit] = useState(LISTING_PAGE_SIZE); const [skills, setSkills] = useState( @@ -488,11 +289,6 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection initialListing ? "idle" : "loading", ); const [loadingMore, setLoadingMore] = useState(false); - const [searchOpen, setSearchOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [searchSkills, setSearchSkills] = useState([]); - const [searchPlugins, setSearchPlugins] = useState([]); - const [searchStatus, setSearchStatus] = useState<"idle" | "loading" | "error">("idle"); const [listingHasMore, setListingHasMore] = useState(initialListing?.hasMore ?? false); const [trendingState, setTrendingState] = useState( initialListing?.kind === "skills" ? initialListing.trendingState : undefined, @@ -501,18 +297,6 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection initialListing?.kind === "skills" && initialListing.trendingState === "unavailable", ); - const trimmedSearch = searchQuery.trim(); - const isSearchMode = trimmedSearch.length > 0; - const listingCategories = kind === "skills" ? HOME_SKILL_LISTING_CATEGORIES : PLUGIN_CATEGORIES; - const selectedCategories = useMemo( - () => - categorySlugs.flatMap((slug) => { - const category = listingCategories.find((candidate) => candidate.slug === slug); - return category ? [category] : []; - }), - [categorySlugs, listingCategories], - ); - const visibleTabs = kind === "skills" ? SKILL_LISTING_TABS.filter( @@ -520,55 +304,20 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection ) : PLUGIN_LISTING_TABS; - const activeItems = isSearchMode - ? kind === "skills" - ? searchSkills - : searchPlugins - : kind === "skills" - ? skills - : plugins; - const activeStatus = isSearchMode ? searchStatus : status; + const activeItems = kind === "skills" ? skills : plugins; + const activeStatus = status; const isEmpty = activeStatus === "idle" && activeItems.length === 0; const showSkillStats = true; const showListingMore = activeStatus === "idle" && (activeItems.length > visibleCount || listingHasMore); - const openListingSearch = useCallback(() => { - setSearchOpen(true); - window.requestAnimationFrame(() => searchInputRef.current?.focus()); - }, []); - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return; - if (event.defaultPrevented) return; - if (isTypingTarget(event.target)) return; - event.preventDefault(); - openListingSearch(); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [openListingSearch]); - - useEffect(() => { - if (!searchOpen) return undefined; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; - event.preventDefault(); - if (trimmedSearch) { - setSearchQuery(""); - return; - } - setSearchOpen(false); - searchInputRef.current?.blur(); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [searchOpen, trimmedSearch]); - - useEffect(() => { - if (isSearchMode) return undefined; - const cacheKey = listingCacheKey({ kind, tab, categorySlugs, fetchLimit }); + const cacheKey = listingCacheKey({ + kind, + tab, + categorySlugs: EMPTY_CATEGORY_SLUGS, + fetchLimit, + }); const cached = listingCache.get(cacheKey); if (cached) { if (cached.kind === "skills") { @@ -603,27 +352,29 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection const load = kind === "skills" - ? fetchSkillListing(tab, categorySlugs, fetchLimit, controller.signal).then((result) => { - if (controller.signal.aborted) return; - listingCache.set(cacheKey, { - kind: "skills", - items: result.page, - hasMore: result.hasMore, - trendingState: result.trendingState, - }); - setSkills(result.page); - setTrendingState(result.trendingState); - if (tab === "trending") { - const unavailable = result.trendingState === "unavailable"; - setCanonicalTrendingUnavailable(unavailable); - if (unavailable) setTab("featured"); - } - setListingHasMore(result.hasMore); - setStatus("idle"); - }) + ? fetchSkillListing(tab, EMPTY_CATEGORY_SLUGS, fetchLimit, controller.signal).then( + (result) => { + if (controller.signal.aborted) return; + listingCache.set(cacheKey, { + kind: "skills", + items: result.page, + hasMore: result.hasMore, + trendingState: result.trendingState, + }); + setSkills(result.page); + setTrendingState(result.trendingState); + if (tab === "trending") { + const unavailable = result.trendingState === "unavailable"; + setCanonicalTrendingUnavailable(unavailable); + if (unavailable) setTab("featured"); + } + setListingHasMore(result.hasMore); + setStatus("idle"); + }, + ) : fetchPluginListing( tab === "trending" ? "new" : tab, - categorySlugs, + EMPTY_CATEGORY_SLUGS, fetchLimit, controller.signal, ).then((result) => { @@ -658,152 +409,29 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection }); return () => controller.abort(); - }, [categorySlugs, fetchLimit, isSearchMode, kind, listingCache, tab]); - - useEffect(() => { - if (!isSearchMode) { - setSearchSkills([]); - setSearchPlugins([]); - setSearchStatus("idle"); - return undefined; - } - - searchRequestRef.current += 1; - const requestId = searchRequestRef.current; - const controller = new AbortController(); - const isLoadMore = fetchLimit > LISTING_PAGE_SIZE; - if (isLoadMore) { - setLoadingMore(true); - } else { - setSearchStatus("loading"); - setListingHasMore(false); - } - - const handle = window.setTimeout(() => { - const load = - kind === "skills" - ? Promise.all( - (categorySlugs.length > 0 ? categorySlugs : [null]).map((categorySlug) => - convexHttp.action(api.search.searchNativeSkills, { - query: trimmedSearch, - limit: fetchLimit, - highlightedOnly: tab === "featured" ? true : undefined, - ...(categorySlug ? { categorySlug } : {}), - }), - ), - ).then((results) => { - if (controller.signal.aborted || requestId !== searchRequestRef.current) return; - const rows = uniqueSkillEntries( - results.flatMap((hits) => - (hits as SkillSearchHit[]) - .map((hit) => ({ - skill: hit.skill, - ownerHandle: hit.ownerHandle, - owner: hit.owner, - })) - .filter((entry) => skillMatchesAnyCategory(entry.skill, categorySlugs)), - ), - ); - const items = rows.slice(0, fetchLimit); - const hasMore = - rows.length > fetchLimit || - results.some((hits) => (hits as SkillSearchHit[]).length >= fetchLimit); - setSearchSkills(items); - setListingHasMore(hasMore); - setSearchStatus("idle"); - }) - : Promise.all( - (categorySlugs.length > 0 ? categorySlugs : [null]).map((categorySlug) => - fetchPluginCatalog({ - q: trimmedSearch, - category: categorySlug ?? undefined, - featured: tab === "featured" ? true : undefined, - sort: "updated", - isOfficial: tab === "official" ? true : undefined, - limit: fetchLimit, - signal: controller.signal, - }), - ), - ).then((results) => { - if (controller.signal.aborted || requestId !== searchRequestRef.current) return; - const items = uniquePlugins( - results.flatMap((result) => - result.items.filter((item) => itemMatchesAnyCategory(item, categorySlugs)), - ), - ); - const hasMore = results.some( - (result) => result.nextCursor != null || result.items.length >= fetchLimit, - ); - setSearchPlugins(items); - setListingHasMore(hasMore); - setSearchStatus("idle"); - }); - - load - .catch(() => { - if (controller.signal.aborted || requestId !== searchRequestRef.current) return; - if (isLoadMore) return; - if (kind === "skills") setSearchSkills([]); - else setSearchPlugins([]); - setSearchStatus("error"); - }) - .finally(() => { - if (controller.signal.aborted || requestId !== searchRequestRef.current) return; - setLoadingMore(false); - }); - }, LISTING_SEARCH_DEBOUNCE_MS); - - return () => { - controller.abort(); - window.clearTimeout(handle); - }; - }, [categorySlugs, fetchLimit, isSearchMode, kind, tab, trimmedSearch]); - - useEffect(() => { - if (categorySlugs.length === 0) return; - const isValid = kind === "skills" ? isSkillCategorySlug : isPluginCategorySlug; - const validCategorySlugs = categorySlugs.filter((slug) => isValid(slug)); - if (validCategorySlugs.length !== categorySlugs.length) { - setCategorySlugs(validCategorySlugs); - } - }, [categorySlugs, kind]); + }, [fetchLimit, kind, listingCache, tab]); useEffect(() => { setVisibleCount(LISTING_PAGE_SIZE); setFetchLimit(LISTING_PAGE_SIZE); - }, [categorySlugs, isSearchMode, kind, tab, trimmedSearch, view]); + }, [kind, tab]); - const visibleSkills = (isSearchMode ? searchSkills : skills).slice(0, visibleCount); - const visiblePlugins = (isSearchMode ? searchPlugins : plugins).slice(0, visibleCount); + const visibleSkills = skills.slice(0, visibleCount); + const visiblePlugins = plugins.slice(0, visibleCount); const handleSeeMore = () => { setVisibleCount((count) => count + LISTING_PAGE_SIZE); setFetchLimit((limit) => limit + LISTING_PAGE_SIZE); }; - const closeListingSearch = () => { - setSearchOpen(false); - setSearchQuery(""); - searchInputRef.current?.blur(); - }; - - const handleListingSearchSubmit = (event: FormEvent) => { - event.preventDefault(); - }; - const handleKindChange = (nextKind: ListingKind) => { if (nextKind === kind) return; setKind(nextKind); - setCategorySlugs([]); setTab( nextKind === "skills" ? (canonicalTrendingUnavailable ? "featured" : "trending") : "new", ); }; - const removeCategory = (slug: string) => { - setCategorySlugs((current) => current.filter((categorySlug) => categorySlug !== slug)); - }; - return (
    +
    +
    + {visibleTabs.map((item) => ( + + ))} +
    +
    +
    - -
    - - - - {selectedCategories.length > 0 ? ( -
    - {selectedCategories.length <= 3 ? ( - selectedCategories.map((category) => ( - - )) - ) : ( - <> - - {selectedCategories.length} categories - - - - )} -
    - ) : null}
    - {activeStatus === "idle" && view === "list" && activeItems.length > 0 ? ( + {activeStatus === "idle" && activeItems.length > 0 ? (