diff --git a/templates/content/actions/document-discovery.db.test.ts b/templates/content/actions/document-discovery.db.test.ts index 0284b2f831a..b7d849a4251 100644 --- a/templates/content/actions/document-discovery.db.test.ts +++ b/templates/content/actions/document-discovery.db.test.ts @@ -2,6 +2,7 @@ import { rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { closeDbExec } from "@agent-native/core/db"; import { runWithRequestContext } from "@agent-native/core/server"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -79,11 +80,165 @@ beforeAll(async () => { } }, 60_000); -afterAll(() => { +afterAll(async () => { + await closeDbExec(); rmSync(TEST_DB_PATH, { force: true, recursive: true }); }); describe("bounded document discovery", () => { + it("matches case-insensitively while treating wildcard input literally", async () => { + await getDb().insert(schema.documents).values({ + id: "search-literal", + ownerEmail: OWNER, + title: "Literal 100%_ Match", + }); + const result = await asUser(OWNER, () => + searchDocuments.run({ + query: "literal 100%_ match", + searchFields: "title", + limit: 8, + offset: 0, + }), + ); + expect(result.documents.map((doc) => doc.id)).toEqual(["search-literal"]); + const ordinary = await asUser(OWNER, () => + searchDocuments.run({ + query: "BOUNDED DOCUMENT", + searchFields: "title", + limit: 8, + offset: 0, + }), + ); + expect(ordinary.pagination.totalItems).toBe(203); + }); + it("filters title and modified date before pagination and returns authorized parent context", async () => { + const first = await asUser(OWNER, () => + searchDocuments.run({ + query: "Bounded document", + searchFields: "title", + spaceId: SPACE_ID, + modifiedAfter: "2020-01-01T00:00:00.000Z", + modifiedBefore: "2100-01-01T00:00:00.000Z", + documentType: "page", + limit: 8, + offset: 0, + }), + ); + const later = await asUser(OWNER, () => + searchDocuments.run({ + query: "Bounded document", + searchFields: "title", + spaceId: SPACE_ID, + modifiedAfter: "2020-01-01T00:00:00.000Z", + modifiedBefore: "2100-01-01T00:00:00.000Z", + documentType: "page", + limit: 8, + offset: first.pagination.nextOffset!, + }), + ); + expect(first.pagination.totalItems).toBe(203); + expect(later.documents).toHaveLength(8); + expect( + later.documents.some((doc) => + first.documents.some((prior) => prior.id === doc.id), + ), + ).toBe(false); + expect(first.documents[0]).toMatchObject({ + parentTitle: "Discovery parent", + documentType: "page", + }); + const bodyOnly = await asUser(OWNER, () => + searchDocuments.run({ + query: "needle payload", + searchFields: "title", + limit: 8, + offset: 0, + }), + ); + expect(bodyOnly.pagination.totalItems).toBe(0); + const future = await asUser(OWNER, () => + searchDocuments.run({ + query: "Bounded document", + modifiedAfter: "2100-01-01T00:00:00.000Z", + limit: 8, + offset: 0, + }), + ); + expect(future.pagination.totalItems).toBe(0); + }); + + it("does not disclose a private parent through an independently visible child", async () => { + await getDb().insert(schema.documents).values({ + id: "search-shared-child", + parentId: PARENT_ID, + ownerEmail: OUTSIDER, + title: "Independent child match", + content: "child excerpt", + visibility: "private", + }); + const result = await asUser(OUTSIDER, () => + searchDocuments.run({ + query: "Independent child match", + limit: 8, + offset: 0, + }), + ); + expect(result.documents).toHaveLength(1); + expect(result.documents[0]).toMatchObject({ + parentId: null, + parentTitle: null, + snippet: "child excerpt", + }); + expect(JSON.stringify(result)).not.toContain("Discovery parent"); + expect(JSON.stringify(result)).not.toContain(PARENT_ID); + }); + + it("counts and paginates hidden and database matches in the Action", async () => { + await getDb() + .insert(schema.documents) + .values([ + { + id: "search-hidden", + ownerEmail: OWNER, + title: "Kind needle hidden", + hideFromSearch: 1, + }, + { + id: "search-kind-page", + ownerEmail: OWNER, + title: "Kind needle page", + }, + { + id: "search-kind-db", + ownerEmail: OWNER, + title: "Kind needle database", + }, + ]); + await getDb().insert(schema.contentDatabases).values({ + id: "search-kind-database", + documentId: "search-kind-db", + ownerEmail: OWNER, + title: "Kind needle database", + }); + const all = await asUser(OWNER, () => + searchDocuments.run({ query: "Kind needle", limit: 8, offset: 0 }), + ); + expect(all.pagination.totalItems).toBe(2); + const database = await asUser(OWNER, () => + searchDocuments.run({ + query: "Kind needle", + documentType: "database", + limit: 8, + offset: 0, + }), + ); + expect(database.pagination.totalItems).toBe(1); + expect(database.documents[0]).toMatchObject({ + id: "search-kind-db", + documentType: "database", + }); + }); + it("returns explicit continuation metadata through a terminal list page", async () => { const first = await asUser(OWNER, () => listDocuments.run({ parentId: PARENT_ID, limit: 100, offset: 0 }), diff --git a/templates/content/actions/search-documents.ts b/templates/content/actions/search-documents.ts index d55d00df593..3418bb88c82 100644 --- a/templates/content/actions/search-documents.ts +++ b/templates/content/actions/search-documents.ts @@ -3,7 +3,19 @@ import { getRequestOrgId, getRequestUserEmail, } from "@agent-native/core/server/request-context"; -import { asc, desc, sql } from "drizzle-orm"; +import { + and, + asc, + desc, + eq, + exists, + gte, + inArray, + isNull, + lt, + or, + sql, +} from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -66,6 +78,18 @@ export default defineAction({ .enum(["page", "database"]) .optional() .describe("Only ordinary pages or database pages"), + searchFields: z + .enum(["all", "title"]) + .optional() + .describe("Match title only, or title, description and body (default)"), + modifiedAfter: z.iso + .datetime() + .optional() + .describe("Modified at or after this UTC timestamp"), + modifiedBefore: z.iso + .datetime() + .optional() + .describe("Modified before this UTC timestamp"), limit: z.coerce .number() .int() @@ -109,9 +133,25 @@ export default defineAction({ parentId: args.parentId, spaceId: args.spaceId, documentType: args.documentType, - additional: pattern - ? sql`(${schema.documents.title} LIKE ${pattern} ESCAPE '\\' OR ${schema.documents.description} LIKE ${pattern} ESCAPE '\\' OR ${schema.documents.content} LIKE ${pattern} ESCAPE '\\')` - : undefined, + additional: and( + args.query + ? or( + eq(schema.documents.hideFromSearch, 0), + isNull(schema.documents.hideFromSearch), + ) + : undefined, + pattern + ? args.searchFields === "title" + ? sql`${schema.documents.title} ILIKE ${pattern} ESCAPE '\\'` + : sql`(${schema.documents.title} ILIKE ${pattern} ESCAPE '\\' OR ${schema.documents.description} ILIKE ${pattern} ESCAPE '\\' OR ${schema.documents.content} ILIKE ${pattern} ESCAPE '\\')` + : undefined, + args.modifiedAfter + ? gte(schema.documents.updatedAt, args.modifiedAfter) + : undefined, + args.modifiedBefore + ? lt(schema.documents.updatedAt, args.modifiedBefore) + : undefined, + ), }); const [countRow] = await db .select({ count: sql`count(*)` }) @@ -138,6 +178,19 @@ export default defineAction({ contentLength: sql`length(${schema.documents.content})`, hideFromSearch: schema.documents.hideFromSearch, updatedAt: schema.documents.updatedAt, + sourceKind: schema.documents.sourceKind, + sourceUpdatedAt: schema.documents.sourceUpdatedAt, + documentType: sql<"page" | "database">`case when ${exists( + db + .select({ id: schema.contentDatabases.id }) + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.documentId, schema.documents.id), + isNull(schema.contentDatabases.deletedAt), + ), + ), + )} then 'database' else 'page' end`, }) .from(schema.documents) .where(where) @@ -145,10 +198,35 @@ export default defineAction({ .limit(args.limit) .offset(args.offset); + const parentIds = [ + ...new Set(docs.flatMap((doc) => (doc.parentId ? [doc.parentId] : []))), + ]; + const parents = parentIds.length + ? await db + .select({ id: schema.documents.id, title: schema.documents.title }) + .from(schema.documents) + .where( + documentDiscoveryWhere({ + userEmail, + authorizedOrgIds, + spaceId: args.spaceId, + additional: inArray(schema.documents.id, parentIds), + }), + ) + : []; + const parentById = new Map(parents.map((parent) => [parent.id, parent])); + return { documents: docs.map((doc) => ({ id: doc.id, - parentId: doc.parentId, + parentId: + doc.parentId && parentById.has(doc.parentId) ? doc.parentId : null, + parentTitle: doc.parentId + ? (parentById.get(doc.parentId)?.title ?? null) + : null, + documentType: doc.documentType, + sourceKind: doc.sourceKind, + sourceUpdatedAt: doc.sourceUpdatedAt, title: doc.title, description: doc.description, icon: doc.icon, diff --git a/templates/content/app/components/ContentCommandSearch.tsx b/templates/content/app/components/ContentCommandSearch.tsx new file mode 100644 index 00000000000..26edd6b9957 --- /dev/null +++ b/templates/content/app/components/ContentCommandSearch.tsx @@ -0,0 +1,382 @@ +import { useActionQuery } from "@agent-native/core/client/hooks"; +import { useFormatters, useT } from "@agent-native/core/client/i18n"; +import { CommandMenu } from "@agent-native/core/client/navigation"; +import { + IconDatabase, + IconFileText, + IconFolderOpen, +} from "@tabler/icons-react"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router"; + +import { useContentSpaces } from "@/hooks/use-content-spaces"; +import { useLocalStorage } from "@/hooks/use-local-storage"; +import { + contentCommandDocumentPath, + isLocalFileSearchResult, + searchHighlightParts, + type CommandSearchDocumentsResponse, +} from "@/lib/content-command-search"; + +import { + contentSpaceForStoredSelection, + SELECTED_CONTENT_SPACE_STORAGE_KEY, +} from "./sidebar/select-content-space"; +import { Button } from "./ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import { Skeleton } from "./ui/skeleton"; + +function Highlight({ text, query }: { text: string; query: string }) { + return searchHighlightParts(text, query).map((part, index) => + part.match ? ( + + {part.text} + + ) : ( + part.text + ), + ); +} + +function SearchChoice({ + label, + value, + choices, + onChange, +}: { + label: string; + value: string; + choices: { value: string; label: string }[]; + onChange: (value: string) => void; +}) { + return ( + + + + + + + {choices.map((choice) => ( + + {choice.label} + + ))} + + + + ); +} + +function SearchLoading() { + const t = useT(); + return ( +
+ {[0, 1, 2].map((index) => ( +
+ + +
+ ))} +
+ ); +} + +function focusSearchInput(control: HTMLElement) { + control + .closest('[role="dialog"]') + ?.querySelector('[role="combobox"]') + ?.focus(); +} + +function SearchPage({ + query, + spaceId, + searchFields, + documentType, + modifiedAfter, + onOpenChange, +}: { + query: string; + spaceId: string; + searchFields: "all" | "title"; + documentType?: "page" | "database"; + modifiedAfter?: string; + onOpenChange: (open: boolean) => void; +}) { + const t = useT(); + const navigate = useNavigate(); + const { formatDate } = useFormatters(); + const [offset, setOffset] = useState(0); + const results = useActionQuery( + "search-documents", + { + query, + spaceId, + searchFields, + documentType, + modifiedAfter, + limit: 20, + offset, + }, + { retry: false }, + ); + if (results.isFetching) return ; + if (results.error) + return ( +
+ {t("root.commandSearchError")} + +
+ ); + if (!results.data) return ; + return ( + <> + + {results.data.documents.length === 0 ? ( +
+ {t("root.commandSearchEmpty")} +
+ ) : null} + {results.data.documents.map((document) => { + const Icon = + document.documentType === "database" + ? IconDatabase + : isLocalFileSearchResult(document) + ? IconFolderOpen + : IconFileText; + return ( + { + onOpenChange(false); + void navigate(contentCommandDocumentPath(document.id)); + }} + > + + + + + + + {[ + document.parentTitle, + document.sourceKind, + t("root.searchModified", { + date: formatDate(document.updatedAt), + }), + ] + .filter(Boolean) + .join(" · ")} + + {document.snippet ? ( + + + + ) : null} + {document.description ? ( + + {document.description} + + ) : null} + {document.sourceUpdatedAt ? ( + + {t("root.searchSourceUpdated", { + date: formatDate(document.sourceUpdatedAt), + })} + + ) : null} + + + ); + })} +
+ {offset > 0 || results.data.pagination.hasMore ? ( +
{ + if (event.key === "Enter" || event.key === " ") + event.stopPropagation(); + }} + onClick={(event) => { + focusSearchInput(event.currentTarget); + }} + > + + +
+ ) : null} + + ); +} + +export function ContentCommandSearchResults({ + query, + onOpenChange, +}: { + query: string; + onOpenChange: (open: boolean) => void; +}) { + const t = useT(); + const spaces = useContentSpaces(); + const [storedSpaceId] = useLocalStorage( + SELECTED_CONTENT_SPACE_STORAGE_KEY, + null, + ); + const [chosenSpace, setChosenSpace] = useState(null); + const selectedSpace = contentSpaceForStoredSelection({ + spaces: spaces.data?.spaces ?? [], + storedSpaceId, + }); + const space = chosenSpace + ? spaces.data?.spaces.find((entry) => entry.id === chosenSpace) + : selectedSpace; + const [searchFields, setSearchFields] = useState("all"); + const [documentType, setDocumentType] = useState("all"); + const [modified, setModified] = useState("all"); + const [modifiedAfter, setModifiedAfter] = useState(); + const [debouncedQuery, setDebouncedQuery] = useState(query.trim()); + useEffect(() => { + const timer = window.setTimeout(() => setDebouncedQuery(query.trim()), 200); + return () => window.clearTimeout(timer); + }, [query]); + + return ( + <> +
{ + if (event.key !== "Tab" && event.key !== "Escape") + event.stopPropagation(); + }} + > + ({ + value: entry.id, + label: entry.name, + }))} + onChange={setChosenSpace} + /> + + + { + setModified(value); + setModifiedAfter( + value === "all" + ? undefined + : new Date( + Date.now() - Number(value) * 86_400_000, + ).toISOString(), + ); + }} + /> +
+ {spaces.error || (!spaces.isLoading && !space) ? ( +
+ {t("root.searchScopeUnavailable")} +
+ ) : !space || query.trim() !== debouncedQuery ? ( + + ) : debouncedQuery ? ( + + ) : null} + + ); +} diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index e09a8bff793..fec97ef3177 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -3140,6 +3140,22 @@ const enUS = { commandContent: "Content", commandSearchDocuments: "Search documents", commandSearchHeading: "Search", + searchScope: "Scope", + searchFields: "Search fields", + searchAllText: "All text", + searchTitleOnly: "Title only", + searchType: "Object type", + searchAllTypes: "All types", + searchDate: "Modified date", + searchAnyDate: "Any date", + searchPastWeek: "Past week", + searchPastMonth: "Past month", + searchPrevious: "Previous results", + searchNext: "Next results", + searchRetry: "Try again", + searchScopeUnavailable: "Search scope is unavailable.", + searchModified: "Modified {{date}}", + searchSourceUpdated: "Source updated {{date}}", commandSearchPlaceholder: "Search documents and databases...", commandSearchLoading: "Searching...", commandSearchError: "Search is unavailable right now.", @@ -10109,6 +10125,22 @@ export const messagesByLocale = { commandContent: "内容", commandSearchDocuments: "搜索文档", commandSearchHeading: "搜索", + searchScope: "范围", + searchFields: "搜索字段", + searchAllText: "全部文本", + searchTitleOnly: "仅标题", + searchType: "对象类型", + searchAllTypes: "全部类型", + searchDate: "修改日期", + searchAnyDate: "任何日期", + searchPastWeek: "过去一周", + searchPastMonth: "过去一个月", + searchPrevious: "上一页结果", + searchNext: "下一页结果", + searchRetry: "重试", + searchScopeUnavailable: "搜索范围不可用。", + searchModified: "修改于 {{date}}", + searchSourceUpdated: "来源更新于 {{date}}", commandSearchPlaceholder: "搜索文档和数据库...", commandSearchLoading: "正在搜索...", commandSearchError: "搜索当前不可用。", @@ -10306,6 +10338,22 @@ export const messagesByLocale = { commandContent: "Contenido", commandSearchDocuments: "Buscar documentos", commandSearchHeading: "Buscar", + searchScope: "Ámbito", + searchFields: "Campos de búsqueda", + searchAllText: "Todo el texto", + searchTitleOnly: "Solo título", + searchType: "Tipo de objeto", + searchAllTypes: "Todos los tipos", + searchDate: "Fecha de modificación", + searchAnyDate: "Cualquier fecha", + searchPastWeek: "Última semana", + searchPastMonth: "Último mes", + searchPrevious: "Resultados anteriores", + searchNext: "Resultados siguientes", + searchRetry: "Reintentar", + searchScopeUnavailable: "El ámbito de búsqueda no está disponible.", + searchModified: "Modificado {{date}}", + searchSourceUpdated: "Fuente actualizada {{date}}", commandSearchPlaceholder: "Buscar documentos y bases de datos...", commandSearchLoading: "Buscando...", commandSearchError: "La búsqueda no está disponible ahora.", @@ -10515,6 +10563,22 @@ export const messagesByLocale = { commandContent: "Contenu", commandSearchDocuments: "Rechercher des documents", commandSearchHeading: "Rechercher", + searchScope: "Périmètre", + searchFields: "Champs de recherche", + searchAllText: "Tout le texte", + searchTitleOnly: "Titre uniquement", + searchType: "Type d’objet", + searchAllTypes: "Tous les types", + searchDate: "Date de modification", + searchAnyDate: "Toute date", + searchPastWeek: "Dernière semaine", + searchPastMonth: "Dernier mois", + searchPrevious: "Résultats précédents", + searchNext: "Résultats suivants", + searchRetry: "Réessayer", + searchScopeUnavailable: "Le périmètre de recherche est indisponible.", + searchModified: "Modifié le {{date}}", + searchSourceUpdated: "Source mise à jour le {{date}}", commandSearchPlaceholder: "Rechercher des documents et bases de données...", commandSearchLoading: "Recherche...", @@ -10723,6 +10787,22 @@ export const messagesByLocale = { commandContent: "Inhalt", commandSearchDocuments: "Dokumente suchen", commandSearchHeading: "Suchen", + searchScope: "Bereich", + searchFields: "Suchfelder", + searchAllText: "Gesamter Text", + searchTitleOnly: "Nur Titel", + searchType: "Objekttyp", + searchAllTypes: "Alle Typen", + searchDate: "Änderungsdatum", + searchAnyDate: "Beliebiges Datum", + searchPastWeek: "Letzte Woche", + searchPastMonth: "Letzter Monat", + searchPrevious: "Vorherige Ergebnisse", + searchNext: "Nächste Ergebnisse", + searchRetry: "Erneut versuchen", + searchScopeUnavailable: "Der Suchbereich ist nicht verfügbar.", + searchModified: "Geändert {{date}}", + searchSourceUpdated: "Quelle aktualisiert {{date}}", commandSearchPlaceholder: "Dokumente und Datenbanken suchen...", commandSearchLoading: "Suche...", commandSearchError: "Die Suche ist derzeit nicht verfügbar.", @@ -10930,6 +11010,22 @@ export const messagesByLocale = { commandContent: "コンテンツ", commandSearchDocuments: "ドキュメントを検索", commandSearchHeading: "検索", + searchScope: "検索範囲", + searchFields: "検索フィールド", + searchAllText: "すべてのテキスト", + searchTitleOnly: "タイトルのみ", + searchType: "オブジェクトの種類", + searchAllTypes: "すべての種類", + searchDate: "更新日", + searchAnyDate: "すべての日付", + searchPastWeek: "過去1週間", + searchPastMonth: "過去1か月", + searchPrevious: "前の検索結果", + searchNext: "次の検索結果", + searchRetry: "再試行", + searchScopeUnavailable: "検索範囲を利用できません。", + searchModified: "更新日 {{date}}", + searchSourceUpdated: "ソース更新日 {{date}}", commandSearchPlaceholder: "ドキュメントとデータベースを検索...", commandSearchLoading: "検索中...", commandSearchError: "現在、検索を利用できません。", @@ -11133,6 +11229,22 @@ export const messagesByLocale = { commandContent: "콘텐츠", commandSearchDocuments: "문서 검색", commandSearchHeading: "검색", + searchScope: "검색 범위", + searchFields: "검색 필드", + searchAllText: "전체 텍스트", + searchTitleOnly: "제목만", + searchType: "개체 유형", + searchAllTypes: "모든 유형", + searchDate: "수정 날짜", + searchAnyDate: "모든 날짜", + searchPastWeek: "지난주", + searchPastMonth: "지난달", + searchPrevious: "이전 결과", + searchNext: "다음 결과", + searchRetry: "다시 시도", + searchScopeUnavailable: "검색 범위를 사용할 수 없습니다.", + searchModified: "수정일 {{date}}", + searchSourceUpdated: "소스 업데이트 {{date}}", commandSearchPlaceholder: "문서와 데이터베이스 검색...", commandSearchLoading: "검색 중...", commandSearchError: "지금은 검색을 사용할 수 없습니다.", @@ -11332,6 +11444,22 @@ export const messagesByLocale = { commandContent: "Conteúdo", commandSearchDocuments: "Buscar documentos", commandSearchHeading: "Buscar", + searchScope: "Escopo", + searchFields: "Campos de pesquisa", + searchAllText: "Todo o texto", + searchTitleOnly: "Somente título", + searchType: "Tipo de objeto", + searchAllTypes: "Todos os tipos", + searchDate: "Data de modificação", + searchAnyDate: "Qualquer data", + searchPastWeek: "Última semana", + searchPastMonth: "Último mês", + searchPrevious: "Resultados anteriores", + searchNext: "Próximos resultados", + searchRetry: "Tentar novamente", + searchScopeUnavailable: "O escopo de pesquisa está indisponível.", + searchModified: "Modificado em {{date}}", + searchSourceUpdated: "Fonte atualizada em {{date}}", commandSearchPlaceholder: "Buscar documentos e bancos de dados...", commandSearchLoading: "Buscando...", commandSearchError: "A busca não está disponível agora.", @@ -11535,6 +11663,22 @@ export const messagesByLocale = { commandContent: "कॉन्टेंट", commandSearchDocuments: "दस्तावेज़ खोजें", commandSearchHeading: "खोजें", + searchScope: "दायरा", + searchFields: "खोज फ़ील्ड", + searchAllText: "पूरा पाठ", + searchTitleOnly: "केवल शीर्षक", + searchType: "ऑब्जेक्ट प्रकार", + searchAllTypes: "सभी प्रकार", + searchDate: "बदलाव की तारीख", + searchAnyDate: "कोई भी तारीख", + searchPastWeek: "पिछला सप्ताह", + searchPastMonth: "पिछला महीना", + searchPrevious: "पिछले परिणाम", + searchNext: "अगले परिणाम", + searchRetry: "फिर प्रयास करें", + searchScopeUnavailable: "खोज का दायरा उपलब्ध नहीं है।", + searchModified: "{{date}} को बदला गया", + searchSourceUpdated: "स्रोत {{date}} को अपडेट हुआ", commandSearchPlaceholder: "दस्तावेज़ और डेटाबेस खोजें...", commandSearchLoading: "खोजा जा रहा है...", commandSearchError: "खोज अभी उपलब्ध नहीं है।", @@ -11727,6 +11871,22 @@ export const messagesByLocale = { commandContent: "المحتوى", commandSearchDocuments: "بحث في المستندات", commandSearchHeading: "بحث", + searchScope: "النطاق", + searchFields: "حقول البحث", + searchAllText: "كل النص", + searchTitleOnly: "العنوان فقط", + searchType: "نوع العنصر", + searchAllTypes: "كل الأنواع", + searchDate: "تاريخ التعديل", + searchAnyDate: "أي تاريخ", + searchPastWeek: "الأسبوع الماضي", + searchPastMonth: "الشهر الماضي", + searchPrevious: "النتائج السابقة", + searchNext: "النتائج التالية", + searchRetry: "إعادة المحاولة", + searchScopeUnavailable: "نطاق البحث غير متاح.", + searchModified: "عُدّل في {{date}}", + searchSourceUpdated: "حُدّث المصدر في {{date}}", commandSearchPlaceholder: "ابحث في المستندات وقواعد البيانات...", commandSearchLoading: "جارٍ البحث...", commandSearchError: "البحث غير متاح الآن.", diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts index 4d017de2485..e385141d196 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -6,6 +6,22 @@ const messages = { commandContent: "內容", commandSearchDocuments: "搜尋檔案", commandSearchHeading: "搜尋", + searchScope: "範圍", + searchFields: "搜尋欄位", + searchAllText: "全部文字", + searchTitleOnly: "僅標題", + searchType: "物件類型", + searchAllTypes: "全部類型", + searchDate: "修改日期", + searchAnyDate: "任何日期", + searchPastWeek: "過去一週", + searchPastMonth: "過去一個月", + searchPrevious: "上一頁結果", + searchNext: "下一頁結果", + searchRetry: "重試", + searchScopeUnavailable: "搜尋範圍無法使用。", + searchModified: "修改於 {{date}}", + searchSourceUpdated: "來源更新於 {{date}}", commandSearchPlaceholder: "搜尋文件和資料庫...", commandSearchLoading: "搜尋中...", commandSearchError: "搜尋目前無法使用。", diff --git a/templates/content/app/lib/content-command-search.test.ts b/templates/content/app/lib/content-command-search.test.ts index 8d78c818c59..43016332879 100644 --- a/templates/content/app/lib/content-command-search.test.ts +++ b/templates/content/app/lib/content-command-search.test.ts @@ -2,122 +2,29 @@ import { describe, expect, it } from "vitest"; import { contentCommandDocumentPath, - groupContentCommandSearchResults, - type CommandSearchDocumentResult, + searchHighlightParts, } from "./content-command-search"; -function document( - id: string, - title: string, - snippet = "", -): CommandSearchDocumentResult { - return { - id, - parentId: null, - title, - icon: null, - snippet, - contentLength: snippet.length, - hideFromSearch: false, - updatedAt: "2026-06-30T00:00:00.000Z", - }; -} - describe("content command search", () => { - it("groups documents, databases, and local-file results", () => { - const groups = groupContentCommandSearchResults({ - query: "launch", - documents: [ - document("doc-1", "Launch notes", "Body snippet"), - document( - "local-file:ZG9jcy9sYXVuY2gubWQ", - "Local launch note", - "Local", - ), - document("local-folder:docs", "Docs folder", "Folder"), - ], - databases: [ - { - databaseId: "db-1", - documentId: "db-doc-1", - spaceId: null, - title: "Launch calendar", - description: "", - }, - { - databaseId: "db-2", - documentId: "db-doc-2", - spaceId: null, - title: "Ideas", - description: "", - }, - ], - }); - - expect(groups.documents.map((doc) => doc.id)).toEqual(["doc-1"]); - expect(groups.localFiles.map((doc) => doc.id)).toEqual([ - "local-file:ZG9jcy9sYXVuY2gubWQ", - "local-folder:docs", - ]); - expect(groups.databases.map((database) => database.databaseId)).toEqual([ - "db-1", - ]); - }); - - it("uses document page routes for selectable results", () => { + it("preserves canonical page and local file routes", () => { expect(contentCommandDocumentPath("doc-1")).toBe("/page/doc-1"); - expect(contentCommandDocumentPath("local-file:ZG9jcy9sYXVuY2gubWQ")).toBe( - "/page/local-file:ZG9jcy9sYXVuY2gubWQ", - ); expect(contentCommandDocumentPath("local-file:docs/launch.md")).toBe( "/page/local-file:docs/launch.md", ); }); - - it("does not duplicate database-backed pages as document results", () => { - const groups = groupContentCommandSearchResults({ - query: "launch", - documents: [ - document("doc-1", "Launch notes"), - document("db-doc-1", "Launch calendar"), - ], - databases: [ - { - databaseId: "db-1", - documentId: "db-doc-1", - spaceId: null, - title: "Launch calendar", - description: "", - }, - ], - }); - - expect(groups.documents.map((doc) => doc.id)).toEqual(["doc-1"]); - expect(groups.databases.map((database) => database.documentId)).toEqual([ - "db-doc-1", + it("highlights literal repeated matches without interpreting markup or regex", () => { + expect(searchHighlightParts("A.b a.B", "a.b")).toEqual([ + { text: "", match: false }, + { text: "A.b", match: true }, + { text: " ", match: false }, + { text: "a.B", match: true }, + { text: "", match: false }, + ]); + expect(searchHighlightParts("No match", " ")).toEqual([ + { text: "No match", match: false }, + ]); + expect(searchHighlightParts("No match", "needle")).toEqual([ + { text: "No match", match: false }, ]); - }); - - it("excludes hidden documents from command search groups", () => { - const hiddenDocument = document("hidden-doc", "Hidden launch note"); - hiddenDocument.hideFromSearch = true; - const hiddenLocalFile = document( - "local-file:aGlkZGVuLmxhdW5jaC5tZA", - "Hidden local launch note", - ); - hiddenLocalFile.hideFromSearch = true; - - const groups = groupContentCommandSearchResults({ - query: "launch", - documents: [ - document("doc-1", "Launch notes"), - hiddenDocument, - hiddenLocalFile, - ], - databases: [], - }); - - expect(groups.documents.map((doc) => doc.id)).toEqual(["doc-1"]); - expect(groups.localFiles).toEqual([]); }); }); diff --git a/templates/content/app/lib/content-command-search.ts b/templates/content/app/lib/content-command-search.ts index 9a0fe1a4135..b4def6aeccb 100644 --- a/templates/content/app/lib/content-command-search.ts +++ b/templates/content/app/lib/content-command-search.ts @@ -1,8 +1,11 @@ -import type { ContentDatabaseSummary } from "@shared/api"; - export interface CommandSearchDocumentResult { id: string; parentId: string | null; + parentTitle: string | null; + description: string; + documentType: "page" | "database"; + sourceKind: string | null; + sourceUpdatedAt: string | null; title: string; icon: string | null; snippet: string; @@ -13,12 +16,14 @@ export interface CommandSearchDocumentResult { export interface CommandSearchDocumentsResponse { documents: CommandSearchDocumentResult[]; -} - -export interface ContentCommandSearchGroups { - documents: CommandSearchDocumentResult[]; - databases: ContentDatabaseSummary[]; - localFiles: CommandSearchDocumentResult[]; + pagination: { + offset: number; + limit: number; + totalItems: number; + returnedItems: number; + hasMore: boolean; + nextOffset: number | null; + }; } export function isLocalFileSearchResult( @@ -34,31 +39,21 @@ export function contentCommandDocumentPath(documentId: string) { return `/page/${documentId}`; } -export function groupContentCommandSearchResults(args: { - documents: CommandSearchDocumentResult[]; - databases: ContentDatabaseSummary[]; - query: string; -}): ContentCommandSearchGroups { - const needle = args.query.trim().toLowerCase(); - const visibleDocuments = args.documents.filter( - (document) => !document.hideFromSearch, - ); - const matchingDatabases = needle - ? args.databases - .filter((database) => database.title.toLowerCase().includes(needle)) - .slice(0, 6) - : []; - const databaseDocumentIds = new Set( - matchingDatabases.map((database) => database.documentId), - ); - - return { - documents: visibleDocuments.filter( - (document) => - !isLocalFileSearchResult(document) && - !databaseDocumentIds.has(document.id), - ), - databases: matchingDatabases, - localFiles: visibleDocuments.filter(isLocalFileSearchResult), - }; +export function searchHighlightParts(text: string, query: string) { + const needle = query.trim().toLowerCase(); + if (!needle) return [{ text, match: false }]; + const parts: { text: string; match: boolean }[] = []; + let cursor = 0; + const lower = text.toLowerCase(); + let index = lower.indexOf(needle); + while (index !== -1) { + if (index > cursor) + parts.push({ text: text.slice(cursor, index), match: false }); + parts.push({ text: text.slice(index, index + needle.length), match: true }); + cursor = index + needle.length; + index = lower.indexOf(needle, cursor); + } + if (cursor < text.length) + parts.push({ text: text.slice(cursor), match: false }); + return parts; } diff --git a/templates/content/app/root.tsx b/templates/content/app/root.tsx index 176dd0819cc..356c243306d 100644 --- a/templates/content/app/root.tsx +++ b/templates/content/app/root.tsx @@ -3,7 +3,6 @@ import { appPath } from "@agent-native/core/client/api-path"; import { AppProviders, createAgentNativeQueryClient, - useActionQuery, } from "@agent-native/core/client/hooks"; import { getLocaleInitScript, @@ -22,14 +21,9 @@ import { getThemeInitScript, } from "@agent-native/core/client/ui"; import { resolveLocaleFromRequest } from "@agent-native/core/server"; -import type { ListContentDatabasesResponse } from "@shared/api"; import { - IconDatabase, IconDeviceDesktop, IconHierarchy2, - IconFileText, - IconFolderOpen, - IconLoader2, IconMoon, IconSun, } from "@tabler/icons-react"; @@ -39,7 +33,6 @@ import { Suspense, useCallback, useEffect, - useMemo, useRef, useState, } from "react"; @@ -69,15 +62,11 @@ import { Toaster } from "@/components/ui/toaster"; import { AppToolkitProvider } from "@/components/ui/toolkit-provider"; import changelog from "../CHANGELOG.md?raw"; +import { ContentCommandSearchResults } from "./components/ContentCommandSearch"; import { LocalFolderLiveSync } from "./components/LocalFolderLiveSync"; import { useDbSync } from "./hooks/use-db-sync"; import { useNavigationState } from "./hooks/use-navigation-state"; import { i18nCatalog } from "./i18n"; -import { - contentCommandDocumentPath, - groupContentCommandSearchResults, - type CommandSearchDocumentsResponse, -} from "./lib/content-command-search"; import stylesheet from "./global.css?url"; import katexStylesheet from "katex/dist/katex.min.css?url"; @@ -241,7 +230,7 @@ function AppSetup() { return ; } -function ThemeToggleItem() { +function ThemeToggleItem({ query }: { query: string }) { const { theme, setTheme } = useTheme(); const t = useT(); const [selectedTheme, setSelectedTheme] = useState("system"); @@ -262,222 +251,27 @@ function ThemeToggleItem() { setTheme(next); }; - return ( - - - {t("root.toggleTheme")} - - {t(`theme.${activeOption.value}`)} - - - ); -} + if ( + query.trim() && + ![t("root.toggleTheme"), "theme", "dark", "light", "system", "mode"].some( + (label) => label.toLowerCase().includes(query.trim().toLowerCase()), + ) + ) + return null; -function CommandStateMessage({ - children, - icon, -}: { - children: React.ReactNode; - icon?: React.ReactNode; -}) { return ( -
- {icon} - {children} -
- ); -} - -function useDebouncedValue(value: T, delayMs: number) { - const [debouncedValue, setDebouncedValue] = useState(value); - - useEffect(() => { - const id = window.setTimeout(() => setDebouncedValue(value), delayMs); - return () => window.clearTimeout(id); - }, [delayMs, value]); - - return debouncedValue; -} - -function ContentCommandSearchResults({ - query, - onOpenChange, -}: { - query: string; - onOpenChange: (open: boolean) => void; -}) { - const t = useT(); - const navigate = useNavigate(); - const trimmedQuery = query.trim(); - const debouncedQuery = useDebouncedValue(trimmedQuery, 200); - const searchEnabled = debouncedQuery.length > 0; - const documentsQuery = useActionQuery( - "search-documents", - searchEnabled ? { query: debouncedQuery, limit: 8 } : undefined, - { enabled: searchEnabled, retry: false }, - ); - const databasesQuery = useActionQuery( - "list-content-databases", - searchEnabled ? { query: debouncedQuery, limit: 6 } : undefined, - { enabled: searchEnabled, retry: false, staleTime: 60_000 }, - ); - - const searchGroups = useMemo( - () => - groupContentCommandSearchResults({ - documents: documentsQuery.data?.documents ?? [], - databases: databasesQuery.data?.databases ?? [], - query: debouncedQuery, - }), - [ - databasesQuery.data?.databases, - documentsQuery.data?.documents, - debouncedQuery, - ], - ); - - if (!trimmedQuery) return null; - - const resultCount = - searchGroups.documents.length + - searchGroups.databases.length + - searchGroups.localFiles.length; - const isWaitingForDebounce = trimmedQuery !== debouncedQuery; - const isLoading = - (isWaitingForDebounce || - documentsQuery.isLoading || - databasesQuery.isLoading) && - resultCount === 0; - const error = documentsQuery.error ?? databasesQuery.error; - const hasResults = resultCount > 0; - - const openDocument = (documentId: string) => { - onOpenChange(false); - void navigate(contentCommandDocumentPath(documentId)); - }; - - if (isLoading) { - return ( - - } - > - {t("root.commandSearchLoading")} - - - ); - } - - if (error && !hasResults) { - return ( - - - {t("root.commandSearchError")} - - - ); - } - - if (!hasResults) { - return ( - - - {t("root.commandSearchEmpty")} - - - ); - } - - return ( - <> - {error ? ( - - - {t("root.commandSearchPartialError")} - - - ) : null} - - {searchGroups.documents.length > 0 ? ( - - {searchGroups.documents.map((document) => ( - openDocument(document.id)} - deferSelect={false} - className="items-start py-2" - > - - - - {document.title || t("sidebar.untitled")} - - {document.snippet ? ( - - {document.snippet} - - ) : null} - - - ))} - - ) : null} - - {searchGroups.databases.length > 0 ? ( - - {searchGroups.databases.map((database) => ( - openDocument(database.documentId)} - deferSelect={false} - className="items-start py-2" - > - - - - {database.title || t("sidebar.untitled")} - - - {t("root.commandDatabaseResultDescription")} - - - - ))} - - ) : null} - - {searchGroups.localFiles.length > 0 ? ( - - {searchGroups.localFiles.map((document) => ( - openDocument(document.id)} - deferSelect={false} - className="items-start py-2" - > - - - - {document.title || t("sidebar.untitled")} - - {document.snippet ? ( - - {document.snippet} - - ) : null} - - - ))} - - ) : null} - + + + + {t("root.toggleTheme")} + + {t(`theme.${activeOption.value}`)} + + + ); } @@ -540,13 +334,18 @@ function ContentCommandMenu({ open={open} onOpenChange={onOpenChange} placeholder={t("root.commandSearchPlaceholder")} + className="w-[calc(100%-1rem)] max-w-xl" + showAgentFallback={false} changelog={changelog} changelogKey="content" renderResults={(search) => ( - + <> + + + )} > @@ -555,9 +354,6 @@ function ContentCommandMenu({ {t("root.openAgent")} - - - ); } diff --git a/templates/content/changelog/2026-09-09-search-picker-filters.md b/templates/content/changelog/2026-09-09-search-picker-filters.md new file mode 100644 index 00000000000..078baf4a4ae --- /dev/null +++ b/templates/content/changelog/2026-09-09-search-picker-filters.md @@ -0,0 +1,5 @@ +--- +type: improved +date: 2026-09-09 +--- +Search pages and databases with scoped filters, result previews, and keyboard navigation across result pages. diff --git a/templates/content/docs/product/capabilities/content.knowledge.search.md b/templates/content/docs/product/capabilities/content.knowledge.search.md index 0e9870c5082..d0f90d223c8 100644 --- a/templates/content/docs/product/capabilities/content.knowledge.search.md +++ b/templates/content/docs/product/capabilities/content.knowledge.search.md @@ -19,7 +19,12 @@ proof_requirements: "Cross-surface UI, Action, agent-context, reload, and failure-state coverage", "Real-interface keyboard and assistive-technology workflow coverage", ] -evidence: [] +evidence: + [ + "../../../actions/document-discovery.db.test.ts", + "../../../app/lib/content-command-search.test.ts", + "../../../app/components/ContentCommandSearch.tsx", + ] superseded_by: null last_reviewed: "2026-07-29" --- @@ -56,6 +61,23 @@ Given a stale connected Source result, when it appears, then its freshness state Existing search paths are in progress donor substrate, but complete indexed, freshness, ranking, and agent-parity proof is incomplete. This Capability remains `in_progress`. +The command picker uses the shared paginated search Action, with title-only, +document-type and modified-date predicates applied before pagination. Parent +context is independently access-scoped. Focused tests cover later pages, +private-parent suppression, hidden-result counts and type filters. This is +bounded lexical search substrate, not proof of indexed retrieval across every +Source, authoritative author filtering, or policy-governed global search. + +Local browser verification of the command picker covered 27 authorized matches +across two result pages, equal-title parent context, keyboard opening, title/body +and type/date filters, explicit request failure and retry. Pagination and retry +return focus to the search input. A reversible composition with the companion +sidebar controls change verified collapsed Search and the 390px mobile drawer: +the picker and filters remain visible above the drawer, Escape returns to Search, +and opening a result closes the drawer. This composed proof depends on that +companion's entry wiring and shared CommandMenu layer fix; it does not certify +screen-reader output or broader cross-context retrieval policy. + ## Proof plan 1. Index titles, bodies, rows, and Sources; test lexical queries, snippets, highlights, and pages.