From 66221afedc53d59ae949645f275b3ec4eb347985 Mon Sep 17 00:00:00 2001 From: jules Date: Tue, 7 Jul 2026 21:03:36 +1000 Subject: [PATCH 1/3] =?UTF-8?q?fix(kb-ui):=20P0=20correctness=20=E2=80=94?= =?UTF-8?q?=20TextReader=20race/errors,=20outline=20nav,=20claims=20drop,?= =?UTF-8?q?=20object=20load-more?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TextReader: monotonic request guard (stale slice can't clobber current) + catch with a retry surface instead of a blank panel / unhandled rejection; Read-more guarded and shows loading. - OutlineNav: an explicit section selection now wins over the initial citation highlight, so clicking sections works on ?start=&end= views (was inert). - ObjectViewer: reset section + image on object switch (no bleed across citation navigation); catch the blob resolve. - ObjectList: optional onLoadMore/hasMore/loadingMore (paginated corpuses). - ClaimsReviewQueue: catch-all "Awaiting review" bucket so a claim the queue returned is never silently dropped (e.g. an open-conflict singleton whose counterpart is not in the payload). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components-claims.tsx | 13 ++-- src/components-object.tsx | 150 +++++++++++++++++++++++++++----------- 2 files changed, 117 insertions(+), 46 deletions(-) diff --git a/src/components-claims.tsx b/src/components-claims.tsx index 1ef414e..8ecfaf6 100644 --- a/src/components-claims.tsx +++ b/src/components-claims.tsx @@ -200,9 +200,12 @@ export const ClaimsReviewQueue: FC = ({ const inConflict = new Set(conflictGroups.flat().map((c) => c.claim_id)); const proposed = claims.filter((c) => c.status === "proposed" && !inConflict.has(c.claim_id)); - const unreviewed = claims.filter( - (c) => c.status === "active" && c.review_state === "unreviewed" && !inConflict.has(c.claim_id), - ); + // Everything the queue returned that isn't a "proposed" card or a rendered + // conflict pair belongs in "Awaiting review". Filtering narrowly (active + + // unreviewed only) silently dropped anything else the endpoint surfaced — + // e.g. a claim flagged open-conflict whose counterpart isn't in this payload + // (a singleton group, not rendered as a pair) rendered nowhere at all. + const rest = claims.filter((c) => c.status !== "proposed" && !inConflict.has(c.claim_id)); if (claims.length === 0) { return <>{emptyState ?? }; @@ -230,8 +233,8 @@ export const ClaimsReviewQueue: FC = ({ ))} -
- {unreviewed.map((c) => ( +
+ {rest.map((c) => ( ))}
diff --git a/src/components-object.tsx b/src/components-object.tsx index cf8fa5e..f7409de 100644 --- a/src/components-object.tsx +++ b/src/components-object.tsx @@ -32,34 +32,52 @@ import { export interface ObjectListProps { objects: ObjectSummary[]; onOpen?: (object: ObjectSummary) => void; + /** When set, renders a "Load more" affordance (the list is paginated). */ + onLoadMore?: () => void; + hasMore?: boolean; + loadingMore?: boolean; emptyState?: ReactNode; className?: string; } -export const ObjectList: FC = ({ objects, onOpen, emptyState, className }) => { +export const ObjectList: FC = ({ objects, onOpen, onLoadMore, hasMore, loadingMore, emptyState, className }) => { if (objects.length === 0 && emptyState) return <>{emptyState}; return ( -
    - {objects.map((o) => ( -
  • - -
  • - ))} -
+
+
    + {objects.map((o) => ( +
  • + +
  • + ))} +
+ {hasMore && onLoadMore && ( + + )} +
); }; @@ -237,28 +255,41 @@ export interface TextReaderProps { export const TextReader: FC = ({ outline, readText, highlight, activeSection, className }) => { const [slice, setSlice] = useState(null); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [loadingMore, setLoadingMore] = useState(false); const markRef = useRef(null); + // Monotonic request id: rapid section/citation nav fires overlapping reads; + // only the newest may commit, so a slow earlier response can't clobber it. + const reqRef = useRef(0); const load = useCallback( async (sel?: { section?: string; start?: number; end?: number }) => { + const req = ++reqRef.current; setLoading(true); + setError(null); try { - setSlice(await readText(sel)); + const next = await readText(sel); + if (reqRef.current === req) setSlice(next); + } catch { + // A failed read must surface, not blank the panel (and never leak as an + // unhandled rejection). + if (reqRef.current === req) setError("Couldn't load this text. Try again."); } finally { - setLoading(false); + if (reqRef.current === req) setLoading(false); } }, [readText], ); - // Initial + reactive loads: prefer an explicit highlight span, then an active - // section, else the head of the document. + // Initial + reactive loads: an explicit outline selection wins (so clicking a + // section works even on a citation-opened view), then the highlight span, + // else the head of the document. useEffect(() => { - if (highlight) { + if (activeSection) { + void load({ section: activeSection }); + } else if (highlight) { const sec = outline.outline.find((s) => highlight.start >= s.start && highlight.end <= s.end); void load(sec ? { section: sec.id } : { start: Math.max(0, highlight.start - 200), end: highlight.end + 200 }); - } else if (activeSection) { - void load({ section: activeSection }); } else { void load(); } @@ -270,13 +301,20 @@ export const TextReader: FC = ({ outline, readText, highlight, }, [slice]); async function loadMore() { - if (!slice?.range) return; - const next = await readText({ start: slice.range.end }); - setSlice((prev) => - prev && prev.range && next.range - ? { ...next, text: (prev.text ?? "") + (next.text ?? ""), range: { start: prev.range.start, end: next.range.end } } - : next, - ); + if (loadingMore || !slice?.range) return; + setLoadingMore(true); + try { + const next = await readText({ start: slice.range.end }); + setSlice((prev) => + prev && prev.range && next.range + ? { ...next, text: (prev.text ?? "") + (next.text ?? ""), range: { start: prev.range.start, end: next.range.end } } + : next, + ); + } catch { + setError("Couldn't load more text. Try again."); + } finally { + setLoadingMore(false); + } } if (loading && !slice) { @@ -286,6 +324,21 @@ export const TextReader: FC = ({ outline, readText, highlight, ); } + if (error && !slice) { + return ( +
+

{error}

+ +
+ ); + } if (!slice) return null; const canReadMore = outline.extracted_chars != null && slice.range != null && slice.range.end < outline.extracted_chars; @@ -295,14 +348,17 @@ export const TextReader: FC = ({ outline, readText, highlight,
{renderWithHighlight(slice, highlight, markRef)}
+ {error &&

{error}

} {canReadMore && ( )} @@ -396,12 +452,24 @@ export const ObjectViewer: FC = ({ !!actions && (actions.onEditMeta || actions.onVerify || actions.onDownload || actions.onArchive || actions.onSupersede || actions.onExtractClaims); + // Navigating object→object (citation click) reuses this component: clear the + // per-object view state so a stale section selection or the previous image + // can't bleed into the new object. + useEffect(() => { + setActiveSection(undefined); + }, [outline.object_id]); + useEffect(() => { let live = true; + setBlob(null); if (isImage && outline.has_blob && resolveBlobUrl) { - void resolveBlobUrl().then((u) => { - if (live) setBlob(u); - }); + void resolveBlobUrl() + .then((u) => { + if (live) setBlob(u); + }) + .catch(() => { + /* leave the placeholder frame; the download action still works */ + }); } return () => { live = false; -- 2.48.1 From c3dc237aa40c795a0aa57ec62d878ae3980b2eba Mon Sep 17 00:00:00 2001 From: jules Date: Wed, 8 Jul 2026 12:05:07 +1000 Subject: [PATCH 2/3] 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 3/3] 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