fix(kb-ui): P0 correctness — TextReader race/errors, outline nav, claims drop, object load-more

- 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) <noreply@anthropic.com>
This commit is contained in:
jules
2026-07-07 21:03:36 +10:00
parent efbf4b2e61
commit 66221afedc
2 changed files with 117 additions and 46 deletions

View File

@@ -200,9 +200,12 @@ export const ClaimsReviewQueue: FC<ClaimsReviewQueueProps> = ({
const inConflict = new Set(conflictGroups.flat().map((c) => c.claim_id)); 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 proposed = claims.filter((c) => c.status === "proposed" && !inConflict.has(c.claim_id));
const unreviewed = claims.filter( // Everything the queue returned that isn't a "proposed" card or a rendered
(c) => c.status === "active" && c.review_state === "unreviewed" && !inConflict.has(c.claim_id), // 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) { if (claims.length === 0) {
return <>{emptyState ?? <Empty />}</>; return <>{emptyState ?? <Empty />}</>;
@@ -230,8 +233,8 @@ export const ClaimsReviewQueue: FC<ClaimsReviewQueueProps> = ({
))} ))}
</Section> </Section>
<Section title="Awaiting review" count={unreviewed.length} hint="Visible already, labelled by tier — review at your pace."> <Section title="Awaiting review" count={rest.length} hint="Visible already, labelled by tier — review at your pace.">
{unreviewed.map((c) => ( {rest.map((c) => (
<ClaimCard key={c.claim_id} claim={c} busy={busyId === c.claim_id} onConfirm={onConfirm} onReject={onReject} onReopen={onReopen} onOpenSource={onOpenSource} /> <ClaimCard key={c.claim_id} claim={c} busy={busyId === c.claim_id} onConfirm={onConfirm} onReject={onReject} onReopen={onReopen} onOpenSource={onOpenSource} />
))} ))}
</Section> </Section>

View File

@@ -32,14 +32,19 @@ import {
export interface ObjectListProps { export interface ObjectListProps {
objects: ObjectSummary[]; objects: ObjectSummary[];
onOpen?: (object: ObjectSummary) => void; onOpen?: (object: ObjectSummary) => void;
/** When set, renders a "Load more" affordance (the list is paginated). */
onLoadMore?: () => void;
hasMore?: boolean;
loadingMore?: boolean;
emptyState?: ReactNode; emptyState?: ReactNode;
className?: string; className?: string;
} }
export const ObjectList: FC<ObjectListProps> = ({ objects, onOpen, emptyState, className }) => { export const ObjectList: FC<ObjectListProps> = ({ objects, onOpen, onLoadMore, hasMore, loadingMore, emptyState, className }) => {
if (objects.length === 0 && emptyState) return <>{emptyState}</>; if (objects.length === 0 && emptyState) return <>{emptyState}</>;
return ( return (
<ul className={cn("flex flex-col divide-y divide-border rounded-xl border border-border bg-card", className)}> <div className={cn("flex flex-col gap-3", className)}>
<ul className="flex flex-col divide-y divide-border rounded-xl border border-border bg-card">
{objects.map((o) => ( {objects.map((o) => (
<li key={o.object_id}> <li key={o.object_id}>
<button <button
@@ -60,6 +65,19 @@ export const ObjectList: FC<ObjectListProps> = ({ objects, onOpen, emptyState, c
</li> </li>
))} ))}
</ul> </ul>
{hasMore && onLoadMore && (
<button
type="button"
data-action="knowledge-load-more-objects"
onClick={onLoadMore}
disabled={loadingMore}
className="inline-flex items-center justify-center gap-1.5 self-center rounded-lg border border-border px-3 py-1.5 text-sm text-muted-foreground transition hover:bg-muted disabled:opacity-50"
>
{loadingMore && <Loader2 className="size-3.5 animate-spin" />}
{loadingMore ? "Loading…" : "Load more"}
</button>
)}
</div>
); );
}; };
@@ -237,28 +255,41 @@ export interface TextReaderProps {
export const TextReader: FC<TextReaderProps> = ({ outline, readText, highlight, activeSection, className }) => { export const TextReader: FC<TextReaderProps> = ({ outline, readText, highlight, activeSection, className }) => {
const [slice, setSlice] = useState<TextSlice | null>(null); const [slice, setSlice] = useState<TextSlice | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loadingMore, setLoadingMore] = useState(false);
const markRef = useRef<HTMLSpanElement | null>(null); const markRef = useRef<HTMLSpanElement | null>(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( const load = useCallback(
async (sel?: { section?: string; start?: number; end?: number }) => { async (sel?: { section?: string; start?: number; end?: number }) => {
const req = ++reqRef.current;
setLoading(true); setLoading(true);
setError(null);
try { 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 { } finally {
setLoading(false); if (reqRef.current === req) setLoading(false);
} }
}, },
[readText], [readText],
); );
// Initial + reactive loads: prefer an explicit highlight span, then an active // Initial + reactive loads: an explicit outline selection wins (so clicking a
// section, else the head of the document. // section works even on a citation-opened view), then the highlight span,
// else the head of the document.
useEffect(() => { 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); 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 }); 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 { } else {
void load(); void load();
} }
@@ -270,13 +301,20 @@ export const TextReader: FC<TextReaderProps> = ({ outline, readText, highlight,
}, [slice]); }, [slice]);
async function loadMore() { async function loadMore() {
if (!slice?.range) return; if (loadingMore || !slice?.range) return;
setLoadingMore(true);
try {
const next = await readText({ start: slice.range.end }); const next = await readText({ start: slice.range.end });
setSlice((prev) => setSlice((prev) =>
prev && prev.range && next.range prev && prev.range && next.range
? { ...next, text: (prev.text ?? "") + (next.text ?? ""), range: { start: prev.range.start, end: next.range.end } } ? { ...next, text: (prev.text ?? "") + (next.text ?? ""), range: { start: prev.range.start, end: next.range.end } }
: next, : next,
); );
} catch {
setError("Couldn't load more text. Try again.");
} finally {
setLoadingMore(false);
}
} }
if (loading && !slice) { if (loading && !slice) {
@@ -286,6 +324,21 @@ export const TextReader: FC<TextReaderProps> = ({ outline, readText, highlight,
</div> </div>
); );
} }
if (error && !slice) {
return (
<div className={cn("py-8 text-sm", className)}>
<p className="text-destructive">{error}</p>
<button
type="button"
data-action="knowledge-text-retry"
onClick={() => void load(activeSection ? { section: activeSection } : undefined)}
className="mt-2 rounded-lg border border-border px-3 py-1.5 text-sm text-muted-foreground transition hover:bg-muted"
>
Retry
</button>
</div>
);
}
if (!slice) return null; if (!slice) return null;
const canReadMore = outline.extracted_chars != null && slice.range != null && slice.range.end < outline.extracted_chars; const canReadMore = outline.extracted_chars != null && slice.range != null && slice.range.end < outline.extracted_chars;
@@ -295,14 +348,17 @@ export const TextReader: FC<TextReaderProps> = ({ outline, readText, highlight,
<article className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground"> <article className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground">
{renderWithHighlight(slice, highlight, markRef)} {renderWithHighlight(slice, highlight, markRef)}
</article> </article>
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
{canReadMore && ( {canReadMore && (
<button <button
type="button" type="button"
data-action="knowledge-read-more" data-action="knowledge-read-more"
onClick={() => void loadMore()} onClick={() => void loadMore()}
className="mt-3 rounded-lg border border-border px-3 py-1.5 text-sm text-muted-foreground transition hover:bg-muted" disabled={loadingMore}
className="mt-3 inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-muted-foreground transition hover:bg-muted disabled:opacity-50"
> >
Read more {loadingMore && <Loader2 className="size-3.5 animate-spin" />}
{loadingMore ? "Loading…" : "Read more"}
</button> </button>
)} )}
</div> </div>
@@ -396,11 +452,23 @@ export const ObjectViewer: FC<ObjectViewerProps> = ({
!!actions && !!actions &&
(actions.onEditMeta || actions.onVerify || actions.onDownload || actions.onArchive || actions.onSupersede || actions.onExtractClaims); (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(() => { useEffect(() => {
let live = true; let live = true;
setBlob(null);
if (isImage && outline.has_blob && resolveBlobUrl) { if (isImage && outline.has_blob && resolveBlobUrl) {
void resolveBlobUrl().then((u) => { void resolveBlobUrl()
.then((u) => {
if (live) setBlob(u); if (live) setBlob(u);
})
.catch(() => {
/* leave the placeholder frame; the download action still works */
}); });
} }
return () => { return () => {