diff --git a/src/components-collections.tsx b/src/components-collections.tsx index dfc38d8..a1e7dfe 100644 --- a/src/components-collections.tsx +++ b/src/components-collections.tsx @@ -70,6 +70,10 @@ export interface CollectionListProps { onCreate?: (ownerType: "account" | "tenant") => void; canManageTenant?: boolean; onOpen?: (collection: Collection) => void; + /** When set, renders a "Load more" affordance (the list is paginated). */ + onLoadMore?: () => void; + hasMore?: boolean; + loadingMore?: boolean; emptyState?: ReactNode; className?: string; } @@ -81,6 +85,9 @@ export const CollectionList: FC = ({ onCreate, canManageTenant = false, onOpen, + onLoadMore, + hasMore, + loadingMore, emptyState, className, }) => { @@ -112,6 +119,18 @@ export const CollectionList: FC = ({ emptyHint={org.length === 0 ? "No shared corpuses yet." : undefined} /> )} + {hasMore && onLoadMore && ( + + )} ); }; diff --git a/src/components-search.tsx b/src/components-search.tsx new file mode 100644 index 0000000..9c04285 --- /dev/null +++ b/src/components-search.tsx @@ -0,0 +1,151 @@ +// PURPOSE: Corpus search (spec §5) — the input + results panel, lifted out of +// the app so every consumer (embedded + standalone) renders search the +// same way instead of hand-rolling hit rendering. Owns query state, +// paginated results, loading/error/clear; shows the caller's `browse` +// content when there is no active search. Pure: the actual search runs +// through an injected `onSearch` callback. +// =========================================================================== +import { useState, type FC, type FormEvent, type ReactNode } from "react"; +import { Loader2, Search, X } from "lucide-react"; +import type { ObjectSummary, SearchHit, SearchPage } from "./types"; +import { ObjectList } from "./components-object"; + +function hitToSummary(h: SearchHit): ObjectSummary { + return { + object_id: h.object_id, + kind: h.kind, + title: h.title, + status: "active", + updated_at: h.updated_at ?? "", + effective_at: h.effective_at ?? null, + sensitivity: h.sensitivity, + }; +} + +export interface KnowledgeSearchProps { + /** Run a search and return a page; `cursor` drives "Load more". */ + onSearch: (query: string, cursor?: string) => Promise; + onOpen?: (objectId: string) => void; + placeholder?: string; + /** Shown below the input while there is no active search (the browse list). */ + browse?: ReactNode; + className?: string; +} + +export const KnowledgeSearch: FC = ({ + onSearch, + onOpen, + placeholder, + browse, + className, +}) => { + const [query, setQuery] = useState(""); + const [hits, setHits] = useState(null); + const [cursor, setCursor] = useState(null); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + + function clear() { + setHits(null); + setCursor(null); + setQuery(""); + setError(null); + } + + async function run(e?: FormEvent) { + e?.preventDefault(); + const q = query.trim(); + if (!q) { + clear(); + return; + } + setLoading(true); + setError(null); + try { + const page = await onSearch(q); + setHits(page.hits); + setCursor(page.next_cursor ?? null); + } catch { + setError("Search failed. Try again."); + } finally { + setLoading(false); + } + } + + async function loadMore() { + if (!cursor || loadingMore) return; + setLoadingMore(true); + try { + const page = await onSearch(query.trim(), cursor); + setHits((prev) => [...(prev ?? []), ...page.hits]); + setCursor(page.next_cursor ?? null); + } catch { + setError("Couldn't load more results. Try again."); + } finally { + setLoadingMore(false); + } + } + + return ( +
+
+
+ + setQuery(e.target.value)} + /> + {query && ( + + )} +
+
+ + {loading ? ( +

+ Searching… +

+ ) : error ? ( +

+ {error} +

+ ) : hits ? ( +
+
+ + {hits.length} result{hits.length === 1 ? "" : "s"} + + +
+ onOpen?.(o.object_id)} + onLoadMore={loadMore} + hasMore={!!cursor} + loadingMore={loadingMore} + emptyState={ +
+ No matches. +
+ } + /> +
+ ) : ( + (browse ?? null) + )} +
+ ); +}; diff --git a/src/index.tsx b/src/index.tsx index fd98eed..f56b011 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -35,3 +35,4 @@ export * from "./components-collections"; export * from "./components-object"; export * from "./components-claims"; export * from "./components-export"; +export * from "./components-search";