From 349a6f25a06def299b8a9c0cbd92f09436261384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eder=20S=C3=A1nchez?= Date: Tue, 2 Jun 2026 13:16:45 -0600 Subject: [PATCH 1/2] fix(admin): extend server-side content search to picker and MCP The core server-side content search (?q=) landed via #1226. Extend it to two surfaces that still post-filtered in memory: - ContentPickerModal now pushes its search box to the server (search option -> ?q=), so it finds entries anywhere in a large collection instead of only the rows already loaded. Uses keepPreviousData to avoid flashing to empty between keystrokes and keeps load-more available while searching. - MCP content_list gains a q parameter, so agents search server-side rather than post-filtering a page of results. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/content-picker-mcp-search.md | 6 ++++ .../src/components/ContentPickerModal.tsx | 28 ++++++++++++------- packages/core/src/astro/types.ts | 1 + packages/core/src/mcp/server.ts | 7 +++++ .../integration/mcp/content-misc.test.ts | 19 +++++++++++++ 5 files changed, 51 insertions(+), 10 deletions(-) create mode 100644 .changeset/content-picker-mcp-search.md diff --git a/.changeset/content-picker-mcp-search.md b/.changeset/content-picker-mcp-search.md new file mode 100644 index 000000000..39f808970 --- /dev/null +++ b/.changeset/content-picker-mcp-search.md @@ -0,0 +1,6 @@ +--- +"emdash": patch +"@emdash-cms/admin": patch +--- + +Extend the new server-side content search (`?q=`) to two more surfaces. The `ContentPickerModal` (used when linking content from the editor) now pushes its search box to the server instead of filtering only the items already loaded, so it can find entries anywhere in a large collection; it uses `keepPreviousData` so the list doesn't flash to empty between keystrokes and keeps load-more available while searching. The MCP `content_list` tool also gains a `q` parameter, so agents can search a collection server-side instead of post-filtering a page of results. diff --git a/packages/admin/src/components/ContentPickerModal.tsx b/packages/admin/src/components/ContentPickerModal.tsx index 99f6a5406..f994ea69b 100644 --- a/packages/admin/src/components/ContentPickerModal.tsx +++ b/packages/admin/src/components/ContentPickerModal.tsx @@ -8,7 +8,7 @@ import { Button, Dialog, Input, Loader, Select } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { MagnifyingGlass, FolderOpen, X } from "@phosphor-icons/react"; -import { useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import * as React from "react"; import { fetchCollections, fetchContentList, getDraftStatus } from "../lib/api"; @@ -55,13 +55,21 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick } }, [collections, selectedCollection]); + // Push search to the server so the picker can find items across the + // entire collection, not just whatever has already been scrolled into + // view. Falls back to no-search when the box is empty. + const searchParam = debouncedSearch.trim() || undefined; const { data: contentResult, isLoading: contentLoading } = useQuery({ - queryKey: ["content-picker", selectedCollection, { limit: 50 }], - queryFn: () => fetchContentList(selectedCollection, { limit: 50 }), + queryKey: ["content-picker", selectedCollection, { limit: 50, search: searchParam }], + queryFn: () => fetchContentList(selectedCollection, { limit: 50, search: searchParam }), enabled: open && !!selectedCollection, + // Keep the previous page's rows visible while the debounced search + // refetches, so the list doesn't flash to empty between keystrokes. + placeholderData: keepPreviousData, }); - // Sync initial page into accumulated items + // Sync initial page into accumulated items. The query re-runs when the + // debounced search changes, so we reset the accumulator each time. React.useEffect(() => { if (contentResult) { setAllItems(contentResult.items); @@ -76,6 +84,7 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick const result = await fetchContentList(selectedCollection, { limit: 50, cursor: nextCursor, + search: searchParam, }); setAllItems((prev) => [...prev, ...result.items]); setNextCursor(result.nextCursor); @@ -84,11 +93,8 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick } }; - const filteredItems = React.useMemo(() => { - if (!debouncedSearch) return allItems; - const query = debouncedSearch.toLowerCase(); - return allItems.filter((item) => getItemTitle(item).toLowerCase().includes(query)); - }, [allItems, debouncedSearch]); + // Items arrive pre-filtered from the server; alias for readability. + const filteredItems = allItems; // Reset state when modal opens or collection changes React.useEffect(() => { @@ -220,7 +226,9 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick ); })} - {nextCursor && !searchQuery && ( + {/* Search is server-side and paginated, so results can span + multiple pages too — keep load-more available while searching. */} + {nextCursor && (