fix(kb-ui): P0 correctness — TextReader race/errors, outline nav, claims drop, object load-more #1
@@ -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>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user