diff --git a/specs/plugin-search-intelligence-web.md b/specs/plugin-search-intelligence-web.md new file mode 100644 index 000000000..69729d372 --- /dev/null +++ b/specs/plugin-search-intelligence-web.md @@ -0,0 +1,60 @@ +# Manual plugin-search attribution + +ClawHub web demand is measured from deliberately entered, settled plugin searches, +not page views. The web UI only adds `searchSource=clawhub-web` to the combined +`GET /api/v1/plugins/search` request. It does not compute or submit counts, +official provenance, or user/device/session metadata. + +## Input boundaries + +- Plugins browse: the existing 250 ms debounce or explicit submit creates a + one-navigation intent. The loader consumes it before dispatch. Initial URL + loads, reloads, preloads, retries, filter changes, and pagination are unmarked. +- Homepage plugin listing: only plugin search-control edits create an intent; + the existing debounce consumes it. Skills searches and filter/page refreshes + are unmarked. +- Header (desktop and mobile): manual input is consumed after the existing + 180 ms debounce. A submit before settlement transfers the same intent into + full search. A submit after settlement cannot count that intent twice. +- Full search: submitting the control in All or Plugins creates an intent. + Merely loading a search URL or fetching plugins as a supporting family for + Skills/Creators does not. Tabs and Load more do not create new intent. + +Navigation intent exists only in memory; never put it in a query string, browser +history, local/session storage, or a network request identifier. Consume it +before dispatch so failed requests are not automatically retried as new demand. +An explicit repeat submit of an unchanged settled query also stays unmarked. + +## Visible response contract + +Marked requests ask for exactly the number of plugin rows shown. Global search +historically fetched `limit + 1` and hid the extra row; that hidden row must not +affect recorded official gaps. When a full-page marked response fills the visible +page, a separate **unmarked** request may determine whether Load more is needed. +Its rows never replace the original marked response. Header typeahead does not +need the extra pagination request. + +Official counts remain backend-owned and deterministic from the exact response's +authoritative `isOfficial === true` metadata. Failed plugin searches show an error, +not an empty-result claim. A supporting pagination-probe failure does not discard +the successful visible search. + +## Cancellation boundary + +Cleared/unmounted input before debounce does not dispatch. Aborts propagate to the +HTTP request. The backend must exclude requests already aborted before its +observation commit. This is completed-search-boundary attribution, **not** a +browser-consumption receipt: a client cancellation after the server completes +cannot retract an observation. No receipt/ack protocol or identity is introduced. + +## Validation + +`src/__tests__/plugin-search-attribution.test.tsx` drives real TanStack routes, +rendered controls, and the real package API adapter. Only external Convex calls +and HTTP responses are substituted. It covers manual input/submit, URL load and +reload, retries, empty/canceled input, all four controls, the header handoff, +hidden-result probing, pagination, filters, and visible failures. + +Release proof additionally needs a real browser against the integrated ClawHub +backend plus its matching raw observation, with source and authoritative result +counts checked together. Synthetic UI screenshots are not proof. diff --git a/src/__tests__/packages-route.test.tsx b/src/__tests__/packages-route.test.tsx index 48da1bd11..c384fb16e 100644 --- a/src/__tests__/packages-route.test.tsx +++ b/src/__tests__/packages-route.test.tsx @@ -106,6 +106,7 @@ describe("plugins route", () => { resetConvexReactMocks(); setupDefaultConvexReactMocks(); navigateMock.mockReset(); + navigateMock.mockResolvedValue(undefined); redirectMock.mockClear(); searchMock = {}; loaderDataMock = undefined; diff --git a/src/__tests__/plugin-search-attribution.test.tsx b/src/__tests__/plugin-search-attribution.test.tsx new file mode 100644 index 000000000..c2fe1b2e3 --- /dev/null +++ b/src/__tests__/plugin-search-attribution.test.tsx @@ -0,0 +1,421 @@ +/* @vitest-environment jsdom */ + +import { + createMemoryHistory, + createRootRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { ConvexHttpClient } from "convex/browser"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import Header from "../components/Header"; +import { HomeListingSection } from "../components/HomeListingSection"; +import { Route as pluginsRoute } from "../routes/plugins/index"; +import { Route as searchRoute } from "../routes/search"; + +const { searchSkills } = vi.hoisted(() => ({ searchSkills: vi.fn(async () => []) })); +vi.mock("@convex-dev/auth/react", () => ({ + useAuthActions: () => ({ signIn: vi.fn(), signOut: vi.fn() }), +})); + +vi.mock("convex/react", async (importOriginal) => ({ + ...(await importOriginal()), + useQuery: () => undefined, + useAction: () => searchSkills, + useConvexAuth: () => ({ isAuthenticated: false, isLoading: false }), +})); + +const requests: URL[] = []; +let pluginResults: Array<{ + score: number; + package: { + name: string; + displayName: string; + family: "code-plugin"; + channel: "community"; + isOfficial: boolean; + createdAt: number; + updatedAt: number; + }; +}> = []; + +function makePluginResults(count: number): typeof pluginResults { + return Array.from({ length: count }, (_, index) => ({ + score: count - index, + package: { + name: `plugin-${index + 1}`, + displayName: `Plugin ${index + 1}`, + family: "code-plugin", + channel: "community", + isOfficial: index === 25, + createdAt: 1, + updatedAt: 1, + }, + })); +} + +async function openPlugins(url: string) { + const root = createRootRoute(); + const route = pluginsRoute.update({ + id: "/plugins", + path: "/plugins", + getParentRoute: () => root, + } as never); + const router = createRouter({ + routeTree: root.addChildren([route]), + history: createMemoryHistory({ initialEntries: [url] }), + defaultPendingMinMs: 0, + }); + await router.load(); + render(); + await screen.findByRole("heading", { name: "Plugins" }); + return router; +} + +async function openGlobalSearch(url = "/search") { + const root = createRootRoute({ + component: () => ( + <> +
+ + + ), + }); + const route = searchRoute.update({ + id: "/search", + path: "/search", + getParentRoute: () => root, + } as never); + const router = createRouter({ + routeTree: root.addChildren([route]), + history: createMemoryHistory({ initialEntries: [url] }), + }); + await router.load(); + render(); + return router; +} + +describe("manual plugin search attribution", () => { + beforeEach(() => { + requests.length = 0; + pluginResults = []; + vi.stubGlobal("scrollTo", vi.fn()); + vi.spyOn(ConvexHttpClient.prototype, "action").mockResolvedValue([]); + vi.spyOn(ConvexHttpClient.prototype, "query").mockResolvedValue({ page: [], isDone: true }); + vi.stubGlobal( + "fetch", + vi.fn(async (input: string) => { + const url = new URL(input); + requests.push(url); + return new Response( + JSON.stringify({ + results: pluginResults.slice(0, Number(url.searchParams.get("limit") ?? 100)), + items: [], + nextCursor: null, + }), + ); + }), + ); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("marks only the settled manual search and keeps query URL loads unmarked", async () => { + const router = await openPlugins("/plugins?q=shared&category=security&topic=audit"); + expect(requests).toHaveLength(1); + expect(requests[0].searchParams.has("searchSource")).toBe(false); + + const input = screen.getByPlaceholderText("Search plugins..."); + fireEvent.change(input, { target: { value: "n" } }); + fireEvent.change(input, { target: { value: "notion" } }); + expect(requests).toHaveLength(1); + + await waitFor(() => expect(requests).toHaveLength(2)); + expect(requests[1].pathname).toBe("/api/v1/plugins/search"); + expect(Object.fromEntries(requests[1].searchParams)).toEqual({ + q: "notion", + limit: "25", + category: "security", + topic: "audit", + searchSource: "clawhub-web", + }); + expect(router.state.location.searchStr).not.toContain("searchSource"); + }); + + it("submits a URL-loaded query once, without marking repeat submit, retry, or reload", async () => { + const router = await openPlugins("/plugins?q=notion"); + const input = screen.getByPlaceholderText("Search plugins..."); + fireEvent.submit(input.closest("form")!); + await waitFor(() => expect(requests).toHaveLength(2)); + expect(requests[1].searchParams.get("searchSource")).toBe("clawhub-web"); + await waitFor(() => expect(router.state.isLoading).toBe(false)); + + fireEvent.submit(input.closest("form")!); + await act(async () => { + await router.invalidate(); + }); + expect(requests.filter((url) => url.searchParams.has("searchSource"))).toHaveLength(1); + cleanup(); + await openPlugins("/plugins?q=notion"); + expect(requests.at(-1)?.searchParams.has("searchSource")).toBe(false); + }); + + it("marks a settled homepage plugin search but not a filter refresh", async () => { + const root = createRootRoute({ + component: () => ( + + ), + }); + const router = createRouter({ + routeTree: root, + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + await router.load(); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Search catalog" })); + const input = await screen.findByRole("searchbox", { name: "Search plugins" }); + fireEvent.change(input, { target: { value: "n" } }); + fireEvent.change(input, { target: { value: "notion" } }); + await waitFor(() => + expect(requests.filter((url) => url.pathname.endsWith("/plugins/search"))).toHaveLength(1), + ); + const search = requests.find((url) => url.pathname.endsWith("/plugins/search"))!; + expect(search.searchParams.get("searchSource")).toBe("clawhub-web"); + expect(search.searchParams.get("limit")).toBe("20"); + fireEvent.change(input, { target: { value: "notion " } }); + fireEvent.click(screen.getByRole("tab", { name: "Official" })); + await waitFor(() => + expect(requests.filter((url) => url.pathname.endsWith("/plugins/search"))).toHaveLength(2), + ); + expect(requests.filter((url) => url.searchParams.has("searchSource"))).toHaveLength(1); + }); + + it("starts a new manual intent after clearing and retyping the same plugin query", async () => { + await openPlugins("/plugins?q=notion"); + const input = screen.getByPlaceholderText("Search plugins..."); + fireEvent.submit(input.closest("form")!); + await waitFor(() => + expect(requests.filter((url) => url.searchParams.has("searchSource"))).toHaveLength(1), + ); + fireEvent.click(screen.getByRole("button", { name: "Close search" })); + await waitFor(() => expect((input as HTMLInputElement).value).toBe("")); + fireEvent.click(screen.getByRole("button", { name: "Search plugins" })); + fireEvent.change(screen.getByPlaceholderText("Search plugins..."), { + target: { value: "notion" }, + }); + await waitFor(() => + expect(requests.filter((url) => url.searchParams.has("searchSource"))).toHaveLength(2), + ); + }); + + it("marks only visible header plugin results after manual input settles", async () => { + await openGlobalSearch(); + const input = await screen.findByRole("combobox"); + fireEvent.change(input, { target: { value: "n" } }); + fireEvent.change(input, { target: { value: "notion" } }); + await waitFor(() => + expect(requests.filter((url) => url.pathname.endsWith("/plugins/search"))).toHaveLength(1), + ); + const request = requests.find((url) => url.pathname.endsWith("/plugins/search"))!; + expect(request.searchParams.get("searchSource")).toBe("clawhub-web"); + expect(request.searchParams.get("limit")).toBe("4"); + }); + + it.each([false, true])( + "does not recount whitespace-only header edits (mobile: %s)", + async (mobile) => { + await openGlobalSearch(); + if (mobile) fireEvent.click(screen.getByRole("button", { name: /^Search$/ })); + const input = screen.getAllByRole("combobox").at(-1)!; + fireEvent.change(input, { target: { value: "notion" } }); + await waitFor(() => expect(requests).toHaveLength(1)); + + vi.useFakeTimers(); + fireEvent.change(input, { target: { value: "notion " } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(requests.filter((url) => url.searchParams.has("searchSource"))).toHaveLength(1); + }, + ); + + it("carries an immediate header submit into full results as one manual search", async () => { + const router = await openGlobalSearch(); + const input = await screen.findByRole("combobox"); + fireEvent.change(input, { target: { value: "notion" } }); + fireEvent.submit(input.closest("form")!); + await waitFor(() => + expect(requests.filter((url) => url.pathname.endsWith("/plugins/search"))).toHaveLength(1), + ); + const request = requests.find((url) => url.pathname.endsWith("/plugins/search"))!; + expect(request.searchParams.get("searchSource")).toBe("clawhub-web"); + expect(request.searchParams.get("limit")).toBe("25"); + expect(router.state.location.searchStr).not.toContain("searchSource"); + }); + + it("marks a full-page submit but not its initial URL or type changes", async () => { + await openGlobalSearch("/search?q=shared&type=plugins"); + await waitFor(() => expect(requests).toHaveLength(1)); + expect(requests[0].searchParams.has("searchSource")).toBe(false); + const input = screen.getByPlaceholderText("Search skills, plugins, and creators..."); + fireEvent.change(input, { target: { value: "notion" } }); + expect(requests).toHaveLength(1); + fireEvent.submit(input.closest("form")!); + await waitFor(() => expect(requests).toHaveLength(2)); + expect(requests[1].searchParams.get("searchSource")).toBe("clawhub-web"); + expect(requests[1].searchParams.get("limit")).toBe("25"); + fireEvent.click(screen.getByRole("button", { name: /^Skills$/ })); + await waitFor(() => expect(requests).toHaveLength(3)); + expect(requests[2].searchParams.has("searchSource")).toBe(false); + }); + + it("keeps a header handoff consumed when the mounted page resubmits the same query", async () => { + const router = await openGlobalSearch(); + const headerInput = await screen.findByRole("combobox"); + fireEvent.change(headerInput, { target: { value: "notion" } }); + fireEvent.submit(headerInput.closest("form")!); + await waitFor(() => expect(requests).toHaveLength(1)); + await waitFor(() => expect(router.state.isLoading).toBe(false)); + + const pageInput = screen.getByPlaceholderText("Search skills, plugins, and creators..."); + fireEvent.submit(pageInput.closest("form")!); + await waitFor(() => expect(requests).toHaveLength(2)); + expect(requests.filter((url) => url.searchParams.has("searchSource"))).toHaveLength(1); + }); + + it("excludes the hidden pagination probe and later pages from manual demand", async () => { + pluginResults = makePluginResults(26); + await openGlobalSearch(); + const input = screen.getByPlaceholderText("Search skills, plugins, and creators..."); + fireEvent.change(input, { target: { value: "notion" } }); + fireEvent.submit(input.closest("form")!); + await screen.findByRole("button", { name: "Load more" }); + expect(requests).toHaveLength(2); + expect( + requests.map((url) => [url.searchParams.get("limit"), url.searchParams.get("searchSource")]), + ).toEqual([ + ["25", "clawhub-web"], + ["26", null], + ]); + expect(document.querySelectorAll(".skill-list-item")).toHaveLength(25); + expect(screen.queryByText("Plugin 26")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Load more" })); + await screen.findByText("Plugin 26"); + expect(requests.at(-1)?.searchParams.has("searchSource")).toBe(false); + expect(requests.filter((url) => url.searchParams.has("searchSource"))).toHaveLength(1); + }); + + it.each(["fulfilled", "rejected", "stale"])( + "renders completed results before the pagination probe settles (%s)", + async (outcome) => { + pluginResults = makePluginResults(26); + const responseFor = vi.mocked(fetch).getMockImplementation()!; + let resolveProbe!: (response: Response) => void; + let rejectProbe!: (error: Error) => void; + const probe = new Promise((resolve, reject) => { + resolveProbe = resolve; + rejectProbe = reject; + }); + vi.mocked(fetch).mockImplementation((input, init) => { + const url = new URL(input instanceof Request ? input.url : input); + if (url.searchParams.get("limit") !== "26") return responseFor(input, init); + requests.push(url); + return probe; + }); + await openGlobalSearch(); + const input = screen.getByPlaceholderText("Search skills, plugins, and creators..."); + fireEvent.change(input, { target: { value: "notion" } }); + fireEvent.submit(input.closest("form")!); + + try { + await screen.findByText("Plugin 1"); + expect(document.querySelectorAll(".skill-list-item")).toHaveLength(25); + expect(screen.queryByRole("button", { name: "Load more" })).toBeNull(); + if (outcome === "stale") { + pluginResults = makePluginResults(1); + fireEvent.change(input, { target: { value: "calendar" } }); + fireEvent.submit(input.closest("form")!); + await waitFor(() => + expect(document.querySelectorAll(".skill-list-item")).toHaveLength(1), + ); + } + } finally { + await act(async () => { + if (outcome === "rejected") rejectProbe(new Error("Pagination unavailable")); + else resolveProbe(new Response(JSON.stringify({ results: makePluginResults(26) }))); + }); + } + if (outcome === "fulfilled") await screen.findByRole("button", { name: "Load more" }); + else { + expect(screen.queryByRole("button", { name: "Load more" })).toBeNull(); + expect(screen.getByText("Plugin 1")).toBeTruthy(); + } + expect(requests.filter((url) => url.searchParams.has("searchSource"))).toHaveLength( + outcome === "stale" ? 2 : 1, + ); + }, + ); + + it("does not dispatch emptied or canceled input before the debounce completes", async () => { + await openPlugins("/plugins"); + requests.length = 0; + vi.useFakeTimers(); + const input = screen.getByPlaceholderText("Search plugins..."); + fireEvent.change(input, { target: { value: "notion" } }); + fireEvent.change(input, { target: { value: "" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(requests.some((url) => url.pathname.endsWith("/search"))).toBe(false); + fireEvent.change(input, { target: { value: "calendar" } }); + cleanup(); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(requests.some((url) => url.searchParams.has("searchSource"))).toBe(false); + }); + + it("does not count a settled typeahead again when opening full search results", async () => { + await openGlobalSearch(); + const input = await screen.findByRole("combobox"); + fireEvent.change(input, { target: { value: "notion" } }); + await waitFor(() => expect(requests).toHaveLength(1)); + fireEvent.submit(input.closest("form")!); + await waitFor(() => expect(requests).toHaveLength(2)); + expect(requests.filter((url) => url.searchParams.has("searchSource"))).toHaveLength(1); + expect(requests[1].searchParams.has("searchSource")).toBe(false); + }); + + it("shows failed plugin searches instead of presenting an official gap as an empty result", async () => { + await openGlobalSearch(); + vi.mocked(fetch).mockResolvedValue(new Response("Search unavailable", { status: 503 })); + const input = screen.getByPlaceholderText("Search skills, plugins, and creators..."); + fireEvent.change(input, { target: { value: "notion" } }); + fireEvent.submit(input.closest("form")!); + expect((await screen.findByRole("alert")).textContent).toContain("Unable to search plugins"); + }); + + it("shows a failed typeahead plugin search without claiming there were no matches", async () => { + await openGlobalSearch(); + vi.mocked(fetch).mockResolvedValue(new Response("Search unavailable", { status: 503 })); + fireEvent.change(await screen.findByRole("combobox"), { target: { value: "notion" } }); + expect((await screen.findByRole("alert")).textContent).toContain("Unable to search plugins"); + expect(screen.queryByText(/No skills, plugins, or creators found/)).toBeNull(); + }); +}); diff --git a/src/__tests__/search-route.test.tsx b/src/__tests__/search-route.test.tsx index e644d475e..09d5601d4 100644 --- a/src/__tests__/search-route.test.tsx +++ b/src/__tests__/search-route.test.tsx @@ -17,6 +17,7 @@ vi.mock("@tanstack/react-router", () => ({ __config: config, useLoaderData: () => loaderDataMock, useSearch: () => searchMock, + useRouteContext: () => ({ manualPluginSearch: null }), }), useNavigate: () => navigateMock, })); diff --git a/src/components/Header.tsx b/src/components/Header.tsx index d89589706..b08c95b7b 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -28,6 +28,7 @@ import { routeToBannedAccountPage, } from "../lib/authErrorMessage"; import { gravatarUrl } from "../lib/gravatar"; +import { navigateWithManualPluginSearch } from "../lib/manualPluginSearch"; import { PRIMARY_NAV_ITEMS, SECONDARY_NAV_ITEMS } from "../lib/nav-items"; import { buildPublisherProfileHref, buildSkillDetailHref } from "../lib/ownerRoute"; import { buildPluginDetailHref, displayPluginPackageName } from "../lib/pluginRoutes"; @@ -42,6 +43,7 @@ import { type UnifiedCreatorResult, type UnifiedPluginResult, type UnifiedSkillResult, + type ManualPluginSearch, } from "../lib/useUnifiedSearch"; import { MarketplaceIcon } from "./MarketplaceIcon"; import { OfficialBadge } from "./OfficialBadge"; @@ -141,6 +143,7 @@ export default function Header() { isAuthenticated && me ? {} : "skip", ); const [navSearchQuery, setNavSearchQuery] = useState(""); + const manualPluginSearchRef = useRef(null); const [typeaheadOpen, setTypeaheadOpen] = useState(false); const [typeaheadActiveIndex, setTypeaheadActiveIndex] = useState(0); const [mobileSearchOpen, setMobileSearchOpen] = useState(false); @@ -160,7 +163,10 @@ export default function Header() { pluginResults, creatorResults, isSearching: typeaheadSearching, + pluginSearchError, } = useUnifiedSearch(navSearchQuery, "all", { + manualPluginSearch: manualPluginSearchRef.current, + detectPluginHasMore: false, debounceMs: 180, enabled: typeaheadOpen && hasNavSearchQuery, limits: { skills: 4, plugins: 4, creators: 4 }, @@ -280,14 +286,25 @@ export default function Header() { setMode(next); }; + const handleNavSearchChange = (value: string) => { + const query = value.trim(); + if (query !== trimmedNavSearchQuery) { + manualPluginSearchRef.current = { query, consumed: false }; + } + setNavSearchQuery(value); + setTypeaheadOpen(true); + }; + const handleNavSearch = (e: React.FormEvent) => { e.preventDefault(); const q = navSearchQuery.trim(); if (!q) return; - void navigate({ - to: "/search", - search: { q, type: undefined }, - }); + void navigateWithManualPluginSearch(manualPluginSearchRef.current, () => + navigate({ + to: "/search", + search: { q, type: undefined }, + }), + ); setNavSearchQuery(""); setTypeaheadOpen(false); setMobileSearchOpen(false); @@ -541,10 +558,7 @@ export default function Header() { role="combobox" placeholder="Search skills, plugins, and creators" value={navSearchQuery} - onChange={(e) => { - setNavSearchQuery(e.target.value); - setTypeaheadOpen(true); - }} + onChange={(e) => handleNavSearchChange(e.target.value)} onFocus={() => setTypeaheadOpen(true)} onKeyDown={handleSearchKeyDown} aria-label="Search" @@ -560,6 +574,7 @@ export default function Header() { { - setNavSearchQuery(e.target.value); - setTypeaheadOpen(true); - }} + onChange={(e) => handleNavSearchChange(e.target.value)} onFocus={() => setTypeaheadOpen(true)} onKeyDown={handleSearchKeyDown} aria-label="Search" @@ -773,6 +785,7 @@ export default function Header() { void; onSelectItem: (item: TypeaheadItem) => void; pluginItems: TypeaheadItem[]; @@ -871,7 +886,12 @@ function SearchTypeahead({ Searching… ) : null} - {hasQuery && !loading && !hasMatches ? ( + {hasQuery && !loading && pluginSearchError ? ( +
+ Unable to search plugins. Please try again later. +
+ ) : null} + {hasQuery && !loading && !hasMatches && !pluginSearchError ? (
No skills, plugins, or creators found for "{query}"
diff --git a/src/components/HomeListingSection.tsx b/src/components/HomeListingSection.tsx index 52f1dc4f0..1cfa59a70 100644 --- a/src/components/HomeListingSection.tsx +++ b/src/components/HomeListingSection.tsx @@ -293,6 +293,7 @@ function createInitialListingCache(initialListing: HomeListingInitialData | null export function HomeListingSection({ initialListing = null }: HomeListingSectionProps = {}) { const searchInputRef = useRef(null); const searchRequestRef = useRef(0); + const manualPluginQueryRef = useRef(null); const listingCacheRef = useRef | null>(null); listingCacheRef.current ??= createInitialListingCache(initialListing); const listingCache = listingCacheRef.current; @@ -481,6 +482,11 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection } const handle = window.setTimeout(() => { + const searchSource = + kind === "plugins" && manualPluginQueryRef.current === trimmedSearch + ? ("clawhub-web" as const) + : undefined; + manualPluginQueryRef.current = null; const load = kind === "skills" && tab === "trending" ? searchHomeTrendingSkillListing(trimmedSearch, fetchLimit, controller.signal).then( @@ -517,6 +523,7 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection }) : fetchPluginCatalog({ q: trimmedSearch, + ...(searchSource ? { searchSource } : {}), category: categorySlug, featured: tab === "featured" ? true : undefined, isOfficial: tab === "official" ? true : undefined, @@ -660,7 +667,12 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection label={kind === "skills" ? "Search skills" : "Search plugins"} placeholder={kind === "skills" ? "Search skills..." : "Search plugins..."} value={searchQuery} - onChange={setSearchQuery} + onChange={(next) => { + if (next.trim() !== trimmedSearch) { + manualPluginQueryRef.current = kind === "plugins" ? next.trim() || null : null; + } + setSearchQuery(next); + }} onClear={searchDisclosure.closeSearch} closeLabel="Close search" /> diff --git a/src/lib/manualPluginSearch.ts b/src/lib/manualPluginSearch.ts new file mode 100644 index 000000000..9f1da818b --- /dev/null +++ b/src/lib/manualPluginSearch.ts @@ -0,0 +1,21 @@ +import type { ManualPluginSearch } from "./useUnifiedSearch"; + +let pendingNavigation: ManualPluginSearch | null = null; + +// Carry a control's one-shot intent across client navigation, never in a URL, +// browser history, storage, or a request identifier. A reload starts empty. +export function navigateWithManualPluginSearch( + intent: ManualPluginSearch | null, + navigate: () => Promise | void, +) { + pendingNavigation = intent; + return Promise.resolve(navigate()).finally(() => { + if (pendingNavigation === intent) pendingNavigation = null; + }); +} + +export function takeManualPluginSearch(query: string | undefined) { + const intent = pendingNavigation; + pendingNavigation = null; + return intent?.query === query?.trim() ? intent : null; +} diff --git a/src/lib/packageApi.ts b/src/lib/packageApi.ts index 9abcdfee6..525513837 100644 --- a/src/lib/packageApi.ts +++ b/src/lib/packageApi.ts @@ -425,6 +425,7 @@ export async function fetchPackages(params: { export async function fetchPluginCatalog(params: { q?: string; + searchSource?: "clawhub-web"; cursor?: string; family?: PluginFamily; isOfficial?: boolean; @@ -474,6 +475,7 @@ export async function fetchPluginCatalog(params: { if (params.q?.trim()) { const url = await packageApiUrl(`${ApiRoutes.plugins}/search`); url.searchParams.set("q", params.q.trim()); + if (params.searchSource) url.searchParams.set("searchSource", params.searchSource); if (typeof params.limit === "number") url.searchParams.set("limit", String(params.limit)); if (typeof params.isOfficial === "boolean") { url.searchParams.set("isOfficial", String(params.isOfficial)); diff --git a/src/lib/useUnifiedSearch.ts b/src/lib/useUnifiedSearch.ts index 0db5d56ce..11402678a 100644 --- a/src/lib/useUnifiedSearch.ts +++ b/src/lib/useUnifiedSearch.ts @@ -11,6 +11,7 @@ import { } from "./skillsShCatalog"; export type UnifiedSearchType = "all" | "skills" | "plugins" | "creators"; +export type ManualPluginSearch = { query: string; consumed: boolean }; const MAX_UNIFIED_SEARCH_LIMIT = 100; const MAX_CREATOR_SEARCH_LIMIT = 50; @@ -80,6 +81,8 @@ export type UnifiedSearchInitialData = { }; type UnifiedSearchOptions = { + manualPluginSearch?: ManualPluginSearch | null; + detectPluginHasMore?: boolean; debounceMs?: number; enabled?: boolean; initialData?: UnifiedSearchInitialData | null; @@ -141,6 +144,8 @@ export function useUnifiedSearch( const requestRef = useRef(0); const debounceMs = options.debounceMs ?? 300; const enabled = options.enabled ?? true; + const manualPluginSearch = options.manualPluginSearch; + const detectPluginHasMore = options.detectPluginHasMore ?? true; const initialData = options.initialData ?? null; const skillLimit = Math.max(0, Math.min(options.limits?.skills ?? 25, MAX_UNIFIED_SEARCH_LIMIT)); const pluginLimit = Math.max( @@ -198,6 +203,7 @@ export function useUnifiedSearch( const [isSearching, setIsSearching] = useState( () => enabled && trimmedQuery.length > 0 && !matchedInitialData, ); + const [pluginSearchError, setPluginSearchError] = useState(false); useEffect(() => { if (!matchedInitialData) return; @@ -235,6 +241,7 @@ export function useUnifiedSearch( setPluginHasMore(false); setCreatorHasMore(false); setIsSearching(false); + setPluginSearchError(false); return () => {}; } @@ -253,6 +260,7 @@ export function useUnifiedSearch( const requestId = requestRef.current; const controller = new AbortController(); setIsSearching(true); + setPluginSearchError(false); const handle = window.setTimeout(() => { void (async () => { @@ -262,6 +270,7 @@ export function useUnifiedSearch( Promise<{ items: PackageListItem[] }> | null, Promise<{ page: PublicPublisherListItem[]; isDone?: boolean }> | null, ] = [null, null, null]; + let isManualPluginSearch = false; if (shouldFetchSkills) { promises[0] = searchSkills({ @@ -271,9 +280,16 @@ export function useUnifiedSearch( } if (shouldFetchPlugins) { + isManualPluginSearch = Boolean( + manualPluginSearch && + !manualPluginSearch.consumed && + manualPluginSearch.query === trimmedQuery, + ); + if (isManualPluginSearch && manualPluginSearch) manualPluginSearch.consumed = true; promises[1] = fetchPluginCatalog({ q: trimmedQuery, - limit: pluginLimit + 1, + limit: isManualPluginSearch ? pluginLimit : pluginLimit + 1, + ...(isManualPluginSearch ? { searchSource: "clawhub-web" as const } : {}), signal: controller.signal, }); } @@ -288,6 +304,7 @@ export function useUnifiedSearch( const settled = await Promise.allSettled(promises.map((p) => p ?? Promise.resolve(null))); if (requestId !== requestRef.current) return; + setPluginSearchError(shouldFetchPlugins && settled[1].status === "rejected"); const skillsRaw = settled[0].status === "fulfilled" ? settled[0].value : null; const pluginsRaw = settled[1].status === "fulfilled" ? settled[1].value : null; @@ -340,6 +357,22 @@ export function useUnifiedSearch( nextCreatorResults, ), ); + if (isManualPluginSearch && detectPluginHasMore && pluginMatches.length === pluginLimit) { + // The unmarked probe only updates pagination after visible rows are ready. + // A slow, failed, or stale probe must not hold or replace those rows. + void fetchPluginCatalog({ + q: trimmedQuery, + limit: pluginLimit + 1, + signal: controller.signal, + }).then( + (probe) => { + if (requestId === requestRef.current) { + setPluginHasMore(probe.items.length > pluginLimit); + } + }, + () => {}, + ); + } } catch (error) { console.error("Unified search failed:", error); if (requestId === requestRef.current) { @@ -378,6 +411,8 @@ export function useUnifiedSearch( creatorLimit, creatorRequestLimit, matchedInitialData, + manualPluginSearch, + detectPluginHasMore, ]); return { @@ -392,5 +427,6 @@ export function useUnifiedSearch( pluginHasMore, creatorHasMore, isSearching, + pluginSearchError, }; } diff --git a/src/routes/plugins/index.tsx b/src/routes/plugins/index.tsx index 5e62a9bff..eff226ec9 100644 --- a/src/routes/plugins/index.tsx +++ b/src/routes/plugins/index.tsx @@ -43,6 +43,10 @@ type PluginBrowseTab = VisiblePluginSort | "official"; const PLUGINS_PAGE_SIZE = 25; const PLUGIN_CATALOG_REQUEST_TIMEOUT_MS = 5_000; +// One navigation's input intent, never URL/history state. Reloads and preloads +// cannot recreate it; the loader consumes it before dispatch so retries are unmarked. +const manualSearchNavigation: { pending: { query: string } | null } = { pending: null }; + type PluginSearchState = { q?: string; category?: string; @@ -87,6 +91,7 @@ type PluginsLoaderData = { type PluginsPageDataRequest = { q?: string; + searchSource?: "clawhub-web"; category?: string; topic?: string; cursor?: string; @@ -190,6 +195,7 @@ export async function loadPluginsPageData( try { const data = await fetchPluginCatalog({ q: args.q, + ...(args.searchSource ? { searchSource: args.searchSource } : {}), category: args.category, topic: args.topic, officialFirst: Boolean(args.category && !args.q), @@ -324,11 +330,24 @@ export const Route = createFileRoute("/plugins/")({ sort: hasQuery ? undefined : normalizeActivePluginSort(search.sort), }; }, - loader: async ({ deps, abortController }): Promise => - await loadPluginsPageData({ + shouldReload: ({ deps, preload }) => + !preload && manualSearchNavigation.pending && manualSearchNavigation.pending.query === deps.q + ? true + : undefined, + loader: async ({ deps, abortController, preload }): Promise => { + const isManual = Boolean( + !preload && + !abortController.signal.aborted && + manualSearchNavigation.pending && + manualSearchNavigation.pending.query === deps.q, + ); + if (isManual) manualSearchNavigation.pending = null; + return await loadPluginsPageData({ ...deps, + ...(isManual ? { searchSource: "clawhub-web" as const } : {}), signal: abortController.signal, - }), + }); + }, component: PluginsIndex, }); @@ -406,6 +425,7 @@ function PluginsIndex() { const loadMoreInFlightRef = useRef(false); const loadMoreAbortControllerRef = useRef(null); const searchNavigateTimer = useRef(0); + const lastManualQueryRef = useRef(null); useEffect(() => { setQuery(search.q ?? ""); @@ -542,6 +562,9 @@ function PluginsIndex() { const navigateToPluginSearch = useCallback( (next: string, replace: boolean) => { const trimmed = next.trim(); + const intent = trimmed && lastManualQueryRef.current !== trimmed ? { query: trimmed } : null; + manualSearchNavigation.pending = intent; + lastManualQueryRef.current = trimmed || null; void navigate({ search: (prev: PluginSearchState) => ({ ...prev, @@ -552,6 +575,8 @@ function PluginsIndex() { sort: undefined, }), replace, + }).finally(() => { + if (manualSearchNavigation.pending === intent) manualSearchNavigation.pending = null; }); }, [navigate], @@ -575,6 +600,8 @@ function PluginsIndex() { const handleClearSearch = () => { window.clearTimeout(searchNavigateTimer.current); + lastManualQueryRef.current = null; + manualSearchNavigation.pending = null; setQuery(""); searchInputRef.current?.focus(); void navigate({ diff --git a/src/routes/search.tsx b/src/routes/search.tsx index 02fb03455..5ae53b3a4 100644 --- a/src/routes/search.tsx +++ b/src/routes/search.tsx @@ -1,6 +1,6 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { Plus, Search, X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { api } from "../../convex/_generated/api"; import { PluginListItem } from "../components/PluginListItem"; import { PublisherListItem } from "../components/PublisherListItem"; @@ -9,6 +9,7 @@ import { SkillListItem } from "../components/SkillListItem"; import { SkillsShListItem } from "../components/SkillsShListItem"; import { Card } from "../components/ui/card"; import { convexHttp } from "../convex/client"; +import { navigateWithManualPluginSearch, takeManualPluginSearch } from "../lib/manualPluginSearch"; import type { PublicSkill } from "../lib/publicUser"; import type { CanonicalSkillSearchResult } from "../lib/skillsShCatalog"; import { @@ -19,6 +20,7 @@ import { type UnifiedCreatorResult, type UnifiedPluginResult, type UnifiedSkillResult, + type ManualPluginSearch, } from "../lib/useUnifiedSearch"; const SEARCH_PAGE_SIZE = 25; @@ -39,6 +41,9 @@ export const Route = createFileRoute("/search")({ loaderDeps: ({ search }) => ({ q: search.q, }), + beforeLoad: ({ search, preload }) => ({ + manualPluginSearch: preload ? null : takeManualPluginSearch(search.q), + }), loader: async ({ deps }): Promise => await loadInitialSearchResults(deps.q), component: UnifiedSearchPage, @@ -79,12 +84,19 @@ async function loadInitialSearchResults(query: string | undefined) { function UnifiedSearchPage() { const search = Route.useSearch(); + const { manualPluginSearch } = Route.useRouteContext(); const initialSearch = Route.useLoaderData() as UnifiedSearchInitialData | null | undefined; const navigate = useNavigate(); const activeType = search.type ?? "all"; const [query, setQuery] = useState(search.q ?? ""); + const lastManualSearchRef = useRef(manualPluginSearch); const [resultLimit, setResultLimit] = useState(SEARCH_PAGE_SIZE); + useEffect(() => { + // Query navigation keeps this page mounted; retain the header's consumed intent. + if (manualPluginSearch) lastManualSearchRef.current = manualPluginSearch; + }, [manualPluginSearch]); + useEffect(() => { setQuery(search.q ?? ""); }, [search.q]); @@ -105,7 +117,9 @@ function UnifiedSearchPage() { pluginHasMore, creatorHasMore, isSearching, + pluginSearchError, } = useUnifiedSearch(search.q ?? "", "all", { + ...(manualPluginSearch ? { manualPluginSearch } : {}), ...(initialSearch ? { initialData: initialSearch } : null), limits: { skills: resultLimit, @@ -134,13 +148,23 @@ function UnifiedSearchPage() { const handleSearch = (e: React.FormEvent) => { e.preventDefault(); - void navigate({ - to: "/search", - search: { - q: query.trim() || undefined, - type: search.type, - }, - }); + const trimmed = query.trim(); + if (lastManualSearchRef.current?.query !== trimmed) { + lastManualSearchRef.current = { query: trimmed, consumed: false }; + } + const intent = + trimmed && (activeType === "all" || activeType === "plugins") + ? lastManualSearchRef.current + : null; + void navigateWithManualPluginSearch(intent, () => + navigate({ + to: "/search", + search: { + q: query.trim() || undefined, + type: search.type, + }, + }), + ); }; const setType = (type: UnifiedSearchType) => { @@ -155,6 +179,7 @@ function UnifiedSearchPage() { }; const clearSearch = () => { + lastManualSearchRef.current = null; setQuery(""); void navigate({ to: "/search", @@ -230,13 +255,23 @@ function UnifiedSearchPage() { + {!isSearching && pluginSearchError && (activeType === "all" || activeType === "plugins") ? ( +
+

Unable to search plugins

+

+ The plugin catalog is temporarily unavailable. Please try again later. +

+
+ ) : null} {isSearching ? ( ) : !search.q ? (

Enter a search term to find skills, plugins, and creators

- ) : results.length === 0 ? ( + ) : results.length === 0 && + pluginSearchError && + (activeType === "all" || activeType === "plugins") ? null : results.length === 0 ? (