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

Merged
jules merged 3 commits from fix/kb-ui-p0 into main 2026-07-09 12:16:12 +00:00
2 changed files with 117 additions and 46 deletions
Showing only changes of commit 66221afedc - Show all commits

View File

@@ -200,9 +200,12 @@ export const ClaimsReviewQueue: FC<ClaimsReviewQueueProps> = ({
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 ?? <Empty />}</>;
@@ -230,8 +233,8 @@ export const ClaimsReviewQueue: FC<ClaimsReviewQueueProps> = ({
))}
</Section>
<Section title="Awaiting review" count={unreviewed.length} hint="Visible already, labelled by tier — review at your pace.">
{unreviewed.map((c) => (
<Section title="Awaiting review" count={rest.length} hint="Visible already, labelled by tier — review at your pace.">
{rest.map((c) => (
<ClaimCard key={c.claim_id} claim={c} busy={busyId === c.claim_id} onConfirm={onConfirm} onReject={onReject} onReopen={onReopen} onOpenSource={onOpenSource} />
))}
</Section>

View File

@@ -32,14 +32,19 @@ 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<ObjectListProps> = ({ objects, onOpen, emptyState, className }) => {
export const ObjectList: FC<ObjectListProps> = ({ objects, onOpen, onLoadMore, hasMore, loadingMore, emptyState, className }) => {
if (objects.length === 0 && emptyState) return <>{emptyState}</>;
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) => (
<li key={o.object_id}>
<button
@@ -60,6 +65,19 @@ export const ObjectList: FC<ObjectListProps> = ({ objects, onOpen, emptyState, c
</li>
))}
</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 }) => {
const [slice, setSlice] = useState<TextSlice | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loadingMore, setLoadingMore] = useState(false);
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(
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<TextReaderProps> = ({ outline, readText, highlight,
}, [slice]);
async function loadMore() {
if (!slice?.range) return;
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<TextReaderProps> = ({ outline, readText, highlight,
</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;
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">
{renderWithHighlight(slice, highlight, markRef)}
</article>
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
{canReadMore && (
<button
type="button"
data-action="knowledge-read-more"
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>
)}
</div>
@@ -396,11 +452,23 @@ export const ObjectViewer: FC<ObjectViewerProps> = ({
!!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) => {
void resolveBlobUrl()
.then((u) => {
if (live) setBlob(u);
})
.catch(() => {
/* leave the placeholder frame; the download action still works */
});
}
return () => {