From c3dc237aa40c795a0aa57ec62d878ae3980b2eba Mon Sep 17 00:00:00 2001 From: jules Date: Wed, 8 Jul 2026 12:05:07 +1000 Subject: [PATCH 1/2] feat(kb-ui): lift search + collection load-more into the lib Both consumers (embedded + the coming standalone) need the same search UI and paginated lists; keep them from diverging by owning both here. - KnowledgeSearch (components-search.tsx): the search input + paginated results panel, over an injected onSearch. Owns query/results/loading/error/clear and a "Load more" for search results; renders the caller's `browse` content when there is no active search. Replaces each app hand-rolling hit rendering. - CollectionList: onLoadMore/hasMore/loadingMore (mirrors ObjectList), so a paginated corpus list gets a load-more affordance. Stacked on fix/kb-ui-p0 (ObjectList already gained load-more there). tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components-collections.tsx | 19 +++++ src/components-search.tsx | 151 +++++++++++++++++++++++++++++++++ src/index.tsx | 1 + 3 files changed, 171 insertions(+) create mode 100644 src/components-search.tsx 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"; -- 2.48.1 From 03443590a2e74a4e673646078ede5d77b2c1e04e Mon Sep 17 00:00:00 2001 From: jules Date: Wed, 8 Jul 2026 13:38:22 +1000 Subject: [PATCH 2/2] fix(kb-ui): SensitivityBadge tolerates an unset/unknown level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A kb.list row can arrive without a sensitivity (a claim/note, or older data); SENSITIVITY[level] was then undefined and reading .tone crashed the whole object list. Fall back to a neutral badge — never defaulting an unknown value to "Open", which would misstate exposure. (Surfaced building the standalone webapp against a real corpus.) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/_internal.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/_internal.tsx b/src/_internal.tsx index 3da26c1..e6af34a 100644 --- a/src/_internal.tsx +++ b/src/_internal.tsx @@ -117,7 +117,16 @@ const SENSITIVITY: Record = ({ level, className }) => { - const s = SENSITIVITY[level]; + // A row can arrive without a sensitivity (e.g. a claim/note, or older data); + // fall back to a neutral badge rather than crash — and never default an + // unknown value to "Open", which would misstate its exposure. + const s = + SENSITIVITY[level] ?? { + label: level ?? "—", + tone: "bg-muted text-muted-foreground", + icon: null, + title: "Sensitivity not set.", + }; return ( {s.label} -- 2.48.1